AdditionalSection.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import { useState, useRef, useEffect, useMemo } from 'react';
  2. import { Scale } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { useToast } from '../../contexts/ToastContext';
  5. import type { AdditionalSectionProps } from './types';
  6. function SpoolWeightPicker({
  7. catalog,
  8. value,
  9. onChange,
  10. catalogId,
  11. onCatalogIdChange,
  12. }: {
  13. catalog: { id: number; name: string; weight: number }[];
  14. value: number;
  15. onChange: (weight: number) => void;
  16. catalogId: number | null;
  17. onCatalogIdChange: (id: number | null) => void;
  18. }) {
  19. const { t } = useTranslation();
  20. const [isOpen, setIsOpen] = useState(false);
  21. const [search, setSearch] = useState('');
  22. const dropdownRef = useRef<HTMLDivElement>(null);
  23. const inputRef = useRef<HTMLInputElement>(null);
  24. useEffect(() => {
  25. const handleClick = (e: MouseEvent) => {
  26. if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
  27. setIsOpen(false);
  28. }
  29. };
  30. document.addEventListener('mousedown', handleClick);
  31. return () => document.removeEventListener('mousedown', handleClick);
  32. }, []);
  33. // When value changes, auto-select if there's only one matching entry or keep selection if it still matches
  34. useEffect(() => {
  35. // If no catalog loaded yet, skip matching logic
  36. if (catalog.length === 0) {
  37. return;
  38. }
  39. const matches = catalog.filter(e => e.weight === value);
  40. // If currently selected entry still matches the weight, keep it selected
  41. if (catalogId) {
  42. const selected = catalog.find(e => e.id === catalogId);
  43. if (selected && selected.weight === value) {
  44. return; // Keep current selection
  45. }
  46. }
  47. // If exactly one match, auto-select it
  48. if (matches.length === 1) {
  49. onCatalogIdChange(matches[0].id);
  50. } else if (matches.length === 0) {
  51. // No matches, clear selection to prevent stale catalog ID
  52. if (catalogId !== null) {
  53. onCatalogIdChange(null);
  54. }
  55. }
  56. // If multiple matches, don't auto-select - let user choose
  57. }, [value, catalog, catalogId, onCatalogIdChange]);
  58. const filtered = useMemo(() => {
  59. if (!search) return catalog;
  60. const s = search.toLowerCase();
  61. return catalog.filter(e =>
  62. e.name.toLowerCase().includes(s) ||
  63. e.weight.toString().includes(s),
  64. );
  65. }, [catalog, search]);
  66. // Find all entries matching the current weight
  67. const matchingEntries = useMemo(() => {
  68. return catalog.filter(e => e.weight === value);
  69. }, [catalog, value]);
  70. // Display value: show catalog name if selected by ID, otherwise show first match
  71. const displayValue = useMemo(() => {
  72. if (isOpen) return search;
  73. // If a catalog ID is explicitly selected, use that
  74. if (catalogId) {
  75. const entry = catalog.find(e => e.id === catalogId);
  76. if (entry) return entry.name;
  77. }
  78. // Otherwise, show the first matching entry as a suggestion
  79. if (matchingEntries.length > 0) {
  80. return matchingEntries[0].name;
  81. }
  82. // Leave empty if there are no matches
  83. return '';
  84. }, [isOpen, search, catalogId, catalog, matchingEntries]);
  85. return (
  86. <div>
  87. <label className="block text-sm font-medium text-bambu-gray mb-1">
  88. <span className="flex items-center gap-2">
  89. <Scale className="w-3.5 h-3.5 text-bambu-gray" />
  90. {t('inventory.coreWeight')}
  91. </span>
  92. </label>
  93. <div className="flex gap-2 items-center">
  94. <div className="flex-1 min-w-0 relative" ref={dropdownRef}>
  95. <input
  96. ref={inputRef}
  97. type="text"
  98. 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"
  99. placeholder={t('inventory.searchSpoolWeight')}
  100. value={displayValue}
  101. onFocus={() => {
  102. setIsOpen(true);
  103. setSearch('');
  104. }}
  105. onChange={(e) => {
  106. setSearch(e.target.value);
  107. setIsOpen(true);
  108. }}
  109. />
  110. {isOpen && (
  111. <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
  112. {filtered.length === 0 ? (
  113. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  114. ) : (
  115. filtered.map(entry => (
  116. <button
  117. key={entry.id}
  118. type="button"
  119. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
  120. (catalogId ? entry.id === catalogId : entry.weight === value)
  121. ? 'bg-bambu-green/10 text-bambu-green'
  122. : 'text-white'
  123. }`}
  124. onClick={() => {
  125. onCatalogIdChange(entry.id);
  126. onChange(entry.weight);
  127. setIsOpen(false);
  128. setSearch('');
  129. }}
  130. >
  131. <span className="truncate">{entry.name}</span>
  132. <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
  133. </button>
  134. ))
  135. )}
  136. </div>
  137. )}
  138. </div>
  139. <div className="flex items-center gap-1 shrink-0">
  140. <input
  141. type="number"
  142. className="w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green"
  143. value={value}
  144. min={0}
  145. max={2000}
  146. onChange={(e) => {
  147. const val = parseInt(e.target.value);
  148. if (!isNaN(val) && val >= 0) onChange(val);
  149. }}
  150. />
  151. <span className="text-bambu-gray text-sm">g</span>
  152. </div>
  153. </div>
  154. </div>
  155. );
  156. }
  157. export function AdditionalSection({
  158. formData,
  159. updateField,
  160. spoolCatalog,
  161. currencySymbol,
  162. availableCategories,
  163. globalLowStockThreshold,
  164. spoolmanMode = false,
  165. }: AdditionalSectionProps) {
  166. const { t } = useTranslation();
  167. const { showToast } = useToast();
  168. const [measuredInput, setMeasuredInput] = useState('');
  169. const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
  170. const [remainingInput, setRemainingInput] = useState('');
  171. const [isRemainingFocused, setIsRemainingFocused] = useState(false);
  172. const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
  173. const measuredDefault = formData.core_weight + remainingWeight;
  174. useEffect(() => {
  175. if (!isMeasuredFocused) {
  176. setMeasuredInput(String(measuredDefault));
  177. }
  178. }, [isMeasuredFocused, measuredDefault]);
  179. useEffect(() => {
  180. if (!isRemainingFocused) {
  181. setRemainingInput(String(remainingWeight));
  182. }
  183. }, [isRemainingFocused, remainingWeight]);
  184. return (
  185. <div className="space-y-4">
  186. {/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */}
  187. {spoolmanMode ? (
  188. <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
  189. ) : (
  190. <SpoolWeightPicker
  191. catalog={spoolCatalog}
  192. value={formData.core_weight}
  193. onChange={(weight) => updateField('core_weight', weight)}
  194. catalogId={formData.core_weight_catalog_id}
  195. onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
  196. />
  197. )}
  198. {/* Current Weight (remaining filament) */}
  199. <div>
  200. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
  201. <div className="flex items-center gap-2">
  202. <div className="relative flex-1">
  203. <input
  204. type="number"
  205. value={remainingInput}
  206. min={0}
  207. max={formData.label_weight}
  208. onFocus={() => setIsRemainingFocused(true)}
  209. onChange={(e) => {
  210. setRemainingInput(e.target.value);
  211. }}
  212. onBlur={() => {
  213. setIsRemainingFocused(false);
  214. const raw = remainingInput.trim();
  215. const remaining = Number(raw);
  216. if (!raw || !Number.isFinite(remaining) || remaining < 0 || remaining > formData.label_weight) {
  217. setRemainingInput(String(remainingWeight));
  218. return;
  219. }
  220. const rounded = Math.round(remaining);
  221. updateField('weight_used', Math.max(0, formData.label_weight - rounded));
  222. setRemainingInput(String(rounded));
  223. }}
  224. className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  225. />
  226. <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
  227. </div>
  228. <span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
  229. </div>
  230. </div>
  231. {/* Measured Weight (empty spool + remaining filament) */}
  232. <div>
  233. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.measuredWeight')}</label>
  234. <div className="flex items-center gap-2">
  235. <div className="relative flex-1">
  236. <input
  237. type="number"
  238. value={measuredInput}
  239. min={0}
  240. onFocus={() => setIsMeasuredFocused(true)}
  241. onChange={(e) => {
  242. setMeasuredInput(e.target.value);
  243. }}
  244. onBlur={() => {
  245. setIsMeasuredFocused(false);
  246. const raw = measuredInput.trim();
  247. const measured = Number(raw);
  248. const minAllowed = formData.core_weight;
  249. const maxAllowed = formData.core_weight + formData.label_weight;
  250. if (!raw || !Number.isFinite(measured) || measured < minAllowed || measured > maxAllowed) {
  251. showToast(t('inventory.measuredWeightError', { min: minAllowed, max: maxAllowed }), 'error');
  252. setMeasuredInput(String(measuredDefault));
  253. return;
  254. }
  255. const rounded = Math.round(measured);
  256. const remaining = Math.max(0, Math.min(formData.label_weight, rounded - formData.core_weight));
  257. updateField('weight_used', Math.max(0, formData.label_weight - remaining));
  258. setMeasuredInput(String(rounded));
  259. }}
  260. className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  261. />
  262. <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
  263. </div>
  264. <span className="text-xs text-bambu-gray shrink-0">/ {formData.core_weight + formData.label_weight}g</span>
  265. </div>
  266. </div>
  267. {/* Cost per kg */}
  268. <div>
  269. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.costPerKg', 'Cost per kg')}</label>
  270. <div className="flex items-center gap-2">
  271. <div className="relative flex-1">
  272. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">{currencySymbol}</span>
  273. <input
  274. type="number"
  275. value={formData.cost_per_kg ?? ''}
  276. min={0}
  277. step={0.01}
  278. placeholder="0.00"
  279. onChange={(e) => {
  280. const value = e.target.value === '' ? null : parseFloat(e.target.value);
  281. updateField('cost_per_kg', value);
  282. }}
  283. style={{ paddingLeft: `${Math.max(2, currencySymbol.length * 0.6 + 1)}rem` }}
  284. className="w-full py-2 pr-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  285. />
  286. </div>
  287. </div>
  288. </div>
  289. {/* Category (#729) */}
  290. <div>
  291. <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">
  292. {t('inventory.category')}
  293. </label>
  294. <input
  295. id="spool-category"
  296. type="text"
  297. list="spool-category-options"
  298. 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"
  299. placeholder={t('inventory.categoryPlaceholder')}
  300. value={formData.category}
  301. maxLength={50}
  302. onChange={(e) => updateField('category', e.target.value)}
  303. />
  304. {availableCategories.length > 0 && (
  305. <datalist id="spool-category-options">
  306. {availableCategories.map((c) => <option key={c} value={c} />)}
  307. </datalist>
  308. )}
  309. </div>
  310. {/* Per-spool low-stock threshold override (#729) */}
  311. <div>
  312. <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-low-stock-threshold">
  313. {t('inventory.lowStockThresholdOverride')}
  314. </label>
  315. <div className="flex items-center gap-2">
  316. <div className="relative flex-1">
  317. <input
  318. id="spool-low-stock-threshold"
  319. type="number"
  320. className="w-full px-3 py-2 pr-8 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"
  321. placeholder={String(globalLowStockThreshold)}
  322. value={formData.low_stock_threshold_pct ?? ''}
  323. min={1}
  324. max={99}
  325. step={1}
  326. onChange={(e) => {
  327. const raw = e.target.value;
  328. if (raw === '') {
  329. updateField('low_stock_threshold_pct', null);
  330. return;
  331. }
  332. const n = Number(raw);
  333. if (Number.isFinite(n)) {
  334. updateField('low_stock_threshold_pct', Math.min(99, Math.max(1, Math.round(n))));
  335. }
  336. }}
  337. />
  338. <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">%</span>
  339. </div>
  340. </div>
  341. <p className="text-xs text-bambu-gray mt-1">
  342. {t('inventory.lowStockThresholdOverrideHelp', { global: globalLowStockThreshold })}
  343. </p>
  344. </div>
  345. {/* Note */}
  346. <div>
  347. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
  348. <textarea
  349. 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 resize-none min-h-[80px]"
  350. placeholder={t('inventory.notePlaceholder')}
  351. value={formData.note}
  352. onChange={(e) => updateField('note', e.target.value)}
  353. />
  354. </div>
  355. {/* Storage Location */}
  356. <div>
  357. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.storageLocation')}</label>
  358. <input
  359. type="text"
  360. maxLength={255}
  361. 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"
  362. placeholder={t('inventory.storageLocationPlaceholder')}
  363. value={formData.storage_location}
  364. onChange={(e) => updateField('storage_location', e.target.value)}
  365. />
  366. </div>
  367. </div>
  368. );
  369. }