Material number as a first-class spool field (#2994)

This commit is contained in:
Thomansky
2026-09-29 15:11:50 +02:00
committed by GitHub
parent 926957f648
commit 71d4b2f70d
51 changed files with 2123 additions and 13 deletions
@@ -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