import { useState, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Store, Plus, Trash2, Loader2, Pencil, Check, X, Search, ExternalLink, AlertTriangle } from 'lucide-react'; import { api, ApiError } from '../api/client'; import type { Supplier } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../utils/inventoryQueries'; import { ConfirmModal } from './ConfirmModal'; interface SuppliersModalProps { open: boolean; onClose: () => void; } // Managed supplier master list (#2988) — where filament is bought, distinct // from brand (who made it). Inventory master data that spools reference, so // it opens from the Inventory toolbar exactly like Locations, in both the // built-in and the Spoolman inventory. export function SuppliersModal({ open, onClose }: SuppliersModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); // The list lives in react-query under the key useWebSocket invalidates on // inventory_changed, so a supplier created inline in the spool dialog — or // by another user, in another tab — shows up here (#2988). const { data: suppliers = [], isLoading: loading, isError } = useQuery({ queryKey: inventorySuppliersQueryKey, queryFn: api.getSuppliers, enabled: open, }); const [search, setSearch] = useState(''); // Add/Edit form state const [showAddForm, setShowAddForm] = useState(false); const [editingId, setEditingId] = useState(null); const [formName, setFormName] = useState(''); const [formWebsite, setFormWebsite] = useState(''); const [formCustomerNumber, setFormCustomerNumber] = useState(''); const [formNote, setFormNote] = useState(''); const [saving, setSaving] = useState(false); const [deleteSupplier, setDeleteSupplier] = useState(null); useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [open, onClose]); if (!open) return null; const filtered = suppliers.filter((s) => s.name.toLowerCase().includes(search.toLowerCase())); const resetForm = () => { setFormName(''); setFormWebsite(''); setFormCustomerNumber(''); setFormNote(''); }; const formPayload = () => ({ name: formName.trim(), website: formWebsite.trim() || null, customer_number: formCustomerNumber.trim() || null, note: formNote.trim() || null, }); const handleAdd = async () => { if (!formName.trim()) { showToast(t('inventory.suppliers.nameRequired'), 'error'); return; } setSaving(true); try { await api.createSupplier(formPayload()); await invalidateInventorySuppliers(queryClient); setShowAddForm(false); resetForm(); showToast(t('inventory.suppliers.added'), 'success'); } catch (err) { console.error('SuppliersModal.handleAdd failed:', err); // 409: the name is taken — say so instead of a generic failure. showToast( t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.addFailed'), 'error', ); } finally { setSaving(false); } }; const startEdit = (supplier: Supplier) => { setEditingId(supplier.id); setFormName(supplier.name); setFormWebsite(supplier.website ?? ''); setFormCustomerNumber(supplier.customer_number ?? ''); setFormNote(supplier.note ?? ''); }; const cancelEdit = () => { setEditingId(null); resetForm(); }; const handleUpdate = async (id: number) => { if (!formName.trim()) { showToast(t('inventory.suppliers.nameRequired'), 'error'); return; } setSaving(true); try { await api.updateSupplier(id, formPayload()); await invalidateInventorySuppliers(queryClient); cancelEdit(); showToast(t('inventory.suppliers.updated'), 'success'); } catch (err) { console.error('SuppliersModal.handleUpdate failed:', err); showToast( t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.updateFailed'), 'error', ); } finally { setSaving(false); } }; const handleDelete = async () => { if (!deleteSupplier) return; try { await api.deleteSupplier(deleteSupplier.id); await invalidateInventorySuppliers(queryClient); showToast(t('inventory.suppliers.deleted'), 'success'); } catch (err) { console.error('SuppliersModal.handleDelete failed:', err); // 409: still referenced by spools — surface the guard, not a generic error. if (err instanceof ApiError && err.status === 409) { showToast(t('inventory.suppliers.deleteBlocked', { count: deleteSupplier.spool_count }), 'error'); } else { showToast(t('inventory.suppliers.deleteFailed'), 'error'); } } finally { setDeleteSupplier(null); } }; const formFields = (
setFormName(e.target.value)} />
setFormWebsite(e.target.value)} /> setFormCustomerNumber(e.target.value)} />
setFormNote(e.target.value)} />
); return (

{t('inventory.suppliers.title')}

({suppliers.length})

{t('inventory.suppliers.description')}

setSearch(e.target.value)} />
{showAddForm && (

{t('inventory.suppliers.addNew')}

{formFields}
)} {loading ? (
{t('common.loading')}
) : isError ? ( // A failed load must not read as "no suppliers yet" — that invites // the user to create one that already exists.
{t('inventory.suppliers.loadFailed')}
) : (
{filtered.length === 0 ? ( ) : ( filtered.map((supplier) => ( {editingId === supplier.id ? ( <> ) : ( <> )} )) )}
{t('common.name')} {t('inventory.suppliers.customerNumber')} {t('inventory.suppliers.spools')}
{search ? t('inventory.suppliers.noMatch') : t('inventory.suppliers.empty')}
{formFields}
{supplier.name} {supplier.website && ( )}
{supplier.note &&
{supplier.note}
}
{supplier.customer_number || '-'} {supplier.spool_count}
)}
{deleteSupplier && ( 0 ? t('inventory.suppliers.deleteConfirmReferenced', { name: deleteSupplier.name, count: deleteSupplier.spool_count, }) : t('inventory.suppliers.deleteConfirm', { name: deleteSupplier.name }) } confirmText={t('common.delete')} variant="danger" onConfirm={handleDelete} onCancel={() => setDeleteSupplier(null)} /> )}
); }