SuppliersModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import { useState, useEffect } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Store, Plus, Trash2, Loader2, Pencil, Check, X, Search, ExternalLink, AlertTriangle } from 'lucide-react';
  5. import { api, ApiError } from '../api/client';
  6. import type { Supplier } from '../api/client';
  7. import { useToast } from '../contexts/ToastContext';
  8. import { inventorySuppliersQueryKey, invalidateInventorySuppliers } from '../utils/inventoryQueries';
  9. import { ConfirmModal } from './ConfirmModal';
  10. interface SuppliersModalProps {
  11. open: boolean;
  12. onClose: () => void;
  13. }
  14. // Managed supplier master list (#2988) — where filament is bought, distinct
  15. // from brand (who made it). Inventory master data that spools reference, so
  16. // it opens from the Inventory toolbar exactly like Locations, in both the
  17. // built-in and the Spoolman inventory.
  18. export function SuppliersModal({ open, onClose }: SuppliersModalProps) {
  19. const { t } = useTranslation();
  20. const { showToast } = useToast();
  21. const queryClient = useQueryClient();
  22. // The list lives in react-query under the key useWebSocket invalidates on
  23. // inventory_changed, so a supplier created inline in the spool dialog — or
  24. // by another user, in another tab — shows up here (#2988).
  25. const { data: suppliers = [], isLoading: loading, isError } = useQuery({
  26. queryKey: inventorySuppliersQueryKey,
  27. queryFn: api.getSuppliers,
  28. enabled: open,
  29. });
  30. const [search, setSearch] = useState('');
  31. // Add/Edit form state
  32. const [showAddForm, setShowAddForm] = useState(false);
  33. const [editingId, setEditingId] = useState<number | null>(null);
  34. const [formName, setFormName] = useState('');
  35. const [formWebsite, setFormWebsite] = useState('');
  36. const [formCustomerNumber, setFormCustomerNumber] = useState('');
  37. const [formNote, setFormNote] = useState('');
  38. const [saving, setSaving] = useState(false);
  39. const [deleteSupplier, setDeleteSupplier] = useState<Supplier | null>(null);
  40. useEffect(() => {
  41. if (!open) return;
  42. const handleKeyDown = (e: KeyboardEvent) => {
  43. if (e.key === 'Escape') onClose();
  44. };
  45. window.addEventListener('keydown', handleKeyDown);
  46. return () => window.removeEventListener('keydown', handleKeyDown);
  47. }, [open, onClose]);
  48. if (!open) return null;
  49. const filtered = suppliers.filter((s) => s.name.toLowerCase().includes(search.toLowerCase()));
  50. const resetForm = () => {
  51. setFormName('');
  52. setFormWebsite('');
  53. setFormCustomerNumber('');
  54. setFormNote('');
  55. };
  56. const formPayload = () => ({
  57. name: formName.trim(),
  58. website: formWebsite.trim() || null,
  59. customer_number: formCustomerNumber.trim() || null,
  60. note: formNote.trim() || null,
  61. });
  62. const handleAdd = async () => {
  63. if (!formName.trim()) {
  64. showToast(t('inventory.suppliers.nameRequired'), 'error');
  65. return;
  66. }
  67. setSaving(true);
  68. try {
  69. await api.createSupplier(formPayload());
  70. await invalidateInventorySuppliers(queryClient);
  71. setShowAddForm(false);
  72. resetForm();
  73. showToast(t('inventory.suppliers.added'), 'success');
  74. } catch (err) {
  75. console.error('SuppliersModal.handleAdd failed:', err);
  76. // 409: the name is taken — say so instead of a generic failure.
  77. showToast(
  78. t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.addFailed'),
  79. 'error',
  80. );
  81. } finally {
  82. setSaving(false);
  83. }
  84. };
  85. const startEdit = (supplier: Supplier) => {
  86. setEditingId(supplier.id);
  87. setFormName(supplier.name);
  88. setFormWebsite(supplier.website ?? '');
  89. setFormCustomerNumber(supplier.customer_number ?? '');
  90. setFormNote(supplier.note ?? '');
  91. };
  92. const cancelEdit = () => {
  93. setEditingId(null);
  94. resetForm();
  95. };
  96. const handleUpdate = async (id: number) => {
  97. if (!formName.trim()) {
  98. showToast(t('inventory.suppliers.nameRequired'), 'error');
  99. return;
  100. }
  101. setSaving(true);
  102. try {
  103. await api.updateSupplier(id, formPayload());
  104. await invalidateInventorySuppliers(queryClient);
  105. cancelEdit();
  106. showToast(t('inventory.suppliers.updated'), 'success');
  107. } catch (err) {
  108. console.error('SuppliersModal.handleUpdate failed:', err);
  109. showToast(
  110. t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.updateFailed'),
  111. 'error',
  112. );
  113. } finally {
  114. setSaving(false);
  115. }
  116. };
  117. const handleDelete = async () => {
  118. if (!deleteSupplier) return;
  119. try {
  120. await api.deleteSupplier(deleteSupplier.id);
  121. await invalidateInventorySuppliers(queryClient);
  122. showToast(t('inventory.suppliers.deleted'), 'success');
  123. } catch (err) {
  124. console.error('SuppliersModal.handleDelete failed:', err);
  125. // 409: still referenced by spools — surface the guard, not a generic error.
  126. if (err instanceof ApiError && err.status === 409) {
  127. showToast(t('inventory.suppliers.deleteBlocked', { count: deleteSupplier.spool_count }), 'error');
  128. } else {
  129. showToast(t('inventory.suppliers.deleteFailed'), 'error');
  130. }
  131. } finally {
  132. setDeleteSupplier(null);
  133. }
  134. };
  135. const formFields = (
  136. <div className="space-y-2">
  137. <input
  138. type="text"
  139. className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  140. placeholder={t('inventory.suppliers.namePlaceholder')}
  141. value={formName}
  142. maxLength={200}
  143. onChange={(e) => setFormName(e.target.value)}
  144. />
  145. <div className="flex gap-2">
  146. <input
  147. type="text"
  148. className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  149. placeholder={t('inventory.suppliers.websitePlaceholder')}
  150. value={formWebsite}
  151. maxLength={500}
  152. onChange={(e) => setFormWebsite(e.target.value)}
  153. />
  154. <input
  155. type="text"
  156. className="w-40 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  157. placeholder={t('inventory.suppliers.customerNumberPlaceholder')}
  158. value={formCustomerNumber}
  159. maxLength={100}
  160. onChange={(e) => setFormCustomerNumber(e.target.value)}
  161. />
  162. </div>
  163. <input
  164. type="text"
  165. className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  166. placeholder={t('inventory.suppliers.notePlaceholder')}
  167. value={formNote}
  168. maxLength={500}
  169. onChange={(e) => setFormNote(e.target.value)}
  170. />
  171. </div>
  172. );
  173. return (
  174. <div className="fixed inset-0 z-50 flex items-center justify-center">
  175. <div className="absolute inset-0 bg-black/60" onClick={onClose} />
  176. <div
  177. className="relative w-full max-w-3xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
  178. role="dialog"
  179. aria-modal="true"
  180. aria-labelledby="suppliers-modal-title"
  181. >
  182. <div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-bambu-dark-tertiary">
  183. <div className="flex items-center gap-2">
  184. <Store className="w-5 h-5 text-bambu-gray" />
  185. <h2 id="suppliers-modal-title" className="text-lg font-semibold text-white">
  186. {t('inventory.suppliers.title')}
  187. </h2>
  188. <span className="text-sm text-bambu-gray">({suppliers.length})</span>
  189. </div>
  190. <div className="flex items-center gap-2">
  191. <button
  192. onClick={() => { setShowAddForm(true); setEditingId(null); resetForm(); }}
  193. className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
  194. >
  195. <Plus className="w-4 h-4" />
  196. <span className="hidden sm:inline">{t('common.add')}</span>
  197. </button>
  198. <button
  199. onClick={onClose}
  200. className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
  201. aria-label={t('common.close')}
  202. >
  203. <X className="w-5 h-5" />
  204. </button>
  205. </div>
  206. </div>
  207. <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-4">
  208. <p className="text-sm text-bambu-gray">{t('inventory.suppliers.description')}</p>
  209. <div className="relative">
  210. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  211. <input
  212. type="text"
  213. className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  214. placeholder={t('inventory.suppliers.search')}
  215. value={search}
  216. onChange={(e) => setSearch(e.target.value)}
  217. />
  218. </div>
  219. {showAddForm && (
  220. <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary space-y-3">
  221. <h3 className="text-sm font-medium text-white">{t('inventory.suppliers.addNew')}</h3>
  222. {formFields}
  223. <div className="flex gap-2 justify-end">
  224. <button
  225. onClick={() => { setShowAddForm(false); resetForm(); }}
  226. className="px-3 py-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
  227. >
  228. {t('common.cancel')}
  229. </button>
  230. <button
  231. onClick={handleAdd}
  232. disabled={saving}
  233. className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1"
  234. >
  235. {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
  236. {t('common.add')}
  237. </button>
  238. </div>
  239. </div>
  240. )}
  241. {loading ? (
  242. <div className="flex items-center justify-center py-8 text-bambu-gray">
  243. <Loader2 className="w-5 h-5 animate-spin mr-2" />
  244. {t('common.loading')}
  245. </div>
  246. ) : isError ? (
  247. // A failed load must not read as "no suppliers yet" — that invites
  248. // the user to create one that already exists.
  249. <div className="flex items-center justify-center gap-2 py-8 text-sm text-red-400">
  250. <AlertTriangle className="w-4 h-4" />
  251. {t('inventory.suppliers.loadFailed')}
  252. </div>
  253. ) : (
  254. <div className="border border-bambu-dark-tertiary rounded-lg">
  255. <table className="w-full text-sm">
  256. <thead className="bg-bambu-dark sticky top-0">
  257. <tr>
  258. <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
  259. <th className="px-4 py-2 text-left text-bambu-gray font-medium hidden sm:table-cell">
  260. {t('inventory.suppliers.customerNumber')}
  261. </th>
  262. <th className="px-4 py-2 text-right text-bambu-gray font-medium w-20">
  263. {t('inventory.suppliers.spools')}
  264. </th>
  265. <th className="px-4 py-2 w-24"></th>
  266. </tr>
  267. </thead>
  268. <tbody>
  269. {filtered.length === 0 ? (
  270. <tr>
  271. <td colSpan={4} className="px-4 py-8 text-center text-bambu-gray">
  272. {search ? t('inventory.suppliers.noMatch') : t('inventory.suppliers.empty')}
  273. </td>
  274. </tr>
  275. ) : (
  276. filtered.map((supplier) => (
  277. <tr key={supplier.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark align-top">
  278. {editingId === supplier.id ? (
  279. <>
  280. <td className="px-4 py-2" colSpan={3}>{formFields}</td>
  281. <td className="px-4 py-2">
  282. <div className="flex justify-end gap-1">
  283. <button
  284. onClick={() => handleUpdate(supplier.id)}
  285. disabled={saving}
  286. className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
  287. >
  288. {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
  289. </button>
  290. <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
  291. <X className="w-4 h-4" />
  292. </button>
  293. </div>
  294. </td>
  295. </>
  296. ) : (
  297. <>
  298. <td className="px-4 py-2 text-white">
  299. <div className="flex items-center gap-1.5">
  300. <span>{supplier.name}</span>
  301. {supplier.website && (
  302. <a
  303. href={supplier.website}
  304. target="_blank"
  305. rel="noopener noreferrer"
  306. className="text-bambu-gray hover:text-bambu-green"
  307. title={supplier.website}
  308. >
  309. <ExternalLink className="w-3.5 h-3.5" />
  310. </a>
  311. )}
  312. </div>
  313. {supplier.note && <div className="text-xs text-bambu-gray mt-0.5">{supplier.note}</div>}
  314. </td>
  315. <td className="px-4 py-2 text-bambu-gray hidden sm:table-cell">
  316. {supplier.customer_number || '-'}
  317. </td>
  318. <td className="px-4 py-2 text-right font-mono text-bambu-gray">{supplier.spool_count}</td>
  319. <td className="px-4 py-2">
  320. <div className="flex justify-end gap-1">
  321. <button
  322. onClick={() => { startEdit(supplier); setShowAddForm(false); }}
  323. className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
  324. >
  325. <Pencil className="w-4 h-4" />
  326. </button>
  327. <button
  328. onClick={() => setDeleteSupplier(supplier)}
  329. className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
  330. >
  331. <Trash2 className="w-4 h-4" />
  332. </button>
  333. </div>
  334. </td>
  335. </>
  336. )}
  337. </tr>
  338. ))
  339. )}
  340. </tbody>
  341. </table>
  342. </div>
  343. )}
  344. </div>
  345. </div>
  346. {deleteSupplier && (
  347. <ConfirmModal
  348. title={t('inventory.suppliers.deleteTitle')}
  349. message={
  350. deleteSupplier.spool_count > 0
  351. ? t('inventory.suppliers.deleteConfirmReferenced', {
  352. name: deleteSupplier.name,
  353. count: deleteSupplier.spool_count,
  354. })
  355. : t('inventory.suppliers.deleteConfirm', { name: deleteSupplier.name })
  356. }
  357. confirmText={t('common.delete')}
  358. variant="danger"
  359. onConfirm={handleDelete}
  360. onCancel={() => setDeleteSupplier(null)}
  361. />
  362. )}
  363. </div>
  364. );
  365. }