Browse Source

[Feature] Copy filament (#1246)

Miguel Ángel López Vicente 2 weeks ago
parent
commit
6a130c09d7

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

@@ -144,6 +144,7 @@ describe('SpoolFormModal quick-add toggle', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
       />,
     );
@@ -161,6 +162,7 @@ describe('SpoolFormModal quick-add toggle', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={existingSpool}
+        mode="edit"
         currencySymbol="$"
       />,
     );
@@ -177,6 +179,7 @@ describe('SpoolFormModal quick-add toggle', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
       />,
     );
@@ -209,6 +212,7 @@ describe('SpoolFormModal quick-add toggle', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
       />,
     );
@@ -226,6 +230,7 @@ describe('SpoolFormModal quick-add toggle', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
       />,
     );
@@ -256,6 +261,7 @@ describe('SpoolFormModal quick-add toggle', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={existingSpool}
+        mode="edit"
         currencySymbol="$"
       />,
     );
@@ -273,6 +279,7 @@ describe('SpoolFormModal quick-add toggle', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
       />,
     );

+ 92 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -112,6 +112,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={existingSpool}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -144,6 +145,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={existingSpool}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -220,6 +222,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithCatalogId}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -314,6 +317,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithCost}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -347,6 +351,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithoutCost}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -383,6 +388,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithBadRgba}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -415,6 +421,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={existingSpool} // rgba = 'FF0000FF' (valid)
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -445,6 +452,7 @@ describe('SpoolFormModal weightTouched', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
         spoolmanMode={true}
       />
@@ -498,6 +506,7 @@ describe('SpoolFormModal weightTouched', () => {
       <SpoolFormModal
         isOpen={true}
         onClose={vi.fn()}
+        mode="create"
         currencySymbol="$"
         spoolmanMode={true}
       />
@@ -556,6 +565,7 @@ describe('SpoolFormModal weightTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithCatalogId}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -622,6 +632,7 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolmanSpool}
+        mode="edit"
         currencySymbol="$"
         spoolmanMode={true}
       />
@@ -641,6 +652,7 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolmanSpool}
+        mode="edit"
         currencySymbol="$"
         spoolmanMode={true}
       />
@@ -838,6 +850,7 @@ describe('SpoolFormModal storageLocationTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithStorageLocation}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -868,6 +881,7 @@ describe('SpoolFormModal storageLocationTouched', () => {
         isOpen={true}
         onClose={vi.fn()}
         spool={spoolWithStorageLocation}
+        mode="edit"
         currencySymbol="$"
       />
     );
@@ -921,3 +935,81 @@ describe('SpoolFormModal storageLocationTouched', () => {
     expect(payload).toHaveProperty('storage_location', null);
   });
 });
+
+describe('SpoolFormModal copy mode', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('shows "Copy Spool" as the modal title when spool and mode="copy" are passed', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        mode="copy"
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+  });
+
+  it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        mode="copy"
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+
+    // The save button label is "Copy Spool" in copy mode
+    const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
+      .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
+    expect(saveBtn).toBeTruthy();
+    fireEvent.click(saveBtn!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+    expect(api.updateSpool).not.toHaveBeenCalled();
+  });
+
+  it('resets weight_used to 0 in the create payload when copying a spool with non-zero usage', async () => {
+    // existingSpool has weight_used: 300 — must become 0 on copy
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        mode="copy"
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+
+    const saveBtn = screen.getAllByRole('button', { name: /copy spool/i })
+      .find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg'));
+    expect(saveBtn).toBeTruthy();
+    fireEvent.click(saveBtn!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    expect((payload as Record<string, unknown>).weight_used).toBe(0);
+  });
+});

+ 206 - 0
frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx

@@ -0,0 +1,206 @@
+/**
+ * Tests for the copy-spool button in InventoryPage.
+ *
+ * Three callsites — table-row, card, and grouped-view inner row — each wire
+ * onCopy from the page-level setFormModal({ spool, mode: 'copy' }) state.
+ * These tests cover the two visually distinct components (SpoolTableRow and
+ * SpoolCard). The grouped-view path is SpoolTableGroup which renders inner
+ * SpoolTableRow rows with onCopy={onCopy ? () => onCopy(spool) : undefined} —
+ * a one-line forward of the same callback the table-row test already
+ * exercises end-to-end.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseSpool = {
+  subtype: null,
+  brand: 'eSun',
+  color_name: 'Blue',
+  rgba: '0000FFFF',
+  extra_colors: null,
+  effect_type: null,
+  label_weight: 1000,
+  core_weight: 250,
+  core_weight_catalog_id: null,
+  slicer_filament: null,
+  slicer_filament_name: null,
+  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: [] as never[],
+  cost_per_kg: null,
+  last_scale_weight: null,
+  last_weighed_at: null,
+  storage_location: null,
+  category: null,
+  low_stock_threshold_pct: null,
+  spoolman_id: null,
+  spoolman_filament_id: null,
+};
+
+const MOCK_SPOOL = {
+  ...baseSpool,
+  id: 5,
+  material: 'PETG',
+  weight_used: 400,
+};
+
+const MOCK_SETTINGS = {
+  auto_archive: false,
+  save_thumbnails: false,
+  capture_finish_photo: false,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: false,
+  check_printer_firmware: false,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'immediate',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+function setupHandlers(spools: unknown[] = [MOCK_SPOOL]) {
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)),
+    http.get('/api/v1/settings/spoolman', () =>
+      HttpResponse.json({
+        spoolman_enabled: 'false',
+        spoolman_url: '',
+        spoolman_sync_mode: 'auto',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'true',
+      })
+    ),
+    http.get('/api/v1/inventory/spools', () => HttpResponse.json(spools)),
+    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    // SpoolFormModal kicks off these fetches the moment it opens. Without
+    // handlers MSW would passthrough to the real network and ECONNREFUSED;
+    // those promises then resolve after the test environment is torn down,
+    // surfacing as an unhandled rejection in the modal's setState finally.
+    http.get('/api/v1/cloud/status', () =>
+      HttpResponse.json({ is_authenticated: false })
+    ),
+    http.get('/api/v1/cloud/local-presets', () =>
+      HttpResponse.json({ filament: [], printer: [], process: [] })
+    ),
+    http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+  );
+}
+
+describe('InventoryPage — copy button', () => {
+  beforeEach(() => {
+    setupHandlers();
+  });
+
+  it('opens SpoolFormModal in "Copy Spool" mode when the copy button in the table row is clicked', async () => {
+    render(<InventoryPageRouter />);
+
+    // Wait for the spool list to render
+    await waitFor(() => {
+      expect(screen.getAllByText('PETG').length).toBeGreaterThan(0);
+    });
+
+    // Find the "Copy Spool" button (title attribute) in the table row
+    const copyButtons = await screen.findAllByTitle('Copy Spool');
+    expect(copyButtons.length).toBeGreaterThan(0);
+
+    // Click the first copy button (table view is default)
+    fireEvent.click(copyButtons[0]);
+
+    // The modal should open with the "Copy Spool" heading
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+  });
+
+  it('opens SpoolFormModal in "Copy Spool" mode when the copy button in the cards view is clicked', async () => {
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      expect(screen.getAllByText('PETG').length).toBeGreaterThan(0);
+    });
+
+    // Switch to cards view
+    fireEvent.click(screen.getByRole('button', { name: /^Cards$/ }));
+
+    // The card-view copy button has the same title; wait for the card render
+    // to settle, then click it.
+    const copyButtons = await screen.findAllByTitle('Copy Spool');
+    expect(copyButtons.length).toBeGreaterThan(0);
+    fireEvent.click(copyButtons[0]);
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+  });
+
+});

+ 15 - 0
frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx

@@ -122,6 +122,21 @@ function setupCommonHandlers(spoolList: object[]) {
     http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)),
     http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
     http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
+    // Deep-link flows open SpoolFormModal, which fires off these fetches the
+    // moment it mounts. Without handlers MSW would passthrough to the real
+    // network (ECONNREFUSED); the rejected fetch then resolves after the
+    // test environment is torn down, surfacing as an unhandled rejection
+    // ("window is not defined") in the modal's setState finally.
+    http.get('/api/v1/cloud/status', () =>
+      HttpResponse.json({ is_authenticated: false })
+    ),
+    http.get('/api/v1/cloud/local-presets', () =>
+      HttpResponse.json({ filament: [], printer: [], process: [] })
+    ),
+    http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
   );
 }
 

+ 16 - 7
frontend/src/components/SpoolFormModal.tsx

@@ -21,10 +21,13 @@ type TabId = 'filament' | 'pa-profile';
 
 const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
 
+export type SpoolFormMode = 'create' | 'edit' | 'copy';
+
 interface SpoolFormModalProps {
   isOpen: boolean;
   onClose: () => void;
   spool?: InventorySpool | null;
+  mode: SpoolFormMode;
   printersWithCalibrations?: PrinterWithCalibrations[];
   currencySymbol: string;
   onSpoolsCreated?: (spools: InventorySpool[]) => void;
@@ -38,6 +41,7 @@ export function SpoolFormModal({
   isOpen,
   onClose,
   spool,
+  mode,
   printersWithCalibrations = [],
   currencySymbol,
   onSpoolsCreated,
@@ -48,7 +52,8 @@ export function SpoolFormModal({
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  const isEditing = !!spool;
+  const isEditing = mode === 'edit';
+  const isCopying = mode === 'copy';
 
   // Form state
   const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
@@ -305,7 +310,7 @@ export function SpoolFormModal({
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
           core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
-          weight_used: spool.weight_used || 0,
+          weight_used: isCopying ? 0 : spool.weight_used || 0,
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
           cost_per_kg: spool.cost_per_kg ?? null,
@@ -340,7 +345,7 @@ export function SpoolFormModal({
       setWeightTouched(false);
       setStorageLocationTouched(false);
     }
-  }, [isOpen, spool]);
+  }, [isOpen, spool, mode, isCopying]);
 
   // Expand all printers in PA profile section when calibrations are available
   useEffect(() => {
@@ -704,7 +709,7 @@ export function SpoolFormModal({
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
           <h2 className="text-lg font-semibold text-white">
-            {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')}
+            {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
           </h2>
           <button
             onClick={onClose}
@@ -714,8 +719,12 @@ export function SpoolFormModal({
           </button>
         </div>
 
-        {/* Quick Add toggle — only in create mode */}
-        {!isEditing && (
+        {/* Quick Add toggle — only in create mode (not edit, not copy).
+            In copy mode the modal title is the singular "Copy Spool", so the
+            quantity-driven bulkCreateMutation path would silently produce N
+            copies under a misleading title — keep this toggle out of that
+            mode entirely. */}
+        {mode === 'create' && (
           <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" />
@@ -904,7 +913,7 @@ export function SpoolFormModal({
             ) : (
               <>
                 <Save className="w-4 h-4" />
-                {isEditing ? t('common.save') : t('inventory.addSpool')}
+                {isEditing ? t('common.save') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
               </>
             )}
           </Button>

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

@@ -3458,6 +3458,7 @@ export default {
       },
     },
     addSpool: 'Spule hinzufügen',
+    copySpool: 'Spule kopieren',
     editSpool: 'Spule bearbeiten',
     material: 'Material',
     selectMaterial: 'Material auswählen...',

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

@@ -3462,6 +3462,7 @@ export default {
     },
     addSpool: 'Add Spool',
     editSpool: 'Edit Spool',
+    copySpool: 'Copy Spool',
     material: 'Material',
     selectMaterial: 'Select material...',
     subtype: 'Subtype',

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

@@ -3448,6 +3448,7 @@ export default {
     },
     addSpool: 'Ajouter Bobine',
     editSpool: 'Modifier Bobine',
+    copySpool: 'Copier Bobine',
     material: 'Matériau',
     selectMaterial: 'Choisir matériau...',
     subtype: 'Sous-type',

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

@@ -3447,6 +3447,7 @@ export default {
     },
     addSpool: 'Aggiungi Bobina',
     editSpool: 'Modifica Bobina',
+    copySpool: 'Copia Bobina',
     material: 'Materiale',
     selectMaterial: 'Seleziona materiale...',
     subtype: 'Sottotipo',

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

@@ -3459,6 +3459,7 @@ export default {
     },
     addSpool: 'スプールを追加',
     editSpool: 'スプールを編集',
+    copySpool: 'スプールをコピー',
     material: '素材',
     selectMaterial: '素材を選択...',
     subtype: 'サブタイプ',

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

@@ -3447,6 +3447,7 @@ export default {
     },
     addSpool: 'Adicionar Carretel',
     editSpool: 'Editar Carretel',
+    copySpool: 'Copiar Carretel',
     material: 'Material',
     selectMaterial: 'Selecionar material...',
     subtype: 'Subtipo',

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

@@ -3447,6 +3447,7 @@ export default {
     },
     addSpool: '添加耗材',
     editSpool: '编辑耗材',
+    copySpool: '复制耗材',
     material: '材料',
     selectMaterial: '选择材料...',
     subtype: '子类型',

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

@@ -3447,6 +3447,7 @@ export default {
     },
     addSpool: '新增耗材',
     editSpool: '編輯耗材',
+    copySpool: '複製耗材',
     material: '材料',
     selectMaterial: '選擇材料...',
     subtype: '子類型',

+ 39 - 15
frontend/src/pages/InventoryPage.tsx

@@ -6,7 +6,7 @@ import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
-  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock,
+  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy,
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -14,7 +14,7 @@ import type { InventorySpool, SpoolCatalogEntry } from '../api/client';
 import { Button } from '../components/Button';
 import { FilamentSwatch } from '../components/FilamentSwatch';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
-import { SpoolFormModal } from '../components/SpoolFormModal';
+import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
@@ -463,7 +463,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const { hasPermission, loading: authLoading } = useAuth();
   const canViewForecast = !authLoading && hasPermission('inventory:forecast_read');
   const [searchParams, setSearchParams] = useSearchParams();
-  const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
+  const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
   const deepLinkHandled = useRef(false);
   const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
   // Label printing (#809). null = closed; otherwise the IDs to print labels for.
@@ -557,14 +557,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     // Case 1: spool is already in the fetched list
     if (spoolmanModeReady && deepLinkSpoolId && deepLinkInList) {
       clearDeepLinkParam();
-      setFormModal({ spool: deepLinkInList });
+      setFormModal({ spool: deepLinkInList, mode: 'edit' });
       return;
     }
 
     // Case 2: spool was fetched individually
     if (deepLinkSpool) {
       clearDeepLinkParam();
-      setFormModal({ spool: deepLinkSpool });
+      setFormModal({ spool: deepLinkSpool, mode: 'edit' });
       return;
     }
 
@@ -1045,7 +1045,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
             <Printer className="w-4 h-4" />
             {t('inventory.labels.printLabels', 'Print labels…')}
           </Button>
-          <Button onClick={() => setFormModal({ spool: null })}>
+          <Button onClick={() => setFormModal({ spool: null, mode: 'create' })}>
             <Plus className="w-4 h-4" />
             {t('inventory.addSpool')}
           </Button>
@@ -1527,8 +1527,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                                 spool={spool}
                                 remaining={remaining}
                                 pct={pct}
-                                onClick={() => setFormModal({ spool })}
+                                onClick={() => setFormModal({ spool, mode: 'edit' })}
                                 onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
+                                onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                                 t={t}
                               />
                             );
@@ -1547,8 +1548,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                     spool={spool}
                     remaining={remaining}
                     pct={pct}
-                    onClick={() => setFormModal({ spool })}
+                    onClick={() => setFormModal({ spool, mode: 'edit' })}
                     onPrintLabel={() => setLabelPickerSpoolIds([spool.id])}
+                    onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                     t={t}
                   />
                 );
@@ -1568,7 +1570,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         ) : (
           <EmptyFilterState
             hasFilters={hasActiveFilters}
-            onAddSpool={() => setFormModal({ spool: null })}
+            onAddSpool={() => setFormModal({ spool: null, mode: 'create' })}
             t={t}
           />
         )
@@ -1623,7 +1625,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                           pct={pct}
                           isExpanded={isExpanded}
                           onToggle={() => toggleGroupExpand(key)}
-                          onEdit={(s) => setFormModal({ spool: s })}
+                          onEdit={(s) => setFormModal({ spool: s, mode: 'edit' })}
+                          onCopy={(s) => setFormModal({ spool: s, mode: 'copy' })}
                           onArchive={(id) => setConfirmAction({ type: 'archive', spoolId: id })}
                           onDelete={(id) => setConfirmAction({ type: 'delete', spoolId: id })}
                           onPrintLabel={(id) => setLabelPickerSpoolIds([id])}
@@ -1646,7 +1649,8 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                         spool={spool}
                         remaining={remaining}
                         pct={pct}
-                        onEdit={() => setFormModal({ spool })}
+                        onEdit={() => setFormModal({ spool, mode: 'edit' })}
+                        onCopy={() => setFormModal({ spool: spool, mode: 'copy' })}
                         onRestore={() => restoreMutation.mutate(spool.id)}
                         onArchive={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                         onDelete={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
@@ -1732,7 +1736,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         ) : (
           <EmptyFilterState
             hasFilters={hasActiveFilters}
-            onAddSpool={() => setFormModal({ spool: null })}
+            onAddSpool={() => setFormModal({ spool: null, mode: 'create' })}
             t={t}
           />
         )
@@ -1744,6 +1748,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           isOpen={true}
           onClose={() => setFormModal(null)}
           spool={formModal.spool}
+          mode={formModal.mode}
           currencySymbol={currencySymbol}
           spoolmanMode={spoolmanMode}
           spoolsQueryKey={spoolsQueryKey}
@@ -1872,13 +1877,14 @@ function PaginationBar({
 
 /* Spool card for cards view */
 function SpoolCard({
-  spool, remaining, pct, onClick, onPrintLabel, t,
+  spool, remaining, pct, onClick, onPrintLabel, onCopy, t,
 }: {
   spool: InventorySpool;
   remaining: number;
   pct: number;
   onClick: () => void;
   onPrintLabel?: () => void;
+  onCopy?: () => void;
   t: (key: string, opts?: Record<string, unknown>) => string;
 }) {
   const bannerStyle = buildFilamentBackground({
@@ -1897,6 +1903,16 @@ function SpoolCard({
         <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
           {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
         </span>
+        {onCopy && (
+          <button
+            type="button"
+            onClick={(e) => { e.stopPropagation(); onCopy(); }}
+            className="p-1.5 bg-black/20 hover:bg-black/40 text-white rounded-full transition-colors"
+            title={t('inventory.copySpool')}
+          >
+            <Copy className="w-3.5 h-3.5" />
+          </button>
+        )}
       </div>
       <div className="p-4 space-y-3">
         <div className="flex items-start justify-between gap-2">
@@ -1966,13 +1982,14 @@ function SpoolCard({
 
 /* Single spool row for table view */
 function SpoolTableRow({
-  spool, remaining, pct, onEdit, onRestore, onArchive, onDelete, onPrintLabel,
+  spool, remaining, pct, onEdit, onCopy, onRestore, onArchive, onDelete, onPrintLabel,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spool: InventorySpool;
   remaining: number;
   pct: number;
   onEdit: () => void;
+  onCopy?: () => void;
   onRestore: () => void;
   onArchive: () => void;
   onDelete: () => void;
@@ -2002,6 +2019,11 @@ function SpoolTableRow({
           <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
             <Edit2 className="w-4 h-4" />
           </button>
+          {onCopy && (
+            <button onClick={onCopy} className="p-1.5 text-bambu-gray hover:text-bambu-green rounded transition-colors" title={t('inventory.copySpool')}>
+              <Copy className="w-4 h-4" />
+            </button>
+          )}
           {onPrintLabel && (
             <button onClick={onPrintLabel} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.labels.printOne')}>
               <Printer className="w-4 h-4" />
@@ -2028,7 +2050,7 @@ function SpoolTableRow({
 /* Grouped spool rows for table view */
 function SpoolTableGroup({
   spools, representative, remaining, pct, isExpanded, onToggle,
-  onEdit, onArchive, onDelete, onPrintLabel,
+  onEdit, onCopy, onArchive, onDelete, onPrintLabel,
   visibleColumns, assignmentMap, catalogMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spools: InventorySpool[];
@@ -2038,6 +2060,7 @@ function SpoolTableGroup({
   isExpanded: boolean;
   onToggle: () => void;
   onEdit: (spool: InventorySpool) => void;
+  onCopy?: (spool: InventorySpool) => void;
   onArchive: (id: number) => void;
   onDelete: (id: number) => void;
   onPrintLabel?: (spoolId: number) => void;
@@ -2089,6 +2112,7 @@ function SpoolTableGroup({
             remaining={r}
             pct={p}
             onEdit={() => onEdit(spool)}
+            onCopy={onCopy ? () => onCopy(spool) : undefined}
             onRestore={() => {}}
             onArchive={() => onArchive(spool.id)}
             onDelete={() => onDelete(spool.id)}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C42VWlxK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CHOG7xih.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DA-wcENs.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CdUD30tt.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CHOG7xih.css">
+    <script type="module" crossorigin src="/assets/index-C42VWlxK.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DA-wcENs.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff