AdditionalSection.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { useState, useRef, useEffect, useMemo } from 'react';
  2. import { Scale } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type { AdditionalSectionProps } from './types';
  5. function SpoolWeightPicker({
  6. catalog,
  7. value,
  8. onChange,
  9. }: {
  10. catalog: { id: number; name: string; weight: number }[];
  11. value: number;
  12. onChange: (weight: number) => void;
  13. }) {
  14. const { t } = useTranslation();
  15. const [isOpen, setIsOpen] = useState(false);
  16. const [search, setSearch] = useState('');
  17. const [selectedId, setSelectedId] = useState<number | null>(null);
  18. const dropdownRef = useRef<HTMLDivElement>(null);
  19. const inputRef = useRef<HTMLInputElement>(null);
  20. useEffect(() => {
  21. const handleClick = (e: MouseEvent) => {
  22. if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
  23. setIsOpen(false);
  24. }
  25. };
  26. document.addEventListener('mousedown', handleClick);
  27. return () => document.removeEventListener('mousedown', handleClick);
  28. }, []);
  29. const filtered = useMemo(() => {
  30. if (!search) return catalog;
  31. const s = search.toLowerCase();
  32. return catalog.filter(e =>
  33. e.name.toLowerCase().includes(s) ||
  34. e.weight.toString().includes(s),
  35. );
  36. }, [catalog, search]);
  37. // Display value: show catalog name if selected, or the weight
  38. const displayValue = useMemo(() => {
  39. if (isOpen) return search;
  40. if (selectedId) {
  41. const entry = catalog.find(e => e.id === selectedId);
  42. if (entry) return entry.name;
  43. }
  44. const match = catalog.find(e => e.weight === value);
  45. if (match) return match.name;
  46. return '';
  47. }, [isOpen, search, selectedId, catalog, value]);
  48. return (
  49. <div>
  50. <label className="block text-sm font-medium text-bambu-gray mb-1">
  51. <span className="flex items-center gap-2">
  52. <Scale className="w-3.5 h-3.5 text-bambu-gray" />
  53. {t('inventory.coreWeight')}
  54. </span>
  55. </label>
  56. <div className="flex gap-2 items-center">
  57. <div className="flex-1 min-w-0 relative" ref={dropdownRef}>
  58. <input
  59. ref={inputRef}
  60. type="text"
  61. 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"
  62. placeholder={t('inventory.searchSpoolWeight')}
  63. value={displayValue}
  64. onFocus={() => {
  65. setIsOpen(true);
  66. setSearch('');
  67. }}
  68. onChange={(e) => {
  69. setSearch(e.target.value);
  70. setIsOpen(true);
  71. }}
  72. />
  73. {isOpen && (
  74. <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">
  75. {filtered.length === 0 ? (
  76. <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
  77. ) : (
  78. filtered.map(entry => (
  79. <button
  80. key={entry.id}
  81. type="button"
  82. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
  83. (selectedId ? entry.id === selectedId : entry.weight === value)
  84. ? 'bg-bambu-green/10 text-bambu-green'
  85. : 'text-white'
  86. }`}
  87. onClick={() => {
  88. setSelectedId(entry.id);
  89. onChange(entry.weight);
  90. setIsOpen(false);
  91. setSearch('');
  92. }}
  93. >
  94. <span className="truncate">{entry.name}</span>
  95. <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
  96. </button>
  97. ))
  98. )}
  99. </div>
  100. )}
  101. </div>
  102. <div className="flex items-center gap-1 shrink-0">
  103. <input
  104. type="number"
  105. 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"
  106. value={value}
  107. min={0}
  108. max={2000}
  109. onChange={(e) => {
  110. const val = parseInt(e.target.value);
  111. if (!isNaN(val) && val >= 0) onChange(val);
  112. }}
  113. />
  114. <span className="text-bambu-gray text-sm">g</span>
  115. </div>
  116. </div>
  117. </div>
  118. );
  119. }
  120. export function AdditionalSection({
  121. formData,
  122. updateField,
  123. spoolCatalog,
  124. }: AdditionalSectionProps) {
  125. const { t } = useTranslation();
  126. return (
  127. <div className="space-y-4">
  128. {/* Empty Spool Weight */}
  129. <SpoolWeightPicker
  130. catalog={spoolCatalog}
  131. value={formData.core_weight}
  132. onChange={(weight) => updateField('core_weight', weight)}
  133. />
  134. {/* Current Weight (remaining filament) */}
  135. <div>
  136. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
  137. <div className="flex items-center gap-2">
  138. <div className="relative flex-1">
  139. <input
  140. type="number"
  141. value={Math.max(0, formData.label_weight - formData.weight_used)}
  142. min={0}
  143. max={formData.label_weight}
  144. onChange={(e) => {
  145. const remaining = parseInt(e.target.value) || 0;
  146. updateField('weight_used', Math.max(0, formData.label_weight - remaining));
  147. }}
  148. 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"
  149. />
  150. <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
  151. </div>
  152. <span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
  153. </div>
  154. </div>
  155. {/* Note */}
  156. <div>
  157. <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
  158. <textarea
  159. 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]"
  160. placeholder={t('inventory.notePlaceholder')}
  161. value={formData.note}
  162. onChange={(e) => updateField('note', e.target.value)}
  163. />
  164. </div>
  165. </div>
  166. );
  167. }