Files
bambuddy/frontend/src/components/spool-form/AdditionalSection.tsx
T
KeyboredandMartinNYHC 8b081dbd9a Allow external materials/brands to be used when adding spool (#455)
* feat(spool-form): enhance brand and material selection in color section

* feat(spool-form): change input field behavoiur when deleting all chars, fix scroll menu theming

* feat(spool-form): add available materials to SpoolFormModal and FilamentSection

* refactor(ColorSection): remove unnecessary hasCatalogMatch variable

* feat(SpoolFormModal): add bilateral brand and material filtering logic

* feat(ColorCatalogSettings): improve input field sizes and layout for color and material selection

* Refactor colorCatalog settings and dependencies, allow custom materials, fix ml-12 marging, show catalog colors on brand/material selection

* Refactor ColorCatalogSettings and ColorSection components; remove  onBlur logic in FilamentSection

* Prioritize exact matches in search results

* Added inventory.useCustomMaterial to locales

* Remove unused filteredCatalogColors logic and update button rendering; update types file to import Printer and SpoolKProfile

* Fix indentation for useCustomMaterial

* Refactor showCatalogSection logic to only depend on matched catalog colors

* Remove extra newline before brand dropdown

* Clean up ColorSection.tsx by removing blank lines

Removed unnecessary blank lines in ColorSection.tsx.

* Ensure material property is a string in ColorSection component

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
2026-02-20 18:34:24 +01:00

319 lines
12 KiB
TypeScript

import { useState, useRef, useEffect, useMemo } from 'react';
import { Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../../contexts/ToastContext';
import type { AdditionalSectionProps } from './types';
function SpoolWeightPicker({
catalog,
value,
onChange,
catalogId,
onCatalogIdChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
catalogId: number | null;
onCatalogIdChange: (id: number | null) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
// When value changes, auto-select if there's only one matching entry or keep selection if it still matches
useEffect(() => {
// If no catalog loaded yet, skip matching logic
if (catalog.length === 0) {
return;
}
const matches = catalog.filter(e => e.weight === value);
// If currently selected entry still matches the weight, keep it selected
if (catalogId) {
const selected = catalog.find(e => e.id === catalogId);
if (selected && selected.weight === value) {
return; // Keep current selection
}
}
// If exactly one match, auto-select it
if (matches.length === 1) {
onCatalogIdChange(matches[0].id);
} else if (matches.length === 0) {
// No matches, clear selection to prevent stale catalog ID
if (catalogId !== null) {
onCatalogIdChange(null);
}
}
// If multiple matches, don't auto-select - let user choose
}, [value, catalog, catalogId, onCatalogIdChange]);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
return catalog.filter(e =>
e.name.toLowerCase().includes(s) ||
e.weight.toString().includes(s),
);
}, [catalog, search]);
// Find all entries matching the current weight
const matchingEntries = useMemo(() => {
return catalog.filter(e => e.weight === value);
}, [catalog, value]);
// Display value: show catalog name if selected by ID, otherwise show first match
const displayValue = useMemo(() => {
if (isOpen) return search;
// If a catalog ID is explicitly selected, use that
if (catalogId) {
const entry = catalog.find(e => e.id === catalogId);
if (entry) return entry.name;
}
// Otherwise, show the first matching entry as a suggestion
if (matchingEntries.length > 0) {
return matchingEntries[0].name;
}
// Leave empty if there are no matches
return '';
}, [isOpen, search, catalogId, catalog, matchingEntries]);
return (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
<span className="flex items-center gap-2">
<Scale className="w-3.5 h-3.5 text-bambu-gray" />
{t('inventory.coreWeight')}
</span>
</label>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0 relative" ref={dropdownRef}>
<input
ref={inputRef}
type="text"
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"
placeholder={t('inventory.searchSpoolWeight')}
value={displayValue}
onFocus={() => {
setIsOpen(true);
setSearch('');
}}
onChange={(e) => {
setSearch(e.target.value);
setIsOpen(true);
}}
/>
{isOpen && (
<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">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filtered.map(entry => (
<button
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(catalogId ? entry.id === catalogId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
onCatalogIdChange(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
}}
>
<span className="truncate">{entry.name}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
</button>
))
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<input
type="number"
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"
value={value}
min={0}
max={2000}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0) onChange(val);
}}
/>
<span className="text-bambu-gray text-sm">g</span>
</div>
</div>
</div>
);
}
export function AdditionalSection({
formData,
updateField,
spoolCatalog,
currencySymbol,
}: AdditionalSectionProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const [measuredInput, setMeasuredInput] = useState('');
const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
const [remainingInput, setRemainingInput] = useState('');
const [isRemainingFocused, setIsRemainingFocused] = useState(false);
const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
const measuredDefault = formData.core_weight + remainingWeight;
useEffect(() => {
if (!isMeasuredFocused) {
setMeasuredInput(String(measuredDefault));
}
}, [isMeasuredFocused, measuredDefault]);
useEffect(() => {
if (!isRemainingFocused) {
setRemainingInput(String(remainingWeight));
}
}, [isRemainingFocused, remainingWeight]);
return (
<div className="space-y-4">
{/* Empty Spool Weight */}
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
catalogId={formData.core_weight_catalog_id}
onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
/>
{/* Current Weight (remaining filament) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
value={remainingInput}
min={0}
max={formData.label_weight}
onFocus={() => setIsRemainingFocused(true)}
onChange={(e) => {
setRemainingInput(e.target.value);
}}
onBlur={() => {
setIsRemainingFocused(false);
const raw = remainingInput.trim();
const remaining = Number(raw);
if (!raw || !Number.isFinite(remaining) || remaining < 0 || remaining > formData.label_weight) {
setRemainingInput(String(remainingWeight));
return;
}
const rounded = Math.round(remaining);
updateField('weight_used', Math.max(0, formData.label_weight - rounded));
setRemainingInput(String(rounded));
}}
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"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
<span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
</div>
</div>
{/* Measured Weight (empty spool + remaining filament) */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.measuredWeight')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="number"
value={measuredInput}
min={0}
onFocus={() => setIsMeasuredFocused(true)}
onChange={(e) => {
setMeasuredInput(e.target.value);
}}
onBlur={() => {
setIsMeasuredFocused(false);
const raw = measuredInput.trim();
const measured = Number(raw);
const minAllowed = formData.core_weight;
const maxAllowed = formData.core_weight + formData.label_weight;
if (!raw || !Number.isFinite(measured) || measured < minAllowed || measured > maxAllowed) {
showToast(t('inventory.measuredWeightError', { min: minAllowed, max: maxAllowed }), 'error');
setMeasuredInput(String(measuredDefault));
return;
}
const rounded = Math.round(measured);
const remaining = Math.max(0, Math.min(formData.label_weight, rounded - formData.core_weight));
updateField('weight_used', Math.max(0, formData.label_weight - remaining));
setMeasuredInput(String(rounded));
}}
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"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
<span className="text-xs text-bambu-gray shrink-0">/ {formData.core_weight + formData.label_weight}g</span>
</div>
</div>
{/* Cost per kg */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.costPerKg', 'Cost per kg')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">{currencySymbol}</span>
<input
type="number"
value={formData.cost_per_kg ?? ''}
min={0}
step={0.01}
placeholder="0.00"
onChange={(e) => {
const value = e.target.value === '' ? null : parseFloat(e.target.value);
updateField('cost_per_kg', value);
}}
style={{ paddingLeft: `${Math.max(2, currencySymbol.length * 0.6 + 1)}rem` }}
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"
/>
</div>
</div>
</div>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
<textarea
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]"
placeholder={t('inventory.notePlaceholder')}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
/>
</div>
</div>
);
}