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

Add bulk spool addition and stock spools

Add Quick Add mode to the spool form for simplified entry (material +
color + weight only), and a quantity field (1-100) for creating multiple
identical spools at once. Stock spools (no slicer profile) are computed
rather than stored — any spool without slicer_filament shows an amber
"Stock" badge. New filter chips (All/Stock/Configured) on the inventory
page. Backend bulk endpoint creates N spools in one transaction.

Backend:
- SpoolBulkCreate schema with quantity validation (1-100)
- POST /inventory/spools/bulk endpoint

Frontend:
- Quick Add toggle in SpoolFormModal (create mode only)
- Quantity field in FilamentSection (both modes)
- bulkCreateSpools API method and bulkCreateMutation
- Stock column (hidden by default) and stock filter chips
- Inline error rendering moved into FilamentSection
- CellCtx extended with t() for translatable cell content

Tests:
- 13 backend tests (schema validation + endpoint logic)
- 10 frontend tests (validateForm quickAdd + UI behavior)

i18n: 6 locales (en, de, fr, it, ja, pt-BR)
Docs: CHANGELOG, README, website features, wiki inventory
maziggy 3 месяцев назад
Родитель
Сommit
16fa84bc16

+ 2 - 0
CHANGELOG.md

@@ -20,11 +20,13 @@ All notable changes to Bambuddy will be documented in this file.
 - **Inventory Location Shows Garbled Characters for AMS-HT Slots** ([#463](https://github.com/maziggy/bambuddy/issues/463)) — The inventory location column computed slot letters via `String.fromCharCode(65 + ams_id)`, which produced accented characters (e.g., `Á`) for AMS-HT units (ams_id ≥ 128). Now uses the shared `formatSlotLabel()` utility which correctly handles AMS-HT and external spool slots.
 
 ### New Features
+- **Bulk Spool Addition & Stock Spools** — Two inventory enhancements for managing large filament collections. **Quick Add mode**: a toggle on the spool form that hides slicer preset, brand, and subtype fields, requiring only material selection — ideal for inventorying filament without a specific slicer profile ("stock" spools). **Bulk create**: a quantity field (1–100) on the spool form that creates multiple identical spools in one transaction via a new `POST /inventory/spools/bulk` endpoint. Stock spools are computed (no database migration) — any spool without a `slicer_filament` is displayed with an amber "Stock" badge. A new filter (All / Stock / Configured) on the inventory page lets you filter by stock status. The Stock column is available but hidden by default in column settings. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Filament Cost Tracking** ([#454](https://github.com/maziggy/bambuddy/pull/454), [#452](https://github.com/maziggy/bambuddy/issues/452)) — Track per-spool filament costs and see cost breakdowns for every print. Each spool can have a `cost_per_kg` value; when a print completes, the usage tracker calculates the cost from actual filament consumption and stores it in the usage history. Archive costs are automatically aggregated from spool usage records. A global `default_filament_cost` setting (Settings → Filament) provides a fallback when spools don't have individual costs set. The print modal shows a real-time cost preview based on loaded filaments. Archive cards display the total cost. The inventory table includes a sortable cost/kg column. The recalculate-costs endpoint can retroactively update all archive costs when filament prices change. Contributed by @Keybored02.
 - **Background Print Dispatch** ([#408](https://github.com/maziggy/bambuddy/pull/408), [#112](https://github.com/maziggy/bambuddy/issues/112)) — Printing from archives and the file manager now runs in the background via an async dispatch service. FTP uploads and print-start commands are decoupled from API request latency, so the UI responds immediately. Real-time progress is streamed to all clients via WebSocket, rendered as a persistent toast with per-job upload progress bars, status badges (dispatched/processing/completed/failed/cancelled), and a cancel button. The dispatcher supports concurrent uploads to different printers with per-printer queuing to prevent conflicts. Cancellation is cooperative — uploads abort at the next chunk boundary and clean up partial files on the printer. Batch progress tracking shows overall completion across multi-printer dispatches. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
 - **Include Beta Updates Setting** — New toggle in Settings → Updates to opt in to beta/prerelease update notifications. Default: off (stable only). The update checker now fetches `/releases` instead of `/releases/latest` and filters by `parse_version()` prerelease detection (not GitHub's `prerelease` flag, which may not be set correctly). Users on the Docker `latest` tag will no longer see notifications for beta releases they can't install.
 
 ### Improved
+- **Bulk Spool & Stock Test Coverage** — Added 13 backend unit tests covering `SpoolBulkCreate` schema validation (quantity bounds, field preservation, stock vs configured distinction) and bulk endpoint logic (correct spool count, single quantity, identical fields). Added 10 frontend tests covering `validateForm` with `quickAdd` flag (6 tests for relaxed vs full validation) and `SpoolFormModal` UI behavior (4 tests for quick-add toggle visibility, PA Profile tab hiding, quantity field rendering).
 - **Filament Cost Tracking Test Coverage** — Added 2 backend unit tests for archive cost aggregation (zero-cost guard preserves existing costs, positive-cost updates archive correctly). Added 2 frontend unit tests for spool form cost_per_kg persistence. Fixed missing `archive_id` database migration, SQLAlchemy `is None` → `.is_(None)` in where clauses, duplicate archive cost write, and unconditional zero-cost overwrite.
 - **Spool Assignment Snapshot Test Coverage** — Added 7 backend unit tests covering spool assignment snapshotting at print start, snapshot-preferred spool lookup in both 3MF and AMS delta paths, fallback to live query for pre-upgrade sessions, and the core mid-print unlink scenario from #459.
 - **Background Dispatch Test Coverage** — Added 5 backend unit tests for dispatch cancel races (single-lock TOCTOU fix), batch counter reset re-check, and job lifecycle. Added 2 FTP regression tests for voidresp error handling (upload-loop prevention) and A1 model voidresp skip. Added 1 frontend test for reprint toast suppression.

+ 1 - 0
README.md

@@ -156,6 +156,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
 - Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
+- **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 
 ### 🔧 Integrations

+ 19 - 0
backend/app/api/routes/inventory.py

@@ -22,6 +22,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spool import (
     SpoolAssignmentCreate,
     SpoolAssignmentResponse,
+    SpoolBulkCreate,
     SpoolCreate,
     SpoolKProfileBase,
     SpoolKProfileResponse,
@@ -482,6 +483,24 @@ async def create_spool(
     return result.scalar_one()
 
 
+@router.post("/spools/bulk", response_model=list[SpoolResponse])
+async def bulk_create_spools(
+    data: SpoolBulkCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Create multiple identical spools."""
+    spools = []
+    for _ in range(data.quantity):
+        spool = Spool(**data.spool.model_dump())
+        db.add(spool)
+        spools.append(spool)
+    await db.commit()
+    ids = [s.id for s in spools]
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id.in_(ids)))
+    return list(result.scalars().all())
+
+
 @router.patch("/spools/{spool_id}", response_model=SpoolResponse)
 async def update_spool(
     spool_id: int,

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

@@ -29,6 +29,11 @@ class SpoolCreate(SpoolBase):
     pass
 
 
+class SpoolBulkCreate(BaseModel):
+    spool: SpoolCreate
+    quantity: int = Field(default=1, ge=1, le=100)
+
+
 class SpoolUpdate(BaseModel):
     material: str | None = None
     subtype: str | None = None

+ 184 - 0
backend/tests/unit/test_bulk_spool_create.py

@@ -0,0 +1,184 @@
+"""Unit tests for bulk spool creation.
+
+Tests:
+- SpoolBulkCreate schema validation (quantity bounds)
+- Bulk create endpoint creates the requested number of spools
+- Bulk create with quantity=1 (single spool)
+- Bulk create returns spools with k_profiles loaded
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.spool import SpoolBulkCreate, SpoolCreate
+
+# ── Schema Validation ──────────────────────────────────────────────────────
+
+
+class TestSpoolBulkCreateSchema:
+    """Tests for the SpoolBulkCreate Pydantic model."""
+
+    def test_default_quantity_is_1(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"))
+        assert data.quantity == 1
+
+    def test_quantity_within_range(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=50)
+        assert data.quantity == 50
+
+    def test_quantity_max_100(self):
+        data = SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=100)
+        assert data.quantity == 100
+
+    def test_quantity_zero_rejected(self):
+        with pytest.raises(ValidationError, match="greater than or equal to 1"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=0)
+
+    def test_quantity_negative_rejected(self):
+        with pytest.raises(ValidationError, match="greater than or equal to 1"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=-1)
+
+    def test_quantity_over_100_rejected(self):
+        with pytest.raises(ValidationError, match="less than or equal to 100"):
+            SpoolBulkCreate(spool=SpoolCreate(material="PLA"), quantity=101)
+
+    def test_spool_fields_preserved(self):
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(
+                material="PETG",
+                brand="Polymaker",
+                subtype="Basic",
+                color_name="Red",
+                rgba="FF0000FF",
+                label_weight=750,
+                note="Test batch",
+            ),
+            quantity=5,
+        )
+        assert data.spool.material == "PETG"
+        assert data.spool.brand == "Polymaker"
+        assert data.spool.label_weight == 750
+        assert data.spool.note == "Test batch"
+        assert data.quantity == 5
+
+    def test_spool_without_slicer_filament_is_stock(self):
+        """A spool without slicer_filament is a 'stock' spool (computed, not stored)."""
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", label_weight=1000),
+            quantity=3,
+        )
+        assert data.spool.slicer_filament is None
+
+    def test_spool_with_slicer_filament_is_configured(self):
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", slicer_filament="GFL99"),
+            quantity=2,
+        )
+        assert data.spool.slicer_filament == "GFL99"
+
+    def test_material_required(self):
+        with pytest.raises(ValidationError):
+            SpoolBulkCreate(spool=SpoolCreate(material=""), quantity=1)
+
+
+# ── Endpoint Logic ─────────────────────────────────────────────────────────
+
+
+def _make_mock_spool(spool_id):
+    """Create a mock Spool ORM object."""
+    spool = MagicMock()
+    spool.id = spool_id
+    spool.material = "PLA"
+    spool.label_weight = 1000
+    spool.k_profiles = []
+    return spool
+
+
+class TestBulkCreateEndpoint:
+    """Tests for the bulk_create_spools endpoint logic."""
+
+    @pytest.mark.asyncio
+    async def test_creates_requested_number_of_spools(self):
+        """Verify N spools are created and added to the session."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PLA", brand="Test", label_weight=1000),
+            quantity=3,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        # Mock the re-fetch query
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        result = await bulk_create_spools(data=data, db=db, _=None)
+
+        assert len(result) == 3
+        assert len(added_objects) == 3
+        db.commit.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_single_quantity_creates_one_spool(self):
+        """Bulk create with quantity=1 should create exactly one spool."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(material="PETG"),
+            quantity=1,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(1)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        result = await bulk_create_spools(data=data, db=db, _=None)
+
+        assert len(result) == 1
+        assert len(added_objects) == 1
+
+    @pytest.mark.asyncio
+    async def test_all_spools_have_same_fields(self):
+        """All created spools should have identical field values."""
+        from backend.app.api.routes.inventory import bulk_create_spools
+
+        data = SpoolBulkCreate(
+            spool=SpoolCreate(
+                material="ABS",
+                brand="Bambu Lab",
+                color_name="Black",
+                rgba="000000FF",
+                label_weight=750,
+            ),
+            quantity=3,
+        )
+
+        db = AsyncMock()
+        added_objects = []
+        db.add = lambda obj: added_objects.append(obj)
+
+        mock_result = MagicMock()
+        mock_spools = [_make_mock_spool(i + 1) for i in range(3)]
+        mock_result.scalars.return_value.all.return_value = mock_spools
+        db.execute = AsyncMock(return_value=mock_result)
+
+        await bulk_create_spools(data=data, db=db, _=None)
+
+        # All added Spool objects should have the same material/brand/color
+        for spool_obj in added_objects:
+            assert spool_obj.material == "ABS"
+            assert spool_obj.brand == "Bambu Lab"
+            assert spool_obj.color_name == "Black"
+            assert spool_obj.label_weight == 750

+ 219 - 0
frontend/src/__tests__/components/SpoolFormBulk.test.tsx

@@ -0,0 +1,219 @@
+/**
+ * Tests for bulk spool creation and quick-add mode.
+ *
+ * Verifies:
+ * - Quick-add toggle appears only in create mode
+ * - Quick-add mode hides slicer preset, brand, subtype fields
+ * - Quick-add mode hides PA Profile tab
+ * - Quantity field is rendered in filament section
+ * - Bulk create calls bulkCreateSpools when quantity > 1
+ * - Single quantity calls createSpool as before
+ * - validateForm with quickAdd=true only requires material
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import { validateForm, defaultFormData } from '../../components/spool-form/types';
+import type { InventorySpool } from '../../api/client';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getColorCatalog: vi.fn().mockResolvedValue([]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    bulkCreateSpools: vi.fn().mockResolvedValue([
+      { id: 100, k_profiles: [] },
+      { id: 101, k_profiles: [] },
+      { id: 102, k_profiles: [] },
+    ]),
+    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+// Mock the toast context
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+const existingSpool: InventorySpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  weight_used: 300,
+  slicer_filament: 'GFL99',
+  slicer_filament_name: 'Generic PLA',
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+  cost_per_kg: null,
+};
+
+describe('validateForm with quickAdd', () => {
+  it('requires only material in quick-add mode', () => {
+    const result = validateForm({ ...defaultFormData, material: 'PLA' }, true);
+    expect(result.isValid).toBe(true);
+    expect(result.errors).toEqual({});
+  });
+
+  it('rejects empty material in quick-add mode', () => {
+    const result = validateForm({ ...defaultFormData, material: '' }, true);
+    expect(result.isValid).toBe(false);
+    expect(result.errors.material).toBeDefined();
+  });
+
+  it('does not require slicer_filament in quick-add mode', () => {
+    const result = validateForm(
+      { ...defaultFormData, material: 'PETG', slicer_filament: '' },
+      true,
+    );
+    expect(result.isValid).toBe(true);
+  });
+
+  it('does not require brand in quick-add mode', () => {
+    const result = validateForm(
+      { ...defaultFormData, material: 'ABS', brand: '' },
+      true,
+    );
+    expect(result.isValid).toBe(true);
+  });
+
+  it('does not require subtype in quick-add mode', () => {
+    const result = validateForm(
+      { ...defaultFormData, material: 'TPU', subtype: '' },
+      true,
+    );
+    expect(result.isValid).toBe(true);
+  });
+
+  it('requires all fields in full mode (quickAdd=false)', () => {
+    const result = validateForm(defaultFormData, false);
+    expect(result.isValid).toBe(false);
+    expect(result.errors.material).toBeDefined();
+    expect(result.errors.slicer_filament).toBeDefined();
+    expect(result.errors.brand).toBeDefined();
+    expect(result.errors.subtype).toBeDefined();
+  });
+});
+
+describe('SpoolFormModal quick-add toggle', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('shows quick-add toggle in create mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    expect(screen.getByText('Quick Add (Stock)')).toBeInTheDocument();
+  });
+
+  it('hides quick-add toggle in edit mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument();
+  });
+
+  it('hides PA Profile tab when quick-add is enabled', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // PA Profile tab should be visible initially
+    expect(screen.getByText('PA Profile')).toBeInTheDocument();
+
+    // Toggle quick-add on — the toggle is a button[role="switch"] sibling of the label
+    const toggleButtons = screen.getAllByRole('button');
+    const quickAddToggle = toggleButtons.find(btn =>
+      btn.getAttribute('type') === 'button' &&
+      btn.className.includes('rounded-full') &&
+      btn.closest('div')?.textContent?.includes('Quick Add')
+    );
+    expect(quickAddToggle).toBeTruthy();
+    fireEvent.click(quickAddToggle!);
+
+    // PA Profile tab should be hidden
+    await waitFor(() => {
+      expect(screen.queryByText('PA Profile')).not.toBeInTheDocument();
+    });
+  });
+
+  it('renders quantity field', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />,
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Quantity field should be visible
+    expect(screen.getByText('Quantity')).toBeInTheDocument();
+    expect(screen.getByDisplayValue('1')).toBeInTheDocument();
+  });
+});

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

@@ -3469,6 +3469,11 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  bulkCreateSpools: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>, quantity: number) =>
+    request<InventorySpool[]>('/inventory/spools/bulk', {
+      method: 'POST',
+      body: JSON.stringify({ spool: data, quantity }),
+    }),
   updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
     request<InventorySpool>(`/inventory/spools/${id}`, {
       method: 'PATCH',

+ 75 - 31
frontend/src/components/SpoolFormModal.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
+import { X, Loader2, Save, Beaker, Palette, Zap } from 'lucide-react';
 import { api } from '../api/client';
 import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
@@ -38,6 +38,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
   const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
   const [activeTab, setActiveTab] = useState<TabId>('filament');
   const [weightTouched, setWeightTouched] = useState(false);
+  const [quickAdd, setQuickAdd] = useState(false);
+  const [quantity, setQuantity] = useState(1);
 
   // Cloud presets
   const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
@@ -275,6 +277,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         setFormData(defaultFormData);
         setPresetInputValue('');
         setSelectedProfiles(new Set());
+        setQuickAdd(false);
+        setQuantity(1);
       }
       setErrors({});
       setActiveTab('filament');
@@ -321,6 +325,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     },
   });
 
+  const bulkCreateMutation = useMutation({
+    mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
+      api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
+    onSuccess: async (newSpools) => {
+      if (selectedProfiles.size > 0) {
+        for (const spool of newSpools) {
+          await saveKProfiles(spool.id);
+        }
+      }
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
+      onClose();
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
   const updateMutation = useMutation({
     mutationFn: (data: Record<string, unknown>) =>
       api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
@@ -397,7 +419,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
   if (!isOpen) return null;
 
   const handleSubmit = () => {
-    const validation = validateForm(formData);
+    const validation = validateForm(formData, quickAdd);
     if (!validation.isValid) {
       setErrors(validation.errors);
       // Switch to filament tab if there are errors there
@@ -435,12 +457,14 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
 
     if (isEditing) {
       updateMutation.mutate(data);
+    } else if (quantity > 1) {
+      bulkCreateMutation.mutate({ data, qty: quantity });
     } else {
       createMutation.mutate(data);
     }
   };
 
-  const isPending = createMutation.isPending || updateMutation.isPending;
+  const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending;
 
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -463,6 +487,32 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
           </button>
         </div>
 
+        {/* Quick Add toggle — only in create mode */}
+        {!isEditing && (
+          <div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex items-center gap-2">
+              <Zap className="w-4 h-4 text-amber-400" />
+              <span className="text-sm text-white">{t('inventory.quickAdd')}</span>
+            </div>
+            <button
+              type="button"
+              onClick={() => {
+                setQuickAdd(!quickAdd);
+                if (!quickAdd) setActiveTab('filament');
+              }}
+              className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
+                quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  quickAdd ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+          </div>
+        )}
+
         {/* Tabs */}
         <div className="flex border-b border-bambu-dark-tertiary flex-shrink-0">
           <button
@@ -476,22 +526,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
             <Palette className="w-4 h-4" />
             {t('inventory.filamentInfoTab')}
           </button>
-          <button
-            onClick={() => setActiveTab('pa-profile')}
-            className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
-              activeTab === 'pa-profile'
-                ? 'text-bambu-green border-b-2 border-bambu-green'
-                : 'text-bambu-gray hover:text-white'
-            }`}
-          >
-            <Beaker className="w-4 h-4" />
-            {t('inventory.paProfileTab')}
-            {selectedProfileCount > 0 && (
-              <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
-                {selectedProfileCount}
-              </span>
-            )}
-          </button>
+          {!quickAdd && (
+            <button
+              onClick={() => setActiveTab('pa-profile')}
+              className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
+                activeTab === 'pa-profile'
+                  ? 'text-bambu-green border-b-2 border-bambu-green'
+                  : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              <Beaker className="w-4 h-4" />
+              {t('inventory.paProfileTab')}
+              {selectedProfileCount > 0 && (
+                <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
+                  {selectedProfileCount}
+                </span>
+              )}
+            </button>
+          )}
         </div>
 
         {/* Content */}
@@ -514,19 +566,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
                   filamentOptions={filamentOptions}
                   availableBrands={availableBrands}
                   availableMaterials={availableMaterials}
+                  quickAdd={quickAdd}
+                  quantity={quantity}
+                  onQuantityChange={setQuantity}
+                  errors={errors}
                 />
-                {errors.slicer_filament && (
-                  <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
-                )}
-                {errors.material && (
-                  <p className="mt-1 text-xs text-red-400">{errors.material}</p>
-                )}
-                {errors.brand && (
-                  <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
-                )}
-                {errors.subtype && (
-                  <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
-                )}
               </div>
 
               {/* Color Section */}

+ 195 - 154
frontend/src/components/spool-form/FilamentSection.tsx

@@ -16,6 +16,10 @@ export function FilamentSection({
   filamentOptions,
   availableBrands,
   availableMaterials,
+  quickAdd,
+  quantity,
+  onQuantityChange,
+  errors,
 }: FilamentSectionProps) {
   const { t } = useTranslation();
   const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
@@ -119,67 +123,74 @@ export function FilamentSection({
   return (
     <div className="space-y-4">
       {/* Cloud status indicator */}
-      <div className="flex items-center gap-2 text-xs text-bambu-gray">
-        {loadingCloudPresets ? (
-          <><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
-        ) : cloudAuthenticated ? (
-          <><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
-        ) : (
-          <><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
-        )}
-      </div>
+      {!quickAdd && (
+        <div className="flex items-center gap-2 text-xs text-bambu-gray">
+          {loadingCloudPresets ? (
+            <><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
+          ) : cloudAuthenticated ? (
+            <><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
+          ) : (
+            <><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
+          )}
+        </div>
+      )}
 
-      {/* Slicer Preset (autocomplete) */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">
-          {t('inventory.slicerPreset')} *
-        </label>
-        <div className="relative" ref={presetRef}>
-          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
-          <input
-            type="text"
-            className="w-full pl-9 pr-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.searchPresets')}
-            value={presetInputValue}
-            onChange={(e) => {
-              setPresetInputValue(e.target.value);
-              setPresetDropdownOpen(true);
-            }}
-            onFocus={() => {
-              setPresetDropdownOpen(true);
-              setPresetInputValue('');
-            }}
-          />
-          {presetDropdownOpen && (
-            <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">
-              {filteredPresets.length === 0 ? (
-                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
-              ) : (
-                filteredPresets.map(option => (
-                  <button
-                    key={option.code}
-                    type="button"
-                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
-                      selectedPresetOption?.code === option.code
-                        ? 'bg-bambu-green/10 text-bambu-green'
-                        : 'text-white'
-                    }`}
-                    onClick={() => handlePresetSelect(option)}
-                  >
-                    <span className="truncate">{option.displayName}</span>
-                    <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
-                  </button>
-                ))
-              )}
+      {/* Slicer Preset (autocomplete) — hidden in quick-add mode */}
+      {!quickAdd && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">
+            {t('inventory.slicerPreset')} *
+          </label>
+          <div className="relative" ref={presetRef}>
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+            <input
+              type="text"
+              className="w-full pl-9 pr-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.searchPresets')}
+              value={presetInputValue}
+              onChange={(e) => {
+                setPresetInputValue(e.target.value);
+                setPresetDropdownOpen(true);
+              }}
+              onFocus={() => {
+                setPresetDropdownOpen(true);
+                setPresetInputValue('');
+              }}
+            />
+            {presetDropdownOpen && (
+              <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">
+                {filteredPresets.length === 0 ? (
+                  <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
+                ) : (
+                  filteredPresets.map(option => (
+                    <button
+                      key={option.code}
+                      type="button"
+                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
+                        selectedPresetOption?.code === option.code
+                          ? 'bg-bambu-green/10 text-bambu-green'
+                          : 'text-white'
+                      }`}
+                      onClick={() => handlePresetSelect(option)}
+                    >
+                      <span className="truncate">{option.displayName}</span>
+                      <span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
+                    </button>
+                  ))
+                )}
+              </div>
+            )}
+          </div>
+          {selectedPresetOption && (
+            <div className="mt-1 text-xs text-bambu-gray">
+              {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
             </div>
           )}
+          {errors?.slicer_filament && (
+            <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
+          )}
         </div>
-        {selectedPresetOption && (
-          <div className="mt-1 text-xs text-bambu-gray">
-            {t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
-          </div>
-        )}
-      </div>
+      )}
 
       {/* Material */}
       <div>
@@ -239,125 +250,139 @@ export function FilamentSection({
             </div>
           )}
         </div>
+        {errors?.material && (
+          <p className="mt-1 text-xs text-red-400">{errors.material}</p>
+        )}
       </div>
-      {/* Brand (dropdown with search) */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
-        <div className="relative" ref={brandRef}>
-          <input
-            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.searchBrand')}
-            value={brandDropdownOpen ? brandSearch : formData.brand}
-            onChange={(e) => {
-              setBrandSearch(e.target.value);
-              setBrandDropdownOpen(true);
-            }}
-            onFocus={() => {
-              setBrandDropdownOpen(true);
-              setBrandSearch('');
-            }}
-          />
-          <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
-          {brandDropdownOpen && (
-            <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">
-              {filteredBrands.length === 0 ? (
-                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
-              ) : (
-                filteredBrands.map(brand => (
+
+      {/* Brand (dropdown with search) — hidden in quick-add mode */}
+      {!quickAdd && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
+          <div className="relative" ref={brandRef}>
+            <input
+              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.searchBrand')}
+              value={brandDropdownOpen ? brandSearch : formData.brand}
+              onChange={(e) => {
+                setBrandSearch(e.target.value);
+                setBrandDropdownOpen(true);
+              }}
+              onFocus={() => {
+                setBrandDropdownOpen(true);
+                setBrandSearch('');
+              }}
+            />
+            <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+            {brandDropdownOpen && (
+              <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">
+                {filteredBrands.length === 0 ? (
+                  <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+                ) : (
+                  filteredBrands.map(brand => (
+                    <button
+                      key={brand}
+                      type="button"
+                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                        formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+                      }`}
+                      onClick={() => {
+                        updateField('brand', brand);
+                        setBrandDropdownOpen(false);
+                        setBrandSearch('');
+                      }}
+                    >
+                      {brand}
+                    </button>
+                  ))
+                )}
+                {/* Allow custom brand */}
+                {brandSearch && !filteredBrands.includes(brandSearch) && (
                   <button
-                    key={brand}
                     type="button"
-                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
-                      formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
-                    }`}
+                    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('brand', brand);
+                      updateField('brand', brandSearch);
                       setBrandDropdownOpen(false);
                       setBrandSearch('');
                     }}
                   >
-                    {brand}
+                    {t('inventory.useCustomBrand', { brand: brandSearch })}
                   </button>
-                ))
-              )}
-              {/* Allow custom brand */}
-              {brandSearch && !filteredBrands.includes(brandSearch) && (
-                <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('brand', brandSearch);
-                    setBrandDropdownOpen(false);
-                    setBrandSearch('');
-                  }}
-                >
-                  {t('inventory.useCustomBrand', { brand: brandSearch })}
-                </button>
-              )}
-            </div>
+                )}
+              </div>
+            )}
+          </div>
+          {errors?.brand && (
+            <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
           )}
         </div>
-      </div>
+      )}
 
-      {/* Variant / Subtype */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
-        <div className="relative" ref={subtypeRef}>
-          <input
-            type="text"
-            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"
-          />
-          <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 => (
+      {/* Variant / Subtype — hidden in quick-add mode */}
+      {!quickAdd && (
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
+          <div className="relative" ref={subtypeRef}>
+            <input
+              type="text"
+              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"
+            />
+            <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
-                    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'
-                    }`}
+                    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', variant);
+                      updateField('subtype', subtypeSearch);
                       setSubtypeDropdownOpen(false);
                       setSubtypeSearch('');
                     }}
                   >
-                    {variant}
+                    {t('inventory.useCustomBrand', { brand: subtypeSearch })}
                   </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>
+          {errors?.subtype && (
+            <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
           )}
         </div>
-      </div>
+      )}
 
       {/* Label Weight */}
       <div>
@@ -387,6 +412,22 @@ export function FilamentSection({
         </div>
       </div>
 
+      {/* Quantity */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.quantity')}</label>
+        <input
+          type="number"
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+          value={quantity}
+          min={1}
+          max={100}
+          onChange={(e) => {
+            const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1));
+            onQuantityChange(val);
+          }}
+        />
+      </div>
+
     </div>
   );
 }

+ 15 - 1
frontend/src/components/spool-form/types.ts

@@ -89,6 +89,10 @@ export interface FilamentSectionProps extends SectionProps {
   filamentOptions: FilamentOption[];
   availableBrands: string[];
   availableMaterials: string[];
+  quickAdd: boolean;
+  quantity: number;
+  onQuantityChange: (value: number) => void;
+  errors?: Partial<Record<keyof SpoolFormData, string>>;
 }
 
 // Color section props
@@ -119,9 +123,19 @@ export interface ValidationResult {
   errors: Partial<Record<keyof SpoolFormData, string>>;
 }
 
-export function validateForm(formData: SpoolFormData): ValidationResult {
+export function validateForm(formData: SpoolFormData, quickAdd = false): ValidationResult {
   const errors: Partial<Record<keyof SpoolFormData, string>> = {};
 
+  if (quickAdd) {
+    if (!formData.material) {
+      errors.material = 'Material is required';
+    }
+    return {
+      isValid: Object.keys(errors).length === 0,
+      errors,
+    };
+  }
+
   if (!formData.slicer_filament) {
     errors.slicer_filament = 'Slicer preset is required';
   }

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

@@ -2592,6 +2592,11 @@ export default {
     allMaterials: 'Alle Materialien',
     filterByBrand: 'Nach Marke filtern...',
     showArchived: 'Archivierte anzeigen',
+    quickAdd: 'Schnellerfassung (Lager)',
+    quantity: 'Menge',
+    stock: 'Lager',
+    configured: 'Konfiguriert',
+    spoolsCreated: '{{count}} Spulen erstellt',
     spoolCreated: 'Spule erstellt',
     spoolUpdated: 'Spule aktualisiert',
     spoolDeleted: 'Spule gelöscht',

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

@@ -2592,6 +2592,11 @@ export default {
     allMaterials: 'All Materials',
     filterByBrand: 'Filter by brand...',
     showArchived: 'Show archived',
+    quickAdd: 'Quick Add (Stock)',
+    quantity: 'Quantity',
+    stock: 'Stock',
+    configured: 'Configured',
+    spoolsCreated: '{{count}} spools created',
     spoolCreated: 'Spool created',
     spoolUpdated: 'Spool updated',
     spoolDeleted: 'Spool deleted',

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

@@ -2580,6 +2580,11 @@ export default {
     allMaterials: 'Tous Matériaux',
     filterByBrand: 'Filtrer par marque...',
     showArchived: 'Afficher archivées',
+    quickAdd: 'Ajout rapide (Stock)',
+    quantity: 'Quantité',
+    stock: 'Stock',
+    configured: 'Configuré',
+    spoolsCreated: '{{count}} bobines créées',
     spoolCreated: 'Bobine créée',
     spoolUpdated: 'Bobine mise à jour',
     spoolDeleted: 'Bobine supprimée',

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

@@ -2397,6 +2397,11 @@ export default {
     allMaterials: 'Tutti i Materiali',
     filterByBrand: 'Filtra per marchio...',
     showArchived: 'Mostra archiviate',
+    quickAdd: 'Aggiunta rapida (Scorta)',
+    quantity: 'Quantità',
+    stock: 'Scorta',
+    configured: 'Configurata',
+    spoolsCreated: '{{count}} bobine create',
     spoolCreated: 'Bobina creata',
     spoolUpdated: 'Bobina aggiornata',
     spoolDeleted: 'Bobina eliminata',

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

@@ -2519,6 +2519,11 @@ export default {
     allMaterials: 'すべての素材',
     filterByBrand: 'ブランドで絞り込み...',
     showArchived: 'アーカイブ済みを表示',
+    quickAdd: 'クイック追加(在庫)',
+    quantity: '数量',
+    stock: '在庫',
+    configured: '設定済み',
+    spoolsCreated: '{{count}}本のスプールを作成しました',
     spoolCreated: 'スプールを作成しました',
     spoolUpdated: 'スプールを更新しました',
     spoolDeleted: 'スプールを削除しました',

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

@@ -2592,6 +2592,11 @@ export default {
     allMaterials: 'Todos os Materiais',
     filterByBrand: 'Filtrar por marca...',
     showArchived: 'Mostrar arquivados',
+    quickAdd: 'Adição rápida (Estoque)',
+    quantity: 'Quantidade',
+    stock: 'Estoque',
+    configured: 'Configurado',
+    spoolsCreated: '{{count}} carretéis criados',
     spoolCreated: 'Carretel criado',
     spoolUpdated: 'Carretel atualizado',
     spoolDeleted: 'Carretel excluído',

+ 60 - 3
frontend/src/pages/InventoryPage.tsx

@@ -52,6 +52,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'tag_id', label: 'Tag ID', visible: false },
   { id: 'data_origin', label: 'Data Origin', visible: false },
   { id: 'tag_type', label: 'Linked Tag Type', visible: false },
+  { id: 'stock', label: 'Stock', visible: false },
   { id: 'remaining', label: 'Remaining', visible: true },
   { id: 'cost_per_kg', label: 'Cost/kg', visible: false },
 ];
@@ -116,6 +117,7 @@ type CellCtx = {
   assignmentMap: Record<number, SpoolAssignment>;
   currencySymbol: string;
   dateFormat: DateFormat;
+  t: TFn;
 };
 
 // Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -143,6 +145,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   tag_id: () => 'Tag ID',
   data_origin: () => 'Data Origin',
   tag_type: () => 'Linked Tag Type',
+  stock: (t) => t('inventory.stock'),
   remaining: (t) => t('inventory.remaining'),
   cost_per_kg: (t) => t('inventory.costPerKg'),
 };
@@ -248,6 +251,16 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   tag_type: ({ spool }) => (
     <span className="text-sm text-bambu-gray">{spool.tag_type || '-'}</span>
   ),
+  stock: ({ spool, t }) => {
+    if (!spool.slicer_filament) {
+      return (
+        <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/20 text-amber-400">
+          {t('inventory.stock')}
+        </span>
+      );
+    }
+    return <span className="text-sm text-bambu-gray">-</span>;
+  },
   remaining: ({ remaining, pct }) => (
     <div className="flex items-center gap-2">
       <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
@@ -292,6 +305,7 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
   note: (s) => (s.note || '').toLowerCase(),
   data_origin: (s) => (s.data_origin || '').toLowerCase(),
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
+  stock: (s) => s.slicer_filament ? 1 : 0,
   cost_per_kg: (s) => s.cost_per_kg ?? 0,
 };
 
@@ -327,6 +341,7 @@ export default function InventoryPage() {
   const [usageFilter, setUsageFilter] = useState<UsageFilter>('all');
   const [materialFilter, setMaterialFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
+  const [stockFilter, setStockFilter] = useState<'all' | 'stock' | 'configured'>('all');
   const [search, setSearch] = useState('');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
   const [sortState, setSortState] = useState<SortState>(loadSortState);
@@ -462,6 +477,13 @@ export default function InventoryPage() {
       filtered = filtered.filter((s) => s.brand === brandFilter);
     }
 
+    // Stock filter
+    if (stockFilter === 'stock') {
+      filtered = filtered.filter((s) => !s.slicer_filament);
+    } else if (stockFilter === 'configured') {
+      filtered = filtered.filter((s) => !!s.slicer_filament);
+    }
+
     // Global search
     if (search) {
       const q = search.toLowerCase();
@@ -476,7 +498,7 @@ export default function InventoryPage() {
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -486,7 +508,7 @@ export default function InventoryPage() {
   const uniqueBrands = [...new Set(spools?.map((s) => s.brand).filter(Boolean) || [])].sort() as string[];
 
   // Check if any filters are non-default
-  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || !!search;
+  const hasActiveFilters = archiveFilter !== 'active' || usageFilter !== 'all' || !!materialFilter || !!brandFilter || stockFilter !== 'all' || !!search;
 
   const handleColumnConfigSave = (config: ColumnConfig[]) => {
     setColumnConfig(config);
@@ -548,6 +570,7 @@ export default function InventoryPage() {
     setUsageFilter('all');
     setMaterialFilter('');
     setBrandFilter('');
+    setStockFilter('all');
     setSearch('');
     resetPage();
   };
@@ -758,6 +781,40 @@ export default function InventoryPage() {
           </button>
         </div>
 
+        {/* Stock filter chips */}
+        <div className="flex items-center rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+          <button
+            onClick={() => { setStockFilter('all'); resetPage(); }}
+            className={`px-3 py-1.5 text-xs font-medium transition-colors ${
+              stockFilter === 'all'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {t('inventory.all')}
+          </button>
+          <button
+            onClick={() => { setStockFilter('stock'); resetPage(); }}
+            className={`px-3 py-1.5 text-xs font-medium transition-colors ${
+              stockFilter === 'stock'
+                ? 'bg-amber-500/20 text-amber-400'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {t('inventory.stock')}
+          </button>
+          <button
+            onClick={() => { setStockFilter('configured'); resetPage(); }}
+            className={`px-3 py-1.5 text-xs font-medium transition-colors ${
+              stockFilter === 'configured'
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            {t('inventory.configured')}
+          </button>
+        </div>
+
         <div className="w-px h-5 bg-bambu-dark-tertiary" />
 
         {/* Material dropdown chip */}
@@ -952,7 +1009,7 @@ export default function InventoryPage() {
                       >
                         {visibleColumns.map((colId) => (
                           <td key={colId} className="py-3 px-4">
-                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat })}
+                            {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
                           </td>
                         ))}
                         <td className="py-3 px-4">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CKMY9yxc.js


+ 1 - 1
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов