Suppliers as a managed list with per-spool assignments (#2996)

This commit is contained in:
Thomansky
2026-09-28 15:14:07 +02:00
committed by GitHub
parent 66379e97b5
commit 053cfa73ad
46 changed files with 4843 additions and 54 deletions
@@ -20,6 +20,7 @@ import { MATERIALS } from './spool-form/constants';
import { FilamentSection } from './spool-form/FilamentSection';
import { ColorSection } from './spool-form/ColorSection';
import { AdditionalSection } from './spool-form/AdditionalSection';
import { SupplierSection, type SupplierLinkDraft } from './spool-form/SupplierSection';
import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
import { normaliseFlow } from '../utils/nozzleFlow';
@@ -81,6 +82,13 @@ export function SpoolFormModal({
// through core_weight.
const [coreWeightTouched, setCoreWeightTouched] = useState(false);
const [locationIdTouched, setLocationIdTouched] = useState(false);
// Supplier assignments (#2988). Held outside SpoolFormData — they are
// relational and saved through their own replace-all endpoint. An untouched
// create does not send them, so it keeps the backend's inherited
// assignments instead of wiping them with an empty list; a copy always
// sends them, see saveSupplierLinks.
const [supplierLinks, setSupplierLinks] = useState<SupplierLinkDraft[]>([]);
const [supplierLinksTouched, setSupplierLinksTouched] = useState(false);
const [quickAdd, setQuickAdd] = useState(false);
const [quantity, setQuantity] = useState(1);
@@ -446,10 +454,23 @@ export function SpoolFormModal({
} else {
setSelectedProfiles(new Map());
}
// Supplier assignments (#2988) — copied on copy: they describe where
// the product is bought, which is what a copy shares.
setSupplierLinks(
(spool.suppliers ?? []).map((link) => ({
supplier_id: link.supplier_id,
supplier_name: link.supplier_name,
supplier_article_number: link.supplier_article_number ?? '',
quoted_price_per_kg: link.quoted_price_per_kg,
// Where a COPY was bought is unknown — only a real edit keeps it.
is_purchase_source: isCopying ? false : link.is_purchase_source,
}))
);
} else {
setFormData(defaultFormData);
setPresetInputValue('');
setSelectedProfiles(new Map());
setSupplierLinks([]);
}
// Reset on every open, not just the create path (#1905). The modal keeps
// its state while closed, and the Quick Add toggle only renders in create
@@ -471,6 +492,7 @@ export function SpoolFormModal({
// inheriting. Only Spoolman spools report the flag (#2908).
setCoreWeightTouched(isCopying && spool?.core_weight_is_inherited === false);
setLocationIdTouched(false);
setSupplierLinksTouched(false);
}
}, [isOpen, spool, mode, isCopying]);
@@ -582,6 +604,8 @@ export function SpoolFormModal({
if (newSpool?.id) {
const ok = await savePrinterProfiles(newSpool.id);
if (!ok) return;
const suppliersOk = await saveSupplierLinks(newSpool.id);
if (!suppliersOk) return;
}
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
@@ -625,6 +649,12 @@ export function SpoolFormModal({
await savePrinterProfiles(s.id);
}
}
// Every copy of a bulk add shares the same supplier assignments (#2988).
if (shouldSaveSupplierLinks) {
for (const s of createdSpools) {
await saveSupplierLinks(s.id);
}
}
await refreshSpoolQueries();
if (onSpoolsCreated) onSpoolsCreated(createdSpools);
if (spoolmanResult && spoolmanResult.failed_count > 0) {
@@ -658,6 +688,8 @@ export function SpoolFormModal({
if (spool?.id) {
const ok = await savePrinterProfiles(spool.id);
if (!ok) return;
const suppliersOk = await saveSupplierLinks(spool.id);
if (!suppliersOk) return;
}
await refreshSpoolQueries();
showToast(t('inventory.spoolUpdated'), 'success');
@@ -774,6 +806,41 @@ export function SpoolFormModal({
},
});
// Supplier assignments (#2988): replace-all save.
//
// Skipped on an untouched create so the backend's inheritance can fill the
// new spool in — an empty list would wipe what it just attached. A COPY is
// the opposite case and always saves: the dialog seeded the chips from the
// spool being copied and showed them, so they have to be what the copy
// gets. Inheritance cannot stand in for that — it keys on the (material,
// subtype, brand, color_name) tuple and so resolves to the NEWEST spool of
// the product rather than the one on screen, and in Spoolman mode there is
// no inheritance at all.
const shouldSaveSupplierLinks = supplierLinksTouched || isCopying;
const saveSupplierLinks = async (spoolId: number): Promise<boolean> => {
if (!shouldSaveSupplierLinks) return true;
// Spoolman parity (#2988): the assignment rows live Bambuddy-side either
// way; only the endpoint differs (twin table keyed by the remote id).
const save = spoolmanMode ? api.setSpoolmanSpoolSuppliers : api.setSpoolSuppliers;
try {
await save(
spoolId,
supplierLinks.map((link) => ({
supplier_id: link.supplier_id,
supplier_article_number: link.supplier_article_number.trim() || null,
quoted_price_per_kg: link.quoted_price_per_kg,
is_purchase_source: link.is_purchase_source,
})),
);
return true;
} catch (err) {
console.error('SpoolFormModal.saveSupplierLinks failed:', err);
showToast(t('inventory.suppliers.saveFailed'), 'error');
return false;
}
};
// Save everything the Printers tab holds: one K profile per hotend and the
// per-printer-model preset overrides. Returns false if either write failed,
// which keeps the modal open so the user does not lose what they picked.
@@ -1117,6 +1184,21 @@ export function SpoolFormModal({
/>
</div>
{/* Suppliers (#2988) — both inventories: the assignments live
Bambuddy-side either way (Spoolman's vendor is the
manufacturer, not the seller), so the same section renders
in Spoolman mode and saves to the twin endpoint. */}
<div>
<SupplierSection
links={supplierLinks}
onChange={(next) => {
setSupplierLinks(next);
setSupplierLinksTouched(true);
}}
currencySymbol={currencySymbol}
/>
</div>
{/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
{isEditing && spool && !spoolmanMode && (
<div>