From 6a130c09d75ab947cc3d3dc2f698ae97205f2c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel=20L=C3=B3pez=20Vicente?= Date: Sat, 9 May 2026 09:40:21 +0200 Subject: [PATCH] [Feature] Copy filament (#1246) --- .../components/SpoolFormBulk.test.tsx | 7 + .../components/SpoolFormModal.test.tsx | 92 ++++++++ .../pages/InventoryPageCopyButton.test.tsx | 206 ++++++++++++++++++ .../pages/InventoryPageDeepLink.test.tsx | 15 ++ frontend/src/components/SpoolFormModal.tsx | 23 +- frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/it.ts | 1 + frontend/src/i18n/locales/ja.ts | 1 + frontend/src/i18n/locales/pt-BR.ts | 1 + frontend/src/i18n/locales/zh-CN.ts | 1 + frontend/src/i18n/locales/zh-TW.ts | 1 + frontend/src/pages/InventoryPage.tsx | 54 +++-- .../{index-CdUD30tt.js => index-C42VWlxK.js} | 186 ++++++++-------- static/assets/index-CHOG7xih.css | 1 - static/assets/index-DA-wcENs.css | 1 + static/index.html | 4 +- 18 files changed, 479 insertions(+), 118 deletions(-) create mode 100644 frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx rename static/assets/{index-CdUD30tt.js => index-C42VWlxK.js} (86%) delete mode 100644 static/assets/index-CHOG7xih.css create mode 100644 static/assets/index-DA-wcENs.css diff --git a/frontend/src/__tests__/components/SpoolFormBulk.test.tsx b/frontend/src/__tests__/components/SpoolFormBulk.test.tsx index 024daf8e9..902a6e6b0 100644 --- a/frontend/src/__tests__/components/SpoolFormBulk.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormBulk.test.tsx @@ -144,6 +144,7 @@ describe('SpoolFormModal quick-add toggle', () => { , ); @@ -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', () => { , ); @@ -209,6 +212,7 @@ describe('SpoolFormModal quick-add toggle', () => { , ); @@ -226,6 +230,7 @@ describe('SpoolFormModal quick-add toggle', () => { , ); @@ -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', () => { , ); diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index d227d6f72..847f421fe 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/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', () => { @@ -498,6 +506,7 @@ describe('SpoolFormModal weightTouched', () => { @@ -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( + + ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument(); + }); + }); + + it('calls api.createSpool (not api.updateSpool) when saving in copy mode', async () => { + render( + + ); + + 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( + + ); + + 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).weight_used).toBe(0); + }); +}); diff --git a/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx b/frontend/src/__tests__/pages/InventoryPageCopyButton.test.tsx new file mode 100644 index 000000000..8132fc145 --- /dev/null +++ b/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(); + + // 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(); + + 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(); + }); + }); + +}); diff --git a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx b/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx index b5922a864..cda91b217 100644 --- a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx +++ b/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([])), ); } diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 6730497ab..f97261fa5 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/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(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 */}

- {isEditing ? t('inventory.editSpool') : t('inventory.addSpool')} + {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}

- {/* 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' && (
@@ -904,7 +913,7 @@ export function SpoolFormModal({ ) : ( <> - {isEditing ? t('common.save') : t('inventory.addSpool')} + {isEditing ? t('common.save') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')} )} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index eaed9f0a5..62b6154e7 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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...', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2c3ee147a..fe4ab157a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 63521e4e5..573589278 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index e2242066a..f64e8c1d1 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 9316d5505..1fca66155 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -3459,6 +3459,7 @@ export default { }, addSpool: 'スプールを追加', editSpool: 'スプールを編集', + copySpool: 'スプールをコピー', material: '素材', selectMaterial: '素材を選択...', subtype: 'サブタイプ', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index abe28a95d..2f3763e64 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 4c80e3556..b9b58b419 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3447,6 +3447,7 @@ export default { }, addSpool: '添加耗材', editSpool: '编辑耗材', + copySpool: '复制耗材', material: '材料', selectMaterial: '选择材料...', subtype: '子类型', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 6ae8fa649..cd7661050 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -3447,6 +3447,7 @@ export default { }, addSpool: '新增耗材', editSpool: '編輯耗材', + copySpool: '複製耗材', material: '材料', selectMaterial: '選擇材料...', subtype: '子類型', diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index f73d8c900..4c8eb5b04 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/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 {t('inventory.labels.printLabels', 'Print labels…')} - @@ -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 ) : ( 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 ) : ( 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; }) { const bannerStyle = buildFilamentBackground({ @@ -1897,6 +1903,16 @@ function SpoolCard({ {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'} + {onCopy && ( + + )}
@@ -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({ + {onCopy && ( + + )} {onPrintLabel && (