SupplierSection.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { AlertTriangle, ChevronDown, Loader2, Plus, Store, X } 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. // One editable supplier assignment as the form holds it (#2988). Mirrors
  10. // SpoolSupplierLinkInput but keeps the name so chips render without lookups.
  11. export interface SupplierLinkDraft {
  12. supplier_id: number;
  13. supplier_name: string;
  14. supplier_article_number: string;
  15. /** Quoted price for comparison — never the cost basis (spool.cost_per_kg). */
  16. quoted_price_per_kg: number | null;
  17. is_purchase_source: boolean;
  18. }
  19. interface SupplierSectionProps {
  20. links: SupplierLinkDraft[];
  21. onChange: (links: SupplierLinkDraft[]) => void;
  22. currencySymbol: string;
  23. }
  24. // Multi-select with chips for the spool dialog (#2988): a material can come
  25. // from several suppliers. Each chip carries the per-assignment fields
  26. // (supplier's article number, price there, bought-here marker) and "+ new
  27. // supplier" creates a master-list entry without leaving the dialog.
  28. export function SupplierSection({ links, onChange, currencySymbol }: SupplierSectionProps) {
  29. const { t } = useTranslation();
  30. const { showToast } = useToast();
  31. const queryClient = useQueryClient();
  32. const containerRef = useRef<HTMLDivElement>(null);
  33. const [open, setOpen] = useState(false);
  34. const [search, setSearch] = useState('');
  35. const [creating, setCreating] = useState(false);
  36. // Shared key with SuppliersModal, invalidated by the inventory_changed
  37. // broadcast — a supplier created in either place shows up in both (#2988).
  38. const { data: suppliers = [], isLoading, isError } = useQuery({
  39. queryKey: inventorySuppliersQueryKey,
  40. queryFn: api.getSuppliers,
  41. });
  42. useEffect(() => {
  43. if (!open) return;
  44. const onDocClick = (e: MouseEvent) => {
  45. if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
  46. setOpen(false);
  47. setSearch('');
  48. }
  49. };
  50. document.addEventListener('mousedown', onDocClick);
  51. return () => document.removeEventListener('mousedown', onDocClick);
  52. }, [open]);
  53. const linkedIds = useMemo(() => new Set(links.map((l) => l.supplier_id)), [links]);
  54. const candidates = useMemo(
  55. () =>
  56. suppliers
  57. .filter((s) => !linkedIds.has(s.id))
  58. .filter((s) => s.name.toLowerCase().includes(search.trim().toLowerCase())),
  59. [suppliers, linkedIds, search]
  60. );
  61. const exactMatch = suppliers.some((s) => s.name.toLowerCase() === search.trim().toLowerCase());
  62. const addLink = (supplier: Supplier) => {
  63. onChange([
  64. ...links,
  65. {
  66. supplier_id: supplier.id,
  67. supplier_name: supplier.name,
  68. supplier_article_number: '',
  69. quoted_price_per_kg: null,
  70. is_purchase_source: false,
  71. },
  72. ]);
  73. setOpen(false);
  74. setSearch('');
  75. };
  76. const createAndAdd = async () => {
  77. const name = search.trim();
  78. if (!name) return;
  79. setCreating(true);
  80. try {
  81. const supplier = await api.createSupplier({ name });
  82. await invalidateInventorySuppliers(queryClient);
  83. addLink(supplier);
  84. } catch (err) {
  85. console.error('SupplierSection.createAndAdd failed:', err);
  86. showToast(
  87. t(err instanceof ApiError && err.status === 409 ? 'inventory.suppliers.duplicateName' : 'inventory.suppliers.createFailed'),
  88. 'error',
  89. );
  90. } finally {
  91. setCreating(false);
  92. }
  93. };
  94. const updateLink = (supplierId: number, patch: Partial<SupplierLinkDraft>) => {
  95. onChange(
  96. links.map((link) => {
  97. if (link.supplier_id !== supplierId) {
  98. // Only one assignment can be the purchase source.
  99. return patch.is_purchase_source ? { ...link, is_purchase_source: false } : link;
  100. }
  101. return { ...link, ...patch };
  102. })
  103. );
  104. };
  105. const removeLink = (supplierId: number) => {
  106. onChange(links.filter((link) => link.supplier_id !== supplierId));
  107. };
  108. return (
  109. <div>
  110. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.suppliers.label')}</label>
  111. {links.length > 0 && (
  112. <div className="space-y-2 mb-2">
  113. {links.map((link) => (
  114. <div key={link.supplier_id} className="p-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
  115. <div className="flex items-center gap-2">
  116. <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/10 text-bambu-green">
  117. <Store className="w-3 h-3" />
  118. {link.supplier_name}
  119. </span>
  120. <label
  121. className="ml-auto flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer"
  122. title={t('inventory.suppliers.purchaseSourceHelp')}
  123. >
  124. <input
  125. type="checkbox"
  126. className="w-3.5 h-3.5 accent-bambu-green"
  127. checked={link.is_purchase_source}
  128. onChange={(e) => updateLink(link.supplier_id, { is_purchase_source: e.target.checked })}
  129. />
  130. {t('inventory.suppliers.purchaseSource')}
  131. </label>
  132. <button
  133. type="button"
  134. onClick={() => removeLink(link.supplier_id)}
  135. className="p-1 rounded text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
  136. aria-label={t('common.remove')}
  137. >
  138. <X className="w-3.5 h-3.5" />
  139. </button>
  140. </div>
  141. <div className="flex gap-2 mt-2">
  142. <input
  143. type="text"
  144. className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
  145. placeholder={t('inventory.suppliers.articleNumberPlaceholder')}
  146. value={link.supplier_article_number}
  147. maxLength={100}
  148. onChange={(e) => updateLink(link.supplier_id, { supplier_article_number: e.target.value })}
  149. />
  150. <div className="relative w-28">
  151. <span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">
  152. {currencySymbol}
  153. </span>
  154. <input
  155. type="number"
  156. className="w-full pl-6 pr-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs text-right placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
  157. placeholder={t('inventory.suppliers.pricePlaceholder')}
  158. title={t('inventory.suppliers.priceHelp')}
  159. min={0}
  160. step={0.01}
  161. value={link.quoted_price_per_kg ?? ''}
  162. onChange={(e) =>
  163. updateLink(link.supplier_id, {
  164. quoted_price_per_kg: e.target.value === '' ? null : parseFloat(e.target.value),
  165. })
  166. }
  167. />
  168. </div>
  169. </div>
  170. </div>
  171. ))}
  172. </div>
  173. )}
  174. <div className="relative" ref={containerRef}>
  175. <button
  176. type="button"
  177. onClick={() => setOpen((prev) => !prev)}
  178. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-bambu-gray hover:text-white flex items-center justify-between transition-colors focus:outline-none focus:border-bambu-green"
  179. >
  180. <span>{t('inventory.suppliers.addSupplier')}</span>
  181. <ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
  182. </button>
  183. {open && (
  184. <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg">
  185. <div className="p-2 border-b border-bambu-dark-tertiary">
  186. <input
  187. type="text"
  188. autoFocus
  189. className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
  190. placeholder={t('inventory.suppliers.searchPlaceholder')}
  191. value={search}
  192. onChange={(e) => setSearch(e.target.value)}
  193. />
  194. </div>
  195. <div className="max-h-48 overflow-y-auto py-1">
  196. {isLoading ? (
  197. <div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
  198. <Loader2 className="w-4 h-4 animate-spin" />
  199. {t('common.loading')}
  200. </div>
  201. ) : isError ? (
  202. // An empty list here would invite the user to create a
  203. // supplier that already exists, so say the load failed.
  204. <div className="px-3 py-2 text-sm text-red-400 flex items-center gap-2">
  205. <AlertTriangle className="w-4 h-4" />
  206. {t('inventory.suppliers.loadFailed')}
  207. </div>
  208. ) : (
  209. <>
  210. {candidates.map((supplier) => (
  211. <button
  212. key={supplier.id}
  213. type="button"
  214. onClick={() => addLink(supplier)}
  215. className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark-tertiary"
  216. >
  217. {supplier.name}
  218. </button>
  219. ))}
  220. {candidates.length === 0 && !search.trim() && (
  221. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.suppliers.noneLeft')}</div>
  222. )}
  223. {search.trim() && !exactMatch && (
  224. <button
  225. type="button"
  226. onClick={createAndAdd}
  227. disabled={creating}
  228. className="w-full px-3 py-2 text-left text-sm text-bambu-green hover:bg-bambu-dark-tertiary flex items-center gap-1.5"
  229. >
  230. {creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
  231. {t('inventory.suppliers.createNew', { name: search.trim() })}
  232. </button>
  233. )}
  234. </>
  235. )}
  236. </div>
  237. </div>
  238. )}
  239. </div>
  240. <p className="text-xs text-bambu-gray mt-1">{t('inventory.suppliers.help')}</p>
  241. </div>
  242. );
  243. }