mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
Material number as a first-class spool field (#2994)
This commit is contained in:
@@ -26,7 +26,8 @@ type EditableField =
|
||||
| 'label_weight'
|
||||
| 'core_weight'
|
||||
| 'category'
|
||||
| 'low_stock_threshold_pct';
|
||||
| 'low_stock_threshold_pct'
|
||||
| 'material_number';
|
||||
|
||||
type FieldSpec = {
|
||||
id: EditableField;
|
||||
@@ -43,6 +44,11 @@ type FieldSpec = {
|
||||
step?: number;
|
||||
/** Hex pattern for the rgba field. */
|
||||
pattern?: string;
|
||||
/** Lives on the internal spool row only. A Spoolman spool has no such
|
||||
* field, so SpoolmanInventoryUpdate drops it, the payload dumps to {} and
|
||||
* the route answers 400 "update must include at least one field". Offering
|
||||
* it in Spoolman mode is offering a button that cannot work. */
|
||||
internalOnly?: boolean;
|
||||
};
|
||||
|
||||
const FIELDS: FieldSpec[] = [
|
||||
@@ -58,8 +64,11 @@ const FIELDS: FieldSpec[] = [
|
||||
{ id: 'note', type: 'textarea', labelKey: 'inventory.note' },
|
||||
{ id: 'label_weight', type: 'number', labelKey: 'inventory.labelWeight', min: 1, step: 1 },
|
||||
{ id: 'core_weight', type: 'number', labelKey: 'inventory.coreWeight', min: 0, step: 1 },
|
||||
{ id: 'category', type: 'searchable', labelKey: 'inventory.category' },
|
||||
{ id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1 },
|
||||
{ id: 'category', type: 'searchable', labelKey: 'inventory.category', internalOnly: true },
|
||||
{ id: 'low_stock_threshold_pct', type: 'number', labelKey: 'inventory.lowStockThresholdOverride', min: 1, max: 99, step: 1, internalOnly: true },
|
||||
// Internal material / article number (#2870) — bulk-assigning it is the
|
||||
// main way existing inventories get numbered.
|
||||
{ id: 'material_number', type: 'searchable', labelKey: 'inventory.materialNumber', internalOnly: true },
|
||||
];
|
||||
|
||||
export interface BulkEditSpoolsModalProps {
|
||||
@@ -72,8 +81,11 @@ export interface BulkEditSpoolsModalProps {
|
||||
availableSubtypes: string[];
|
||||
availableBrands: string[];
|
||||
availableCategories: string[];
|
||||
availableMaterialNumbers: string[];
|
||||
availableSlicerFilaments: string[];
|
||||
availableSlicerFilamentNames: string[];
|
||||
/** Spoolman-backed inventory: hides the fields Spoolman cannot store. */
|
||||
spoolmanMode?: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (patch: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) => void;
|
||||
}
|
||||
@@ -210,10 +222,15 @@ function combineUnique(...lists: string[][]): string[] {
|
||||
export function BulkEditSpoolsModal({
|
||||
isOpen, selectedCount, isPending,
|
||||
availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories,
|
||||
availableSlicerFilaments, availableSlicerFilamentNames,
|
||||
availableMaterialNumbers, availableSlicerFilaments, availableSlicerFilamentNames,
|
||||
spoolmanMode = false,
|
||||
onClose, onApply,
|
||||
}: BulkEditSpoolsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fields = useMemo(
|
||||
() => FIELDS.filter((f) => !(spoolmanMode && f.internalOnly)),
|
||||
[spoolmanMode],
|
||||
);
|
||||
|
||||
// Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca
|
||||
// + local + built-in). Gated on `isOpen` so closed modal doesn't fetch.
|
||||
@@ -280,6 +297,10 @@ export function BulkEditSpoolsModal({
|
||||
() => combineUnique(availableCategories).map((m) => ({ value: m, label: m })),
|
||||
[availableCategories],
|
||||
);
|
||||
const materialNumberOptions: Option[] = useMemo(
|
||||
() => combineUnique(availableMaterialNumbers).map((m) => ({ value: m, label: m })),
|
||||
[availableMaterialNumbers],
|
||||
);
|
||||
const slicerFilamentOptions: Option[] = useMemo(() => {
|
||||
// value = preset code (what goes into spool.slicer_filament),
|
||||
// label = display name so the user can find it by name.
|
||||
@@ -317,7 +338,7 @@ export function BulkEditSpoolsModal({
|
||||
|
||||
const buildPatch = (): Record<string, string | number> => {
|
||||
const patch: Record<string, string | number> = {};
|
||||
for (const f of FIELDS) {
|
||||
for (const f of fields) {
|
||||
const raw = values[f.id];
|
||||
if (raw === undefined) continue;
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
||||
@@ -345,7 +366,7 @@ export function BulkEditSpoolsModal({
|
||||
// would be silently dropped from the patch — e.g. a malformed rgba hex.
|
||||
// Without this guard the user clicks Apply, the field is dropped, and the
|
||||
// success toast still fires for the OTHER fields.
|
||||
const hasDroppedTickedField = FIELDS.some((f) => {
|
||||
const hasDroppedTickedField = fields.some((f) => {
|
||||
const raw = values[f.id];
|
||||
if (raw === undefined) return false;
|
||||
if (raw.trim() === '') return false;
|
||||
@@ -357,6 +378,7 @@ export function BulkEditSpoolsModal({
|
||||
if (id === 'subtype') return subtypeOptions;
|
||||
if (id === 'brand') return brandOptions;
|
||||
if (id === 'category') return categoryOptions;
|
||||
if (id === 'material_number') return materialNumberOptions;
|
||||
if (id === 'slicer_filament') return slicerFilamentOptions;
|
||||
if (id === 'slicer_filament_name') return slicerFilamentNameOptions;
|
||||
if (id === 'location_id') return locationOptions;
|
||||
@@ -469,7 +491,7 @@ export function BulkEditSpoolsModal({
|
||||
{t('inventory.bulk.editHint')}
|
||||
</p>
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-3">
|
||||
{FIELDS.map((f) => {
|
||||
{fields.map((f) => {
|
||||
const enabled = values[f.id] !== undefined;
|
||||
return (
|
||||
<div key={f.id} className={`flex items-start gap-3 rounded-md p-2 transition-colors ${enabled ? 'bg-bambu-green/5 border border-bambu-green/30' : 'border border-transparent'}`}>
|
||||
|
||||
@@ -1281,6 +1281,7 @@ function ShoppingListPanel({
|
||||
added_full: null, last_used: null, encode_time: null,
|
||||
category: 'Stock',
|
||||
low_stock_threshold_pct: null,
|
||||
material_number: null,
|
||||
};
|
||||
await api.bulkCreateSpools(spoolBase, item.quantity_spools);
|
||||
await api.removeFromShoppingList(id);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
// Consumption, cost and stock grouped by the internal material number
|
||||
// (#2870) — the identifier the business actually purchases and costs by,
|
||||
// unlike brand+material+colour. Data comes from the dedicated aggregate
|
||||
// endpoint so archived spools' recorded usage still counts.
|
||||
|
||||
interface MaterialNumberStatsProps {
|
||||
currency: string;
|
||||
// Dashboard timeframe. Narrows the consumption/cost columns only — the
|
||||
// spool count and remaining weight are point-in-time stock.
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
function formatGrams(g: number): string {
|
||||
if (Math.abs(g) >= 1000) return `${(g / 1000).toFixed(2)} kg`;
|
||||
return `${Math.round(g)} g`;
|
||||
}
|
||||
|
||||
export function MaterialNumberStats({ currency, dateFrom, dateTo }: MaterialNumberStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['material-number-stats', dateFrom ?? null, dateTo ?? null],
|
||||
queryFn: () => api.getMaterialNumberStats({ dateFrom, dateTo }),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A failed request is not an empty inventory: telling someone who has
|
||||
// numbered their spools to go and number them (because of a 403 from a
|
||||
// missing INVENTORY_READ, or a dropped connection) sends them looking for
|
||||
// a problem that isn't there.
|
||||
if (isError) {
|
||||
return <p className="text-sm text-red-700 dark:text-red-400 py-4">{t('stats.materialNumbers.loadFailed')}</p>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <p className="text-sm text-bambu-gray py-4">{t('stats.materialNumbers.empty')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
|
||||
<th className="py-2 pr-4 font-medium">{t('inventory.materialNumber')}</th>
|
||||
<th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.spools')}</th>
|
||||
<th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.remaining')}</th>
|
||||
<th className="py-2 pr-4 font-medium text-right">{t('stats.materialNumbers.consumed')}</th>
|
||||
<th className="py-2 font-medium text-right">{t('stats.materialNumbers.cost')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.material_number} className="border-b border-bambu-dark-tertiary/50 last:border-b-0">
|
||||
<td className="py-2 pr-4 text-white font-medium">{row.material_number}</td>
|
||||
<td className="py-2 pr-4 text-bambu-gray text-right">{row.spool_count}</td>
|
||||
<td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.remaining_g)}</td>
|
||||
<td className="py-2 pr-4 text-bambu-gray text-right">{formatGrams(row.consumed_g)}</td>
|
||||
<td className="py-2 text-bambu-gray text-right">
|
||||
{currency} {row.cost.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -428,6 +428,7 @@ export function SpoolFormModal({
|
||||
cost_per_kg: spool.cost_per_kg ?? null,
|
||||
category: spool.category || '',
|
||||
low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
|
||||
material_number: spool.material_number || '',
|
||||
location_id: spool.location_id ?? null,
|
||||
spoolman_filament_id: null,
|
||||
});
|
||||
@@ -786,6 +787,16 @@ export function SpoolFormModal({
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
})();
|
||||
// Autocomplete for the internal material number (#2870), mirroring the
|
||||
// category datalist above.
|
||||
const availableMaterialNumbers = (() => {
|
||||
const set = new Set<string>();
|
||||
for (const s of allSpools ?? []) {
|
||||
const n = s.material_number?.trim();
|
||||
if (n) set.add(n);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
})();
|
||||
const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
@@ -968,6 +979,7 @@ export function SpoolFormModal({
|
||||
cost_per_kg: formData.cost_per_kg,
|
||||
category: formData.category.trim() || null,
|
||||
low_stock_threshold_pct: formData.low_stock_threshold_pct,
|
||||
material_number: formData.material_number.trim() || null,
|
||||
...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
|
||||
};
|
||||
|
||||
@@ -1174,6 +1186,7 @@ export function SpoolFormModal({
|
||||
spoolCatalog={spoolCatalog}
|
||||
currencySymbol={currencySymbol}
|
||||
availableCategories={availableCategories}
|
||||
availableMaterialNumbers={availableMaterialNumbers}
|
||||
availableLocations={storageLocations}
|
||||
onCreateLocation={async (name) => {
|
||||
try {
|
||||
@@ -1192,6 +1205,7 @@ export function SpoolFormModal({
|
||||
}
|
||||
}}
|
||||
globalLowStockThreshold={globalLowStockThreshold}
|
||||
spoolmanMode={spoolmanMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -174,9 +174,11 @@ export function AdditionalSection({
|
||||
spoolCatalog,
|
||||
currencySymbol,
|
||||
availableCategories,
|
||||
availableMaterialNumbers,
|
||||
availableLocations = [],
|
||||
onCreateLocation,
|
||||
globalLowStockThreshold,
|
||||
spoolmanMode = false,
|
||||
}: AdditionalSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
@@ -318,6 +320,33 @@ export function AdditionalSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Material number (#2870). Hidden in Spoolman mode: there the number
|
||||
is Spoolman's filament-level article_number, maintained in Spoolman
|
||||
itself and surfaced read-only in the list. */}
|
||||
{!spoolmanMode && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-material-number">
|
||||
{t('inventory.materialNumber')}
|
||||
</label>
|
||||
<input
|
||||
id="spool-material-number"
|
||||
type="text"
|
||||
list="spool-material-number-options"
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
|
||||
placeholder={t('inventory.materialNumberPlaceholder')}
|
||||
value={formData.material_number}
|
||||
maxLength={64}
|
||||
onChange={(e) => updateField('material_number', e.target.value)}
|
||||
/>
|
||||
{availableMaterialNumbers.length > 0 && (
|
||||
<datalist id="spool-material-number-options">
|
||||
{availableMaterialNumbers.map((n) => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
)}
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('inventory.materialNumberHelp')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category (#729) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface SpoolFormData {
|
||||
// User-defined category + per-spool low-stock threshold override (#729).
|
||||
category: string;
|
||||
low_stock_threshold_pct: number | null;
|
||||
// Internal material / article number (#2870) — the purchasing identifier
|
||||
// shared by all spools of the same product. Free text.
|
||||
material_number: string;
|
||||
location_id: number | null;
|
||||
// When set the spool is linked to a specific Spoolman filament catalog entry;
|
||||
// the backend skips find_or_create_filament() and uses this ID directly.
|
||||
@@ -64,6 +67,7 @@ export const defaultFormData: SpoolFormData = {
|
||||
cost_per_kg: null,
|
||||
category: '',
|
||||
low_stock_threshold_pct: null,
|
||||
material_number: '',
|
||||
location_id: null,
|
||||
spoolman_filament_id: null,
|
||||
};
|
||||
@@ -207,11 +211,18 @@ export interface AdditionalSectionProps extends SectionProps {
|
||||
// datalist so users naturally re-use existing names instead of creating
|
||||
// near-duplicates ("Production" vs "production" vs "prod"). #729
|
||||
availableCategories: string[];
|
||||
// Material numbers already used on other spools — same autocomplete idea
|
||||
// as availableCategories, for the internal article number (#2870).
|
||||
availableMaterialNumbers: string[];
|
||||
// Global low-stock threshold (%); shown as placeholder on the per-spool
|
||||
// override input so users see what they're overriding. #729
|
||||
globalLowStockThreshold: number;
|
||||
availableLocations?: { id: number; name: string }[];
|
||||
onCreateLocation?: (name: string) => Promise<{ id: number; name: string } | null>;
|
||||
// When true the material number input is hidden: in Spoolman mode the
|
||||
// number is Spoolman's filament-level article_number, maintained in
|
||||
// Spoolman itself and shown read-only in the list (#2870).
|
||||
spoolmanMode?: boolean;
|
||||
}
|
||||
|
||||
// PA Profile section props
|
||||
|
||||
Reference in New Issue
Block a user