Просмотр исходного кода

Add Spool card fixes and additions, It locale update

Matteo Parenti 3 месяцев назад
Родитель
Сommit
30b6190163

+ 53 - 1
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -1,6 +1,7 @@
 import { useState, useRef, useEffect, useMemo } from 'react';
 import { useState, useRef, useEffect, useMemo } from 'react';
 import { Scale } from 'lucide-react';
 import { Scale } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { useToast } from '../../contexts/ToastContext';
 import type { AdditionalSectionProps } from './types';
 import type { AdditionalSectionProps } from './types';
 
 
 function SpoolWeightPicker({
 function SpoolWeightPicker({
@@ -129,6 +130,18 @@ export function AdditionalSection({
   spoolCatalog,
   spoolCatalog,
 }: AdditionalSectionProps) {
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   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 (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
@@ -146,7 +159,7 @@ export function AdditionalSection({
           <div className="relative flex-1">
           <div className="relative flex-1">
             <input
             <input
               type="number"
               type="number"
-              value={Math.max(0, formData.label_weight - formData.weight_used)}
+              value={remainingWeight}
               min={0}
               min={0}
               max={formData.label_weight}
               max={formData.label_weight}
               onChange={(e) => {
               onChange={(e) => {
@@ -161,6 +174,45 @@ export function AdditionalSection({
         </div>
         </div>
       </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>
+
       {/* Note */}
       {/* Note */}
       <div>
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>

+ 60 - 9
frontend/src/components/spool-form/FilamentSection.tsx

@@ -19,9 +19,12 @@ export function FilamentSection({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
   const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
   const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
   const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
+  const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
   const [brandSearch, setBrandSearch] = useState('');
   const [brandSearch, setBrandSearch] = useState('');
+  const [subtypeSearch, setSubtypeSearch] = useState('');
   const presetRef = useRef<HTMLDivElement>(null);
   const presetRef = useRef<HTMLDivElement>(null);
   const brandRef = useRef<HTMLDivElement>(null);
   const brandRef = useRef<HTMLDivElement>(null);
+  const subtypeRef = useRef<HTMLDivElement>(null);
 
 
   // Close dropdowns on outside click
   // Close dropdowns on outside click
   useEffect(() => {
   useEffect(() => {
@@ -32,6 +35,9 @@ export function FilamentSection({
       if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
       if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
         setBrandDropdownOpen(false);
         setBrandDropdownOpen(false);
       }
       }
+      if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {
+        setSubtypeDropdownOpen(false);
+      }
     };
     };
     document.addEventListener('mousedown', handleClick);
     document.addEventListener('mousedown', handleClick);
     return () => document.removeEventListener('mousedown', handleClick);
     return () => document.removeEventListener('mousedown', handleClick);
@@ -54,6 +60,12 @@ export function FilamentSection({
     return availableBrands.filter(b => b.toLowerCase().includes(search));
     return availableBrands.filter(b => b.toLowerCase().includes(search));
   }, [availableBrands, brandSearch]);
   }, [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
   // Handle preset selection
   const handlePresetSelect = (option: FilamentOption) => {
   const handlePresetSelect = (option: FilamentOption) => {
     updateField('slicer_filament', option.code);
     updateField('slicer_filament', option.code);
@@ -210,20 +222,59 @@ export function FilamentSection({
       {/* Variant / Subtype */}
       {/* Variant / Subtype */}
       <div>
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
-        <div className="relative">
+        <div className="relative" ref={subtypeRef}>
           <input
           <input
             type="text"
             type="text"
-            value={formData.subtype}
-            onChange={(e) => 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..."
             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"
             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"
           />
           />
-          <datalist id="variant-suggestions">
-            {KNOWN_VARIANTS.map(v => (
-              <option key={v} value={v} />
-            ))}
-          </datalist>
+          <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+          {subtypeDropdownOpen && (
+            <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+              {filteredVariants.length === 0 ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+              ) : (
+                filteredVariants.map(variant => (
+                  <button
+                    key={variant}
+                    type="button"
+                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                      formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+                    }`}
+                    onClick={() => {
+                      updateField('subtype', variant);
+                      setSubtypeDropdownOpen(false);
+                      setSubtypeSearch('');
+                    }}
+                  >
+                    {variant}
+                  </button>
+                ))
+              )}
+              {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
+                <button
+                  type="button"
+                  className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
+                  onClick={() => {
+                    updateField('subtype', subtypeSearch);
+                    setSubtypeDropdownOpen(false);
+                    setSubtypeSearch('');
+                  }}
+                >
+                  {t('inventory.useCustomBrand', { brand: subtypeSearch })}
+                </button>
+              )}
+            </div>
+          )}
         </div>
         </div>
       </div>
       </div>
 
 

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -2462,6 +2462,8 @@ export default {
     searchSpoolWeight: 'Spulengewicht suchen...',
     searchSpoolWeight: 'Spulengewicht suchen...',
     weightUsed: 'Verbraucht',
     weightUsed: 'Verbraucht',
     currentWeight: 'Restgewicht',
     currentWeight: 'Restgewicht',
+    measuredWeight: 'Gemessenes Gewicht',
+    measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
     slicerFilament: 'Slicer-Filament',
     slicerFilament: 'Slicer-Filament',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerPreset: 'Slicer-Preset',
     slicerPreset: 'Slicer-Preset',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -2462,6 +2462,8 @@ export default {
     searchSpoolWeight: 'Search spool weight...',
     searchSpoolWeight: 'Search spool weight...',
     weightUsed: 'Used',
     weightUsed: 'Used',
     currentWeight: 'Remaining Weight',
     currentWeight: 'Remaining Weight',
+    measuredWeight: 'Measured Weight',
+    measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
     slicerFilament: 'Slicer Filament',
     slicerFilament: 'Slicer Filament',
     slicerFilamentName: 'Slicer Preset Name',
     slicerFilamentName: 'Slicer Preset Name',
     slicerPreset: 'Slicer Preset',
     slicerPreset: 'Slicer Preset',

+ 3 - 1
frontend/src/i18n/locales/fr.ts

@@ -2458,6 +2458,8 @@ export default {
     searchSpoolWeight: 'Chercher poids bobine...',
     searchSpoolWeight: 'Chercher poids bobine...',
     weightUsed: 'Consommé',
     weightUsed: 'Consommé',
     currentWeight: 'Poids restant',
     currentWeight: 'Poids restant',
+    measuredWeight: 'Poids mesuré',
+    measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
     slicerFilament: 'Filament Slicer',
     slicerFilament: 'Filament Slicer',
     slicerFilamentName: 'Nom du Preset Slicer',
     slicerFilamentName: 'Nom du Preset Slicer',
     slicerPreset: 'Preset Slicer',
     slicerPreset: 'Preset Slicer',
@@ -3347,4 +3349,4 @@ export default {
 
 
   // Spoolman Settings
   // Spoolman Settings
   spoolmanSettings: {},
   spoolmanSettings: {},
-};
+};

+ 58 - 0
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.',
     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
   timelapse: {
   timelapse: {
     title: 'Timelapse',
     title: 'Timelapse',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -2393,6 +2393,8 @@ export default {
     searchSpoolWeight: 'スプール重量を検索...',
     searchSpoolWeight: 'スプール重量を検索...',
     weightUsed: '使用量',
     weightUsed: '使用量',
     currentWeight: '残量',
     currentWeight: '残量',
+    measuredWeight: '計測重量',
+    measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
     slicerFilament: 'スライサーフィラメント',
     slicerFilament: 'スライサーフィラメント',
     slicerFilamentName: 'スライサープリセット名',
     slicerFilamentName: 'スライサープリセット名',
     slicerPreset: 'スライサープリセット',
     slicerPreset: 'スライサープリセット',

+ 4 - 4
frontend/src/pages/LoginPage.tsx

@@ -91,7 +91,7 @@ export function LoginPage() {
           <div className="space-y-4">
           <div className="space-y-4">
             <div>
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
-                {advancedAuthStatus?.advanced_auth_enabled 
+                {advancedAuthStatus?.advanced_auth_enabled
                   ? t('login.usernameOrEmail') || 'Username or Email'
                   ? t('login.usernameOrEmail') || 'Username or Email'
                   : t('login.username')}
                   : t('login.username')}
               </label>
               </label>
@@ -102,7 +102,7 @@ export function LoginPage() {
                 value={username}
                 value={username}
                 onChange={(e) => setUsername(e.target.value)}
                 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"
                 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.usernameOrEmailPlaceholder') || 'Enter your username or email'
                   : t('login.usernamePlaceholder')}
                   : t('login.usernamePlaceholder')}
                 autoComplete="username"
                 autoComplete="username"
@@ -217,8 +217,8 @@ export function LoginPage() {
                       className="flex-1"
                       className="flex-1"
                       disabled={forgotPasswordMutation.isPending}
                       disabled={forgotPasswordMutation.isPending}
                     >
                     >
-                      {forgotPasswordMutation.isPending 
-                        ? (t('login.sending') || 'Sending...') 
+                      {forgotPasswordMutation.isPending
+                        ? (t('login.sending') || 'Sending...')
                         : (t('login.sendResetEmail') || 'Send Reset Email')}
                         : (t('login.sendResetEmail') || 'Send Reset Email')}
                     </Button>
                     </Button>
                   </div>
                   </div>

+ 4 - 4
frontend/src/pages/UsersPage.tsx

@@ -147,7 +147,7 @@ export function UsersPage() {
   const handleCreate = () => {
   const handleCreate = () => {
     // Use the status from the query hook
     // Use the status from the query hook
     const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
     const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
-    
+
     if (!formData.username) {
     if (!formData.username) {
       const errorMsg = t('users.toast.fillRequired');
       const errorMsg = t('users.toast.fillRequired');
       showToast(errorMsg, 'error');
       showToast(errorMsg, 'error');
@@ -156,7 +156,7 @@ export function UsersPage() {
       }
       }
       return;
       return;
     }
     }
-    
+
     // Email is required when advanced auth is enabled
     // Email is required when advanced auth is enabled
     if (advancedAuthEnabled && !formData.email) {
     if (advancedAuthEnabled && !formData.email) {
       const errorMsg = 'Email is required when advanced authentication is enabled';
       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');
       console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');
       return;
       return;
     }
     }
-    
+
     // Password validation only when advanced auth is disabled
     // Password validation only when advanced auth is disabled
     if (!advancedAuthEnabled) {
     if (!advancedAuthEnabled) {
       if (!formData.password) {
       if (!formData.password) {
@@ -180,7 +180,7 @@ export function UsersPage() {
         return;
         return;
       }
       }
     }
     }
-    
+
     createMutation.mutate({
     createMutation.mutate({
       username: formData.username,
       username: formData.username,
       password: advancedAuthEnabled ? undefined : formData.password,
       password: advancedAuthEnabled ? undefined : formData.password,