Procházet zdrojové kódy

feat(#729): per-spool category + low-stock threshold override

  Two new optional fields on Spool: free-text `category` (max 50) and
  `low_stock_threshold_pct` (1-99). Powers the "differentiate critical
  spools from prototype spools and alert at different thresholds" use
  case from #729 without taking on the full multi-tag taxonomy + auto-
  apply rules + per-tag alert system the ticket originally proposed.

  Form gains:
  - Category input with datalist autocomplete sourced from categories
    already in use, so casing/spelling stays consistent.
  - Per-spool low-stock threshold input. Empty = global default; the
    global value renders as the placeholder.

  Inventory page:
  - New category filter chip (hidden until at least one spool carries
    a category — keeps the chip row uncluttered).
  - Stat-card "Low Stock" count and the "Low Stock" filter both honour
    the per-spool override.

  Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
  ticket reporter mistook it for a taxonomy-tag delete; the button
  actually clears the RFID UID/UUID off the spool record). Toast key
  renamed from `tagDeleted` to `rfidCleared`.

  i18n: full translations across all 8 locales.

  Tests: 9 new backend schema tests (defaults, partial-update, range
  rejection, max-length); 2 new frontend tests (per-spool threshold
  pulls extra spools into low-stock count, filter chip hidden when no
  categories exist).
maziggy před 1 měsícem
rodič
revize
4304a42542

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 2 - 0
CHANGELOG.md


+ 6 - 0
backend/app/core/database.py

@@ -1108,6 +1108,12 @@ async def run_migrations(conn):
 
     # Migration: Add cost tracking fields to spool table
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
+
+    # Migration: Per-spool category + low-stock threshold override (#729). Both
+    # nullable — NULL category leaves the spool uncategorised, NULL threshold
+    # falls back to the global low_stock_threshold setting.
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
     # Migration: Add cost field to spool_usage_history table
     await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
     # Migration: Add archive_id field to spool_usage_history table

+ 10 - 0
backend/app/models/spool.py

@@ -33,6 +33,16 @@ class Spool(Base):
     note: Mapped[str | None] = mapped_column(String(500))
     added_full: Mapped[bool | None] = mapped_column()  # Whether spool was added as full (unused)
 
+    # User-defined category (e.g. "Production", "Prototype", "Client A") for
+    # filtering and per-group low-stock thresholds (#729). Free text — the
+    # form autocompletes from categories already present on other spools.
+    category: Mapped[str | None] = mapped_column(String(50))
+    # Per-spool override of the global inventory low-stock threshold (%).
+    # NULL falls back to the `low_stock_threshold` setting. Lets users mark
+    # production spools with a higher threshold (alert earlier) and prototype
+    # spools with a lower one without changing the global default.
+    low_stock_threshold_pct: Mapped[int | None] = mapped_column(Integer)
+
     # Cost tracking
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
 

+ 6 - 0
backend/app/schemas/spool.py

@@ -26,6 +26,9 @@ class SpoolBase(BaseModel):
     weight_locked: bool = False
     last_scale_weight: int | None = None
     last_weighed_at: datetime | None = None
+    # User-defined category + per-spool low-stock threshold override (#729).
+    category: str | None = Field(default=None, max_length=50)
+    low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
 
 
 class SpoolCreate(SpoolBase):
@@ -58,6 +61,9 @@ class SpoolUpdate(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool | None = None
+    # User-defined category + per-spool low-stock threshold override (#729).
+    category: str | None = Field(default=None, max_length=50)
+    low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
 
 
 class SpoolKProfileBase(BaseModel):

+ 49 - 0
backend/tests/unit/test_spool_schemas_rgba.py

@@ -129,3 +129,52 @@ class TestSpoolResponseRgbaLeniency:
 
         response = SpoolResponse(**self._make_response_kwargs(rgba="FF00AAFF"))
         assert response.rgba == "FF00AAFF"
+
+
+class TestSpoolCategoryAndThreshold:
+    """#729: per-spool category + low-stock threshold override schema validation."""
+
+    def test_create_accepts_category_and_threshold(self):
+        spool = SpoolCreate(material="PLA", category="Production", low_stock_threshold_pct=50)
+        assert spool.category == "Production"
+        assert spool.low_stock_threshold_pct == 50
+
+    def test_create_defaults_to_null(self):
+        """Both new fields are optional and default to None — backward compat."""
+        spool = SpoolCreate(material="PLA")
+        assert spool.category is None
+        assert spool.low_stock_threshold_pct is None
+
+    def test_update_accepts_partial_changes(self):
+        spool = SpoolUpdate(category="Prototype")
+        assert spool.category == "Prototype"
+        assert spool.low_stock_threshold_pct is None
+
+    def test_update_clears_via_explicit_null(self):
+        """Sending null on PATCH explicitly resets the override."""
+        spool = SpoolUpdate(category=None, low_stock_threshold_pct=None)
+        assert spool.category is None
+        assert spool.low_stock_threshold_pct is None
+
+    def test_threshold_rejects_zero(self):
+        """0% would mean the spool is never low-stock — disallow as a footgun."""
+        with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
+            SpoolCreate(material="PLA", low_stock_threshold_pct=0)
+
+    def test_threshold_rejects_100(self):
+        """100% would mean the spool is always low-stock — disallow."""
+        with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
+            SpoolCreate(material="PLA", low_stock_threshold_pct=100)
+
+    def test_threshold_rejects_negative(self):
+        with pytest.raises(ValidationError, match="low_stock_threshold_pct"):
+            SpoolCreate(material="PLA", low_stock_threshold_pct=-5)
+
+    def test_category_rejects_too_long(self):
+        """50-char cap matches the DB column to prevent silent truncation."""
+        with pytest.raises(ValidationError, match="category"):
+            SpoolCreate(material="PLA", category="X" * 51)
+
+    def test_category_accepts_max_length(self):
+        spool = SpoolCreate(material="PLA", category="X" * 50)
+        assert spool.category == "X" * 50

+ 45 - 0
frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx

@@ -393,4 +393,49 @@ describe('InventoryPage - Low Stock Threshold', () => {
       // Implementation would show appropriate count based on 30% threshold
     });
   });
+
+  describe('per-spool overrides (#729)', () => {
+    it('per-spool low_stock_threshold_pct overrides the global threshold', async () => {
+      // Global = 20%. Spool 2 (PETG Blue) is at 80% remaining — normally NOT
+      // low-stock. But its per-spool threshold is 90% (it's a "production" spool
+      // the user wants to alert on early), so it MUST count as low-stock.
+      // Spool 1 (10%) and Spool 3 (15%) are already < 20% and stay low-stock.
+      // Expected low-stock count: 3 (was 2 before the override).
+      const spoolsWithOverride = mockSpools.map((s) =>
+        s.id === 2 ? { ...s, low_stock_threshold_pct: 90 } : s,
+      );
+      server.use(
+        http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolsWithOverride)),
+      );
+
+      render(<InventoryPageRouter />);
+
+      // The low-stock stat-card pill renders the count with "< 20%" suffix.
+      // Find it and confirm it reflects 3 (override pulled spool 2 in).
+      await waitFor(() => {
+        const pills = screen.getAllByText(/< 20%/i);
+        expect(pills.length).toBeGreaterThan(0);
+      });
+
+      // The exact count cell is rendered separately. Easiest robust assertion:
+      // there's a "3" in the low-stock card. Looking up by text would be brittle
+      // (other "3"s on the page), so we assert via the stat card structure.
+      // The card is keyed by the same "< 20%" pill we already found.
+      const pill = screen.getAllByText(/< 20%/i)[0];
+      const card = pill.closest('div')!.parentElement!;
+      // Card text reads e.g. "Low Stock3< 20%" with the count concatenated.
+      expect(card.textContent).toMatch(/Low Stock\D*3\D/);
+    });
+
+    it('category filter chip is hidden when no spool has a category', async () => {
+      // mockSpools all have category=undefined → chip shouldn't render.
+      render(<InventoryPageRouter />);
+      await waitFor(() => {
+        expect(screen.getAllByText(/< 20%/i).length).toBeGreaterThan(0);
+      });
+      // None of the spools carry a category, so the inventory.category chip
+      // never appears.
+      expect(screen.queryByRole('combobox', { name: /category/i })).not.toBeInTheDocument();
+    });
+  });
 });

+ 3 - 0
frontend/src/api/client.ts

@@ -2126,6 +2126,9 @@ export interface InventorySpool {
   cost_per_kg: number | null;
   last_scale_weight: number | null;
   last_weighed_at: string | null;
+  // User-defined category + per-spool low-stock threshold override (#729).
+  category: string | null;
+  low_stock_threshold_pct: number | null;
   k_profiles?: SpoolKProfile[];
 }
 

+ 30 - 2
frontend/src/components/SpoolFormModal.tsx

@@ -276,6 +276,8 @@ export function SpoolFormModal({
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
           cost_per_kg: spool.cost_per_kg ?? null,
+          category: spool.category || '',
+          low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
@@ -385,7 +387,7 @@ export function SpoolFormModal({
       api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
-      showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
+      showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
       onClose();
     },
     onError: (error: Error) => {
@@ -401,6 +403,28 @@ export function SpoolFormModal({
   });
   const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
 
+  // Read inventory + settings caches (already populated by InventoryPage) to
+  // drive the category autocomplete and low-stock-threshold placeholder. #729
+  const { data: allSpools } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(true),
+    enabled: isOpen,
+  });
+  const { data: settingsForForm } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+    enabled: isOpen,
+  });
+  const availableCategories = (() => {
+    const set = new Set<string>();
+    for (const s of allSpools ?? []) {
+      const c = s.category?.trim();
+      if (c) set.add(c);
+    }
+    return Array.from(set).sort((a, b) => a.localeCompare(b));
+  })();
+  const globalLowStockThreshold = settingsForForm?.low_stock_threshold ?? 20;
+
   const unassignMutation = useMutation({
     mutationFn: () => {
       if (!spoolAssignment) throw new Error('No assignment');
@@ -503,6 +527,8 @@ export function SpoolFormModal({
       nozzle_temp_max: null,
       note: formData.note || null,
       cost_per_kg: formData.cost_per_kg,
+      category: formData.category.trim() || null,
+      low_stock_threshold_pct: formData.low_stock_threshold_pct,
     };
 
     // Only send weight_used when creating or when explicitly changed by the user.
@@ -653,6 +679,8 @@ export function SpoolFormModal({
                   updateField={updateField}
                   spoolCatalog={spoolCatalog}
                   currencySymbol={currencySymbol}
+                  availableCategories={availableCategories}
+                  globalLowStockThreshold={globalLowStockThreshold}
                 />
               </div>
 
@@ -686,7 +714,7 @@ export function SpoolFormModal({
                 disabled={isPending || !spool?.tag_uid}
               >
                 <Tag className="w-4 h-4" />
-                {t('inventory.deleteTag', 'Delete Tag')}
+                {t('inventory.clearRfid', 'Clear RFID Tag')}
               </Button>
               <Button
                 variant="secondary"

+ 60 - 0
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -173,6 +173,8 @@ export function AdditionalSection({
   updateField,
   spoolCatalog,
   currencySymbol,
+  availableCategories,
+  globalLowStockThreshold,
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
@@ -303,6 +305,64 @@ export function AdditionalSection({
         </div>
       </div>
 
+      {/* Category (#729) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-category">
+          {t('inventory.category')}
+        </label>
+        <input
+          id="spool-category"
+          type="text"
+          list="spool-category-options"
+          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.categoryPlaceholder')}
+          value={formData.category}
+          maxLength={50}
+          onChange={(e) => updateField('category', e.target.value)}
+        />
+        {availableCategories.length > 0 && (
+          <datalist id="spool-category-options">
+            {availableCategories.map((c) => <option key={c} value={c} />)}
+          </datalist>
+        )}
+      </div>
+
+      {/* Per-spool low-stock threshold override (#729) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-low-stock-threshold">
+          {t('inventory.lowStockThresholdOverride')}
+        </label>
+        <div className="flex items-center gap-2">
+          <div className="relative flex-1">
+            <input
+              id="spool-low-stock-threshold"
+              type="number"
+              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"
+              placeholder={String(globalLowStockThreshold)}
+              value={formData.low_stock_threshold_pct ?? ''}
+              min={1}
+              max={99}
+              step={1}
+              onChange={(e) => {
+                const raw = e.target.value;
+                if (raw === '') {
+                  updateField('low_stock_threshold_pct', null);
+                  return;
+                }
+                const n = Number(raw);
+                if (Number.isFinite(n)) {
+                  updateField('low_stock_threshold_pct', Math.min(99, Math.max(1, Math.round(n))));
+                }
+              }}
+            />
+            <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-bambu-gray pointer-events-none">%</span>
+          </div>
+        </div>
+        <p className="text-xs text-bambu-gray mt-1">
+          {t('inventory.lowStockThresholdOverrideHelp', { global: globalLowStockThreshold })}
+        </p>
+      </div>
+
       {/* Note */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>

+ 12 - 0
frontend/src/components/spool-form/types.ts

@@ -23,6 +23,9 @@ export interface SpoolFormData {
   slicer_filament: string;
   note: string;
   cost_per_kg: number | null;
+  // User-defined category + per-spool low-stock threshold override (#729).
+  category: string;
+  low_stock_threshold_pct: number | null;
 }
 
 export const defaultFormData: SpoolFormData = {
@@ -38,6 +41,8 @@ export const defaultFormData: SpoolFormData = {
   slicer_filament: '',
   note: '',
   cost_per_kg: null,
+  category: '',
+  low_stock_threshold_pct: null,
 };
 
 // Printer with calibrations type
@@ -106,6 +111,13 @@ export interface ColorSectionProps extends SectionProps {
 export interface AdditionalSectionProps extends SectionProps {
   spoolCatalog: { id: number; name: string; weight: number }[];
   currencySymbol: string;
+  // Categories already used on other spools — drives the category autocomplete
+  // datalist so users naturally re-use existing names instead of creating
+  // near-duplicates ("Production" vs "production" vs "prod"). #729
+  availableCategories: string[];
+  // Global low-stock threshold (%); shown as placeholder on the per-spool
+  // override input so users see what they're overriding. #729
+  globalLowStockThreshold: number;
 }
 
 // PA Profile section props

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

@@ -3298,6 +3298,15 @@ export default {
     tempOverrides: 'Temperatur-Überschreibungen',
     note: 'Notiz',
     notePlaceholder: 'Zusätzliche Notizen zu dieser Spule...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'Kategorie',
+    categoryPlaceholder: 'z. B. Produktion, Prototyp, Kunde A',
+    categoryNone: 'Ohne Kategorie',
+    lowStockThresholdOverride: 'Niedrigbestandsschwelle (diese Spule)',
+    lowStockThresholdOverrideHelp: 'Leer lassen, um den globalen Schwellenwert ({{global}}%) zu verwenden.',
+    // RFID button rename (was "Tag löschen")
+    clearRfid: 'RFID-Tag löschen',
+    rfidCleared: 'RFID-Tag gelöscht',
     archive: 'Archivieren',
     restore: 'Wiederherstellen',
     noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',

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

@@ -3301,6 +3301,16 @@ export default {
     tempOverrides: 'Temperature Overrides',
     note: 'Note',
     notePlaceholder: 'Any additional notes about this spool...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'Category',
+    categoryPlaceholder: 'e.g. Production, Prototype, Client A',
+    categoryNone: 'Uncategorized',
+    lowStockThresholdOverride: 'Low-stock threshold (this spool)',
+    lowStockThresholdOverrideHelp: 'Leave blank to use the global threshold ({{global}}%).',
+    // RFID button rename (was "Delete Tag" — confusing because it sounds like a
+    // taxonomy delete; this clears the RFID tag/UUID off the spool record)
+    clearRfid: 'Clear RFID Tag',
+    rfidCleared: 'RFID tag cleared',
     archive: 'Archive',
     restore: 'Restore',
     noSpools: 'No spools yet. Add your first spool to get started.',

+ 8 - 0
frontend/src/i18n/locales/fr.ts

@@ -3220,6 +3220,14 @@ export default {
     tempOverrides: 'Exceptions Température',
     note: 'Note',
     notePlaceholder: 'Notes additionnelles sur cette bobine...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'Catégorie',
+    categoryPlaceholder: 'ex. Production, Prototype, Client A',
+    categoryNone: 'Sans catégorie',
+    lowStockThresholdOverride: 'Seuil bas (cette bobine)',
+    lowStockThresholdOverrideHelp: 'Laisser vide pour utiliser le seuil global ({{global}} %).',
+    clearRfid: 'Effacer le tag RFID',
+    rfidCleared: 'Tag RFID effacé',
     archive: 'Archiver',
     restore: 'Restaurer',
     noSpools: 'Aucune bobine. Ajoutez votre première bobine pour commencer.',

+ 8 - 0
frontend/src/i18n/locales/it.ts

@@ -3219,6 +3219,14 @@ export default {
     tempOverrides: 'Override Temperatura',
     note: 'Nota',
     notePlaceholder: 'Eventuali note aggiuntive su questa bobina...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'Categoria',
+    categoryPlaceholder: 'es. Produzione, Prototipo, Cliente A',
+    categoryNone: 'Senza categoria',
+    lowStockThresholdOverride: 'Soglia scorte basse (questa bobina)',
+    lowStockThresholdOverrideHelp: 'Lascia vuoto per usare la soglia globale ({{global}}%).',
+    clearRfid: 'Cancella tag RFID',
+    rfidCleared: 'Tag RFID cancellato',
     archive: 'Archivia',
     restore: 'Ripristina',
     noSpools: 'Ancora nessuna bobina. Aggiungi la tua prima bobina per iniziare.',

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

@@ -3258,6 +3258,14 @@ export default {
     tempOverrides: '温度オーバーライド',
     note: 'メモ',
     notePlaceholder: 'このスプールに関する追加メモ...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'カテゴリ',
+    categoryPlaceholder: '例:本番、試作、クライアントA',
+    categoryNone: 'カテゴリなし',
+    lowStockThresholdOverride: '在庫低下のしきい値(このスプール)',
+    lowStockThresholdOverrideHelp: '空欄の場合、グローバル設定({{global}}%)を使用します。',
+    clearRfid: 'RFIDタグをクリア',
+    rfidCleared: 'RFIDタグをクリアしました',
     archive: 'アーカイブ',
     restore: '復元',
     noSpools: 'スプールがありません。最初のスプールを追加してください。',

+ 8 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3233,6 +3233,14 @@ export default {
     tempOverrides: 'Substituições de Temperatura',
     note: 'Nota',
     notePlaceholder: 'Quaisquer notas adicionais sobre este spool...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: 'Categoria',
+    categoryPlaceholder: 'ex. Produção, Protótipo, Cliente A',
+    categoryNone: 'Sem categoria',
+    lowStockThresholdOverride: 'Limite de estoque baixo (este carretel)',
+    lowStockThresholdOverrideHelp: 'Deixe em branco para usar o limite global ({{global}}%).',
+    clearRfid: 'Limpar tag RFID',
+    rfidCleared: 'Tag RFID limpa',
     archive: 'Arquivar',
     restore: 'Restaurar',
     noSpools: 'Nenhum carretel ainda. Adicione seu primeiro carretel para começar.',

+ 8 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3285,6 +3285,14 @@ export default {
     tempOverrides: '温度覆盖',
     note: '备注',
     notePlaceholder: '关于此耗材的任何备注...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: '类别',
+    categoryPlaceholder: '例如:生产、原型、客户A',
+    categoryNone: '未分类',
+    lowStockThresholdOverride: '低库存阈值(此料盘)',
+    lowStockThresholdOverrideHelp: '留空以使用全局阈值({{global}}%)。',
+    clearRfid: '清除 RFID 标签',
+    rfidCleared: 'RFID 标签已清除',
     archive: '归档',
     restore: '恢复',
     noSpools: '暂无耗材。添加您的第一个耗材开始使用。',

+ 8 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3285,6 +3285,14 @@ export default {
     tempOverrides: '溫度覆蓋',
     note: '備註',
     notePlaceholder: '關於此耗材的任何備註...',
+    // Per-spool category + low-stock threshold override (#729)
+    category: '類別',
+    categoryPlaceholder: '例如:生產、原型、客戶A',
+    categoryNone: '未分類',
+    lowStockThresholdOverride: '低庫存閾值(此料盤)',
+    lowStockThresholdOverrideHelp: '留空以使用全域閾值({{global}}%)。',
+    clearRfid: '清除 RFID 標籤',
+    rfidCleared: 'RFID 標籤已清除',
     archive: '歸檔',
     restore: '恢復',
     noSpools: '尚無耗材。新增您的第一個耗材開始使用。',

+ 41 - 4
frontend/src/pages/InventoryPage.tsx

@@ -482,6 +482,7 @@ function InventoryPage() {
   const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
+  const [categoryFilter, setCategoryFilter] = useState('');
   const [spoolFilter, setSpoolFilter] = useState('');
   const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [search, setSearch] = useState('');
@@ -608,7 +609,8 @@ function InventoryPage() {
       totalWeight += remaining;
       totalConsumed += s.weight_used;
       const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
-      if (pct < lowStockThreshold) lowStock++;
+      const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
+      if (pct < threshold) lowStock++;
       const mat = s.material || 'Unknown';
       if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
       byMaterial[mat].count++;
@@ -667,7 +669,8 @@ function InventoryPage() {
       filtered = filtered.filter((s) => {
         const remaining = Math.max(0, s.label_weight - s.weight_used);
         const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
-        return pct < lowStockThreshold;
+        const threshold = s.low_stock_threshold_pct ?? lowStockThreshold;
+        return pct < threshold;
       });
     }
 
@@ -681,6 +684,15 @@ function InventoryPage() {
       filtered = filtered.filter((s) => s.brand === brandFilter);
     }
 
+    // Category dropdown (#729)
+    if (categoryFilter) {
+      if (categoryFilter === '__none__') {
+        filtered = filtered.filter((s) => !s.category);
+      } else {
+        filtered = filtered.filter((s) => s.category === categoryFilter);
+      }
+    }
+
     // Spool name dropdown
     if (spoolFilter) {
       const catalogId = Number(spoolFilter);
@@ -708,7 +720,7 @@ function InventoryPage() {
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, search, lowStockThreshold]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -716,6 +728,8 @@ function InventoryPage() {
   // Unique values for filter dropdowns
   const uniqueMaterials = [...new Set(spools?.map((s) => s.material) || [])].sort();
   const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
+  const uniqueCategories = [...new Set(spools?.map((s) => s.category?.trim()).filter(Boolean) as string[] || [])].sort();
+  const hasUncategorized = (spools ?? []).some((s) => !s.category);
   const uniqueSpoolCatalogIds = [...new Set(spools?.map((s) => s.core_weight_catalog_id).filter((id): id is number => id != null) || [])].sort((a, b) => {
     const nameA = (catalogMap[a]?.name || '').toLowerCase();
     const nameB = (catalogMap[b]?.name || '').toLowerCase();
@@ -723,7 +737,7 @@ function InventoryPage() {
   });
 
   // Check if any filters are non-default
-  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!categoryFilter || !!spoolFilter || stockFilter !== 'all' || !!search;
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
@@ -844,6 +858,7 @@ function InventoryPage() {
     setUsageFilter('all');
     setMaterialFilter('');
     setBrandFilter('');
+    setCategoryFilter('');
     setSpoolFilter('');
     setStockFilter('all');
     setSearch('');
@@ -1200,6 +1215,28 @@ function InventoryPage() {
           ))}
         </select>
 
+        {/* Category dropdown chip (#729) — only render once at least one
+            spool carries a category, otherwise it's noise. */}
+        {(uniqueCategories.length > 0 || categoryFilter) && (
+          <select
+            value={categoryFilter}
+            onChange={(e) => { setCategoryFilter(e.target.value); resetPage(); }}
+            className={`px-3 py-1.5 rounded-lg border text-xs font-medium transition-colors cursor-pointer focus:outline-none ${
+              categoryFilter
+                ? 'bg-bambu-green/20 text-bambu-green border-bambu-green/30'
+                : 'bg-transparent text-bambu-gray border-bambu-dark-tertiary hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <option value="">{t('inventory.category')}</option>
+            {uniqueCategories.map((c) => (
+              <option key={c} value={c}>{c}</option>
+            ))}
+            {hasUncategorized && (
+              <option value="__none__">{t('inventory.categoryNone')}</option>
+            )}
+          </select>
+        )}
+
         {/* Spool name dropdown chip */}
         {uniqueSpoolCatalogIds.length > 0 && (
           <select

+ 2 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -277,6 +277,8 @@ export function SpoolBuddyDashboard() {
         cost_per_kg: null,
         last_scale_weight: weight !== null ? Math.round(weight) : null,
         last_weighed_at: weight !== null ? new Date().toISOString() : null,
+        category: null,
+        low_stock_threshold_pct: null,
       });
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e);

+ 17 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -381,6 +381,17 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
   selectedSpool: InventorySpool | null;
   t: (key: string, fallback: string) => string;
 }) {
+  // Read inventory + settings from the shared react-query cache to drive the
+  // category autocomplete and low-stock-threshold placeholder. #729
+  const { data: allSpoolsForForm = [] } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(true),
+  });
+  const { data: settingsForForm } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
   const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
   const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
@@ -645,6 +656,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
       tag_type: null,
       last_scale_weight: null,
       last_weighed_at: null,
+      category: formData.category.trim() || null,
+      low_stock_threshold_pct: formData.low_stock_threshold_pct,
     };
 
     setCreating(true);
@@ -847,6 +860,10 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
               updateField={updateField}
               spoolCatalog={spoolCatalog}
               currencySymbol={currencySymbol}
+              availableCategories={Array.from(new Set(
+                allSpoolsForForm.map((s) => s.category?.trim()).filter((c): c is string => !!c),
+              )).sort((a, b) => a.localeCompare(b))}
+              globalLowStockThreshold={settingsForForm?.low_stock_threshold ?? 20}
             />
           </div>
         ) : (

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-SD-TbXyn.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index--ZvtiDKM.js"></script>
+    <script type="module" crossorigin src="/assets/index-SD-TbXyn.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-telVPl_h.css">
   </head>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů