From 30b619016372681e5ab991a3922770a76eab9eea Mon Sep 17 00:00:00 2001 From: Matteo Parenti Date: Fri, 13 Feb 2026 16:26:18 +0100 Subject: [PATCH] Add Spool card fixes and additions, It locale update --- .../spool-form/AdditionalSection.tsx | 54 ++++++++++++++- .../components/spool-form/FilamentSection.tsx | 69 ++++++++++++++++--- frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/fr.ts | 4 +- frontend/src/i18n/locales/it.ts | 58 ++++++++++++++++ frontend/src/i18n/locales/ja.ts | 2 + frontend/src/pages/LoginPage.tsx | 8 +-- frontend/src/pages/UsersPage.tsx | 8 +-- 9 files changed, 188 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index e8e4233f9..2921479c3 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -1,6 +1,7 @@ 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({ @@ -129,6 +130,18 @@ export function AdditionalSection({ spoolCatalog, }: AdditionalSectionProps) { const { t } = useTranslation(); + const { showToast } = useToast(); + const [measuredInput, setMeasuredInput] = useState(''); + const [isMeasuredFocused, setIsMeasuredFocused] = 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]); return (
@@ -146,7 +159,7 @@ export function AdditionalSection({
{ @@ -161,6 +174,45 @@ export function AdditionalSection({
+ {/* Measured Weight (empty spool + remaining filament) */} +
+ +
+
+ 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" + /> + g +
+ / {formData.core_weight + formData.label_weight}g +
+
+ {/* Note */}
diff --git a/frontend/src/components/spool-form/FilamentSection.tsx b/frontend/src/components/spool-form/FilamentSection.tsx index badbb98f9..cc5748d8c 100644 --- a/frontend/src/components/spool-form/FilamentSection.tsx +++ b/frontend/src/components/spool-form/FilamentSection.tsx @@ -19,9 +19,12 @@ export function FilamentSection({ const { t } = useTranslation(); const [presetDropdownOpen, setPresetDropdownOpen] = useState(false); const [brandDropdownOpen, setBrandDropdownOpen] = useState(false); + const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false); const [brandSearch, setBrandSearch] = useState(''); + const [subtypeSearch, setSubtypeSearch] = useState(''); const presetRef = useRef(null); const brandRef = useRef(null); + const subtypeRef = useRef(null); // Close dropdowns on outside click useEffect(() => { @@ -32,6 +35,9 @@ export function FilamentSection({ if (brandRef.current && !brandRef.current.contains(e.target as Node)) { setBrandDropdownOpen(false); } + if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) { + setSubtypeDropdownOpen(false); + } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); @@ -54,6 +60,12 @@ export function FilamentSection({ return availableBrands.filter(b => b.toLowerCase().includes(search)); }, [availableBrands, brandSearch]); + const filteredVariants = useMemo(() => { + if (!subtypeSearch) return KNOWN_VARIANTS; + const search = subtypeSearch.toLowerCase(); + return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search)); + }, [subtypeSearch]); + // Handle preset selection const handlePresetSelect = (option: FilamentOption) => { updateField('slicer_filament', option.code); @@ -210,20 +222,59 @@ export function FilamentSection({ {/* Variant / Subtype */}
-
+
updateField('subtype', e.target.value)} - list="variant-suggestions" + value={subtypeDropdownOpen ? subtypeSearch : formData.subtype} + onChange={(e) => { + setSubtypeSearch(e.target.value); + setSubtypeDropdownOpen(true); + }} + onFocus={() => { + setSubtypeDropdownOpen(true); + setSubtypeSearch(''); + }} placeholder="Basic, Matte, Silk..." 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" /> - - {KNOWN_VARIANTS.map(v => ( - + + {subtypeDropdownOpen && ( +
+ {filteredVariants.length === 0 ? ( +
{t('inventory.noResults')}
+ ) : ( + filteredVariants.map(variant => ( + + )) + )} + {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && ( + + )} +
+ )}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 65f06393a..52bf72e7e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2462,6 +2462,8 @@ export default { searchSpoolWeight: 'Spulengewicht suchen...', weightUsed: 'Verbraucht', currentWeight: 'Restgewicht', + measuredWeight: 'Gemessenes Gewicht', + measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.', slicerFilament: 'Slicer-Filament', slicerFilamentName: 'Slicer-Preset-Name', slicerPreset: 'Slicer-Preset', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4fb13fd1e..008348aae 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2462,6 +2462,8 @@ export default { searchSpoolWeight: 'Search spool weight...', weightUsed: 'Used', currentWeight: 'Remaining Weight', + measuredWeight: 'Measured Weight', + measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.', slicerFilament: 'Slicer Filament', slicerFilamentName: 'Slicer Preset Name', slicerPreset: 'Slicer Preset', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 15ee16795..96acda383 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2458,6 +2458,8 @@ export default { searchSpoolWeight: 'Chercher poids bobine...', weightUsed: 'Consommé', currentWeight: 'Poids restant', + measuredWeight: 'Poids mesuré', + measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.', slicerFilament: 'Filament Slicer', slicerFilamentName: 'Nom du Preset Slicer', slicerPreset: 'Preset Slicer', @@ -3347,4 +3349,4 @@ export default { // Spoolman Settings spoolmanSettings: {}, -}; \ No newline at end of file +}; diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 9cb482a55..f80d4b4e2 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2261,6 +2261,64 @@ export default { reportPartialUsageDesc: 'Quando una stampa fallisce o viene annullata, segnala il filamento stimato usato fino a quel punto in base all\'avanzamento layer.', }, + // Inventory + inventory: { + title: 'Inventario Bobine', + addSpool: 'Aggiungi Bobina', + editSpool: 'Modifica Bobina', + material: 'Materiale', + selectMaterial: 'Seleziona materiale...', + subtype: 'Sottotipo', + brand: 'Marchio', + searchBrand: 'Cerca marchio...', + useCustomBrand: 'Usa "{{brand}}"', + colorName: 'Nome Colore', + colorNamePlaceholder: 'Jade White, Fire Red...', + color: 'Colore', + hexColor: 'Colore Hex', + pickColor: 'Scegli colore personalizzato', + labelWeight: 'Peso da Etichetta', + coreWeight: 'Peso Bobina Vuota', + searchSpoolWeight: 'Cerca peso bobina...', + weightUsed: 'Utilizzato', + currentWeight: 'Peso Rimanente', + measuredWeight: 'Peso Misurato', + measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.', + slicerFilament: 'Filamento Slicer', + slicerFilamentName: 'Nome Preset Slicer', + slicerPreset: 'Preset Slicer', + searchPresets: 'Cerca preset filamento...', + selectedPreset: 'Selezionato', + noPresetsFound: 'Nessun preset trovato', + tempOverrides: 'Override Temperatura', + note: 'Nota', + notePlaceholder: 'Eventuali note aggiuntive su questa bobina...', + archive: 'Archivia', + restore: 'Ripristina', + noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.', + noManualSpools: 'Nessuna bobina aggiunta manualmente disponibile. Aggiungi prima una bobina al tuo inventario.', + kProfiles: 'K-Profiles', + addKProfile: 'Aggiungi K-Profile', + assignSpool: 'Assegna Bobina', + unassignSpool: 'Deassegna', + assignSuccess: 'Bobina assegnata e slot AMS configurato', + assignFailed: 'Assegnazione bobina fallita', + selectSpool: 'Seleziona una bobina da assegnare a questo slot', + assigned: 'Assegnato', + assigning: 'Assegnazione...', + searchSpools: 'Cerca bobine...', + allMaterials: 'Tutti i Materiali', + filterByBrand: 'Filtra per marchio...', + showArchived: 'Mostra archiviate', + spoolCreated: 'Bobina creata', + spoolUpdated: 'Bobina aggiornata', + spoolDeleted: 'Bobina eliminata', + spoolArchived: 'Bobina archiviata', + spoolRestored: 'Bobina ripristinata', + deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.', + advancedSettings: 'Impostazioni Avanzate', + }, + // Timelapse timelapse: { title: 'Timelapse', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 485aa246f..c3d26fa22 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2393,6 +2393,8 @@ export default { searchSpoolWeight: 'スプール重量を検索...', weightUsed: '使用量', currentWeight: '残量', + measuredWeight: '計測重量', + measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。', slicerFilament: 'スライサーフィラメント', slicerFilamentName: 'スライサープリセット名', slicerPreset: 'スライサープリセット', diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 1709c09a7..f8483aa28 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -91,7 +91,7 @@ export function LoginPage() {
@@ -102,7 +102,7 @@ export function LoginPage() { value={username} onChange={(e) => setUsername(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" - placeholder={advancedAuthStatus?.advanced_auth_enabled + placeholder={advancedAuthStatus?.advanced_auth_enabled ? t('login.usernameOrEmailPlaceholder') || 'Enter your username or email' : t('login.usernamePlaceholder')} autoComplete="username" @@ -217,8 +217,8 @@ export function LoginPage() { className="flex-1" disabled={forgotPasswordMutation.isPending} > - {forgotPasswordMutation.isPending - ? (t('login.sending') || 'Sending...') + {forgotPasswordMutation.isPending + ? (t('login.sending') || 'Sending...') : (t('login.sendResetEmail') || 'Send Reset Email')}
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index 9e31348d9..408988d1a 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -147,7 +147,7 @@ export function UsersPage() { const handleCreate = () => { // Use the status from the query hook const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false; - + if (!formData.username) { const errorMsg = t('users.toast.fillRequired'); showToast(errorMsg, 'error'); @@ -156,7 +156,7 @@ export function UsersPage() { } return; } - + // Email is required when advanced auth is enabled if (advancedAuthEnabled && !formData.email) { const errorMsg = 'Email is required when advanced authentication is enabled'; @@ -164,7 +164,7 @@ export function UsersPage() { console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled'); return; } - + // Password validation only when advanced auth is disabled if (!advancedAuthEnabled) { if (!formData.password) { @@ -180,7 +180,7 @@ export function UsersPage() { return; } } - + createMutation.mutate({ username: formData.username, password: advancedAuthEnabled ? undefined : formData.password,