diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 7f104798c..4aec1e2c2 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -1044,14 +1044,16 @@ async def scan_external_folder( async def list_files( response: Response, folder_id: int | None = None, + project_id: int | None = None, include_root: bool = True, db: AsyncSession = Depends(get_db), _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)), ): - """List files, optionally filtered by folder. + """List files, optionally filtered by folder or project. Args: folder_id: Filter by folder ID. If None and include_root=True, returns root files. + project_id: Return all files across folders linked to this project (bulk fetch, avoids N+1). include_root: If True and folder_id is None, returns files at root level. If False and folder_id is None, returns all files. """ @@ -1059,6 +1061,10 @@ async def list_files( if folder_id is not None: query = query.where(LibraryFile.folder_id == folder_id) + elif project_id is not None: + # Single join instead of one query per folder (avoids N+1 pattern) + query = query.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id) + query = query.where(LibraryFolder.project_id == project_id) elif include_root: query = query.where(LibraryFile.folder_id.is_(None)) @@ -2210,6 +2216,12 @@ async def print_library_file( if not printer_manager.is_connected(printer_id): raise HTTPException(status_code=400, detail="Printer is not connected") + # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500 + if body.project_id is not None: + project_result = await db.execute(select(Project).where(Project.id == body.project_id)) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + plate_name = body.plate_name if not plate_name and body.plate_id is not None: plate_name = f"Plate {body.plate_id}" @@ -2225,6 +2237,7 @@ async def print_library_file( printer_id=printer_id, printer_name=printer.name, options=body.model_dump(exclude_none=True), + project_id=body.project_id, requested_by_user_id=None, requested_by_username=None, ) diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index d795d37c1..e3453ccab 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -21,6 +21,7 @@ from backend.app.models.library import LibraryFile from backend.app.models.print_batch import PrintBatch from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer +from backend.app.models.project import Project from backend.app.models.user import User from backend.app.schemas.print_queue import ( PrintBatchResponse, @@ -484,6 +485,12 @@ async def add_to_queue( if plate_time is not None: cached_print_time = plate_time + # Validate project exists before insert so a bogus ID yields 404, not an FK-constraint 500 + if data.project_id is not None: + project_result = await db.execute(select(Project).where(Project.id == data.project_id)) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None items = [] for i in range(quantity): @@ -508,6 +515,7 @@ async def add_to_queue( timelapse=data.timelapse, use_ams=data.use_ams, gcode_injection=data.gcode_injection, + project_id=data.project_id, position=max_pos + 1 + i, status="pending", created_by_id=current_user.id if current_user else None, diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py index ad6fac0e4..2ecd9f74c 100644 --- a/backend/app/schemas/library.py +++ b/backend/app/schemas/library.py @@ -208,6 +208,8 @@ class FilePrintRequest(BaseModel): layer_inspect: bool = False timelapse: bool = False use_ams: bool = True + # Project to associate the resulting archive with + project_id: int | None = None class FileUploadResponse(BaseModel): diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 52f74e92e..ad192b086 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -44,6 +44,8 @@ class PrintQueueItemCreate(BaseModel): gcode_injection: bool = False # Batch: create multiple copies (creates a batch if > 1) quantity: int = 1 + # Project to associate the resulting archive with + project_id: int | None = None class PrintQueueItemUpdate(BaseModel): diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 4b642f77c..e02a1dbee 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -854,6 +854,7 @@ class ArchiveService: print_data: dict | None = None, created_by_id: int | None = None, original_filename: str | None = None, + project_id: int | None = None, ) -> PrintArchive | None: """Archive a 3MF file with metadata. @@ -864,6 +865,8 @@ class ArchiveService: created_by_id: User ID who created this archive (optional, for user tracking) original_filename: Original human-readable filename (optional, for library files stored with UUID names) + project_id: Project to associate this archive with (optional, set when triggered + from the project view) """ # Verify printer exists if specified if printer_id is not None: @@ -974,6 +977,7 @@ class ArchiveService: quantity=quantity, extra_data=metadata, created_by_id=created_by_id, + project_id=project_id, ) self.db.add(archive) diff --git a/backend/app/services/background_dispatch.py b/backend/app/services/background_dispatch.py index af9931e27..72e381ae5 100644 --- a/backend/app/services/background_dispatch.py +++ b/backend/app/services/background_dispatch.py @@ -54,6 +54,7 @@ class PrintDispatchJob: options: dict[str, Any] = field(default_factory=dict) requested_by_user_id: int | None = None requested_by_username: str | None = None + project_id: int | None = None @dataclass(slots=True) @@ -160,6 +161,7 @@ class BackgroundDispatchService: options: dict[str, Any], requested_by_user_id: int | None, requested_by_username: str | None, + project_id: int | None = None, ) -> dict[str, Any]: return await self._dispatch( kind="print_library_file", @@ -170,6 +172,7 @@ class BackgroundDispatchService: options=options, requested_by_user_id=requested_by_user_id, requested_by_username=requested_by_username, + project_id=project_id, ) async def cancel_job(self, job_id: int) -> dict[str, Any]: @@ -257,6 +260,7 @@ class BackgroundDispatchService: options: dict[str, Any], requested_by_user_id: int | None, requested_by_username: str | None, + project_id: int | None = None, ) -> dict[str, Any]: async with self._lock: has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs) @@ -279,6 +283,7 @@ class BackgroundDispatchService: options=options, requested_by_user_id=requested_by_user_id, requested_by_username=requested_by_username, + project_id=project_id, ) self._next_job_id += 1 self._batch_total += 1 @@ -722,6 +727,7 @@ class BackgroundDispatchService: printer_id=job.printer_id, source_file=file_path, original_filename=lib_file.filename, + project_id=job.project_id, ) if not archive: raise RuntimeError("Failed to create archive") diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index f38118d69..b4017d1c7 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -1641,6 +1641,7 @@ class PrintScheduler: source_file=file_path, original_filename=filename, created_by_id=item.created_by_id, + project_id=item.project_id, ) if archive: item.archive_id = archive.id diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index d396b296e..8b03aea5a 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -1141,4 +1141,107 @@ describe('PrintModal', () => { expect(input.value).toBe('5'); }); }); + + describe('project_id forwarding', () => { + beforeEach(() => { + // Additional handlers needed for library file mode + server.use( + http.get('/api/v1/library/files/:id', () => { + return HttpResponse.json({ + id: 5, + filename: 'benchy.gcode.3mf', + print_name: null, + file_type: '3mf', + folder_id: null, + project_id: null, + file_hash: null, + file_size_bytes: 1024, + thumbnail_path: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + }), + http.get('/api/v1/library/files/:id/plates', () => { + return HttpResponse.json({ is_multi_plate: false, plates: [] }); + }), + http.get('/api/v1/library/files/:id/filament-requirements', () => { + return HttpResponse.json({ file_id: 5, filename: 'benchy.gcode.3mf', filaments: [] }); + }), + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] }); + }), + ); + }); + + it('includes project_id in printLibraryFile call when projectId prop is set', async () => { + let capturedBody: Record | null = null; + server.use( + http.post('/api/v1/library/files/:id/print', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ status: 'dispatched', dispatch_job_id: 'abc', dispatch_position: 0 }); + }) + ); + const user = userEvent.setup(); + + render( + + ); + + // Wait for the modal to load printer and file data + await waitFor(() => { + expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^print$/i })); + + await waitFor(() => { + expect(capturedBody).not.toBeNull(); + expect(capturedBody?.project_id).toBe(42); + }); + }); + + it('does NOT include project_id in reprintArchive call (archives carry their own project association)', async () => { + // The reprintArchive branch omits project_id by design — archives already carry + // their project association from the original print. This test guards that intent. + let capturedBody: Record | null = null; + server.use( + http.post('/api/v1/archives/:id/reprint', async ({ request }) => { + capturedBody = await request.json() as Record; + return HttpResponse.json({ status: 'dispatched' }); + }) + ); + const user = userEvent.setup(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^print$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^print$/i })); + + await waitFor(() => { + expect(capturedBody).not.toBeNull(); + expect(capturedBody).not.toHaveProperty('project_id'); + }); + }); + }); }); diff --git a/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx b/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx new file mode 100644 index 000000000..8454a57bf --- /dev/null +++ b/frontend/src/__tests__/pages/ProjectDetailPage.test.tsx @@ -0,0 +1,225 @@ +/** + * Tests for the ProjectDetailPage component. + * Covers: isSlicedFilename conditional print-button logic, linked folder file rendering, + * and the PrintModal open trigger with projectId. + */ + +/// + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { ProjectDetailPage } from '../../pages/ProjectDetailPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +// Mock useParams so the component receives a fixed project id without a nested Router +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ id: '1' }), + useNavigate: () => vi.fn(), + }; +}); + +const mockProject = { + id: 1, + name: 'Test Project', + description: 'A test project', + color: '#00ae42', + status: 'active', + priority: 'normal', + due_date: null, + notes: null, + parent_id: null, + archive_count: 0, + total_print_time_seconds: 0, + total_filament_grams: 0, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +const mockFolder = { + id: 10, + name: 'Sliced Files', + project_id: 1, + archive_id: null, + parent_id: null, + file_count: 3, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +function makeFile(overrides: { id: number; filename: string; file_type?: string }) { + return { + id: overrides.id, + filename: overrides.filename, + print_name: null, + file_type: overrides.file_type ?? '3mf', + folder_id: 10, + project_id: 1, + file_hash: null, + file_size_bytes: 1024, + thumbnail_path: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + duplicate_count: 0, + }; +} + +describe('ProjectDetailPage', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/projects/:id', () => { + return HttpResponse.json(mockProject); + }), + http.get('/api/v1/projects/:id/archives', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/projects/:id/bom', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/projects/:id/timeline', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/library/folders/by-project/:id', () => { + return HttpResponse.json([mockFolder]); + }), + ); + }); + + describe('isSlicedFilename — conditional print button', () => { + it('shows print button for .gcode files', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 1, filename: 'benchy.gcode', file_type: 'gcode' })]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Print Now')).toBeInTheDocument(); + }); + }); + + it('shows print button for .gcode.3mf files', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 2, filename: 'benchy.gcode.3mf', file_type: '3mf' })]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Print Now')).toBeInTheDocument(); + }); + }); + + it('does NOT show print button for .gcode.bak files (regression for includes bug)', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 3, filename: 'benchy.gcode.bak', file_type: '3mf' })]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('benchy.gcode.bak')).toBeInTheDocument(); + }); + + expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument(); + }); + + it('does NOT show print button for .stl files', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 4, filename: 'model.stl', file_type: 'stl' })]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('model.stl')).toBeInTheDocument(); + }); + + expect(screen.queryByTitle('Print Now')).not.toBeInTheDocument(); + }); + }); + + describe('linked folder file rendering', () => { + it('renders filenames from linked folder', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([ + makeFile({ id: 5, filename: 'part_a.gcode.3mf', file_type: '3mf' }), + makeFile({ id: 6, filename: 'design.stl', file_type: 'stl' }), + ]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('part_a.gcode.3mf')).toBeInTheDocument(); + expect(screen.getByText('design.stl')).toBeInTheDocument(); + }); + }); + + it('renders the linked folder name', async () => { + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Sliced Files')).toBeInTheDocument(); + }); + }); + }); + + describe('print modal trigger', () => { + it('opens PrintModal when print button is clicked on a sliced file', async () => { + const user = userEvent.setup(); + + server.use( + http.get('/api/v1/library/files', () => { + return HttpResponse.json([makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })]); + }), + http.get('/api/v1/printers/', () => { + return HttpResponse.json([]); + }), + http.get('/api/v1/library/files/:id', () => { + return HttpResponse.json(makeFile({ id: 7, filename: 'cube.gcode.3mf', file_type: '3mf' })); + }), + http.get('/api/v1/library/files/:id/plates', () => { + return HttpResponse.json({ is_multi_plate: false, plates: [] }); + }), + http.get('/api/v1/library/files/:id/filament-requirements', () => { + return HttpResponse.json({ file_id: 7, filename: 'cube.gcode.3mf', filaments: [] }); + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Print Now')).toBeInTheDocument(); + }); + + await user.click(screen.getByTitle('Print Now')); + + // PrintModal should open — look for the modal heading "Print" + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Print' })).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 91e0df2a0..a9fe589fa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1441,6 +1441,8 @@ export interface PrintQueueItemCreate { gcode_injection?: boolean; // Batch: create multiple copies (creates a batch if > 1) quantity?: number; + // Project to associate the resulting archive with + project_id?: number; } export interface PrintQueueItemUpdate { @@ -4240,11 +4242,14 @@ export const api = { getLibraryFoldersByArchive: (archiveId: number) => request(`/library/folders/by-archive/${archiveId}`), - getLibraryFiles: (folderId?: number | null, includeRoot = true) => { + getLibraryFiles: (folderId?: number | null, includeRoot = true, projectId?: number) => { const params = new URLSearchParams(); if (folderId !== undefined && folderId !== null) { params.set('folder_id', String(folderId)); } + if (projectId !== undefined) { + params.set('project_id', String(projectId)); + } params.set('include_root', String(includeRoot)); return request(`/library/files?${params}`); }, @@ -4379,6 +4384,7 @@ export const api = { layer_inspect?: boolean; timelapse?: boolean; use_ams?: boolean; + project_id?: number; } ) => request( diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 81172c6b3..98e674c25 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -48,6 +48,7 @@ export function PrintModal({ initialSelectedPrinterIds, onClose, onSuccess, + projectId, }: PrintModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -654,6 +655,7 @@ export function PrintModal({ ? new Date(scheduleOptions.scheduledTime).toISOString() : undefined, ...printOptions, + project_id: projectId ?? undefined, }); // Model-based assignment @@ -734,8 +736,11 @@ export function PrintModal({ plate_name: selectedPlateName, ams_mapping: printerMapping, ...printOptions, + project_id: projectId, }); } else { + // project_id is intentionally omitted here: reprintArchive targets an existing + // archive that already carries its own project association from the original print. await api.reprintArchive(archiveId!, printerId, { plate_id: selectedPlate ?? undefined, plate_name: selectedPlateName, diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index baf8a0128..2304eef34 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -32,6 +32,8 @@ export interface PrintModalProps { onClose: () => void; /** Handler for successful operation */ onSuccess?: () => void; + /** Project ID to associate the resulting archive with (only when triggered from project view) */ + projectId?: number; } /** diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index dec4a794f..852f70149 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2902,6 +2902,9 @@ export default { forQuickAccess: 'für schnellen Zugriff auf dieses Projekt.', fileCount: '{{count}} Datei(en)', empty: 'Keine Ordner verknüpft. Gehen Sie zum Dateimanager und verknüpfen Sie einen Ordner mit diesem Projekt.', + noFiles: 'Keine Dateien in diesem Ordner.', + print: 'Jetzt drucken', + addToQueue: 'Zur Warteschlange', }, bom: { title: 'Stückliste', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0dbd6f50d..5882f4b13 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2904,6 +2904,9 @@ export default { forQuickAccess: 'to this project for quick access.', fileCount: '{{count}} file(s)', empty: 'No folders linked. Go to File Manager and link a folder to this project.', + noFiles: 'No files in this folder.', + print: 'Print Now', + addToQueue: 'Add to Queue', }, bom: { title: 'Bill of Materials', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 6f8d29341..ebb67327d 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2889,6 +2889,9 @@ export default { forQuickAccess: 'pour un accès rapide.', fileCount: '{{count}} fichier(s)', empty: 'Aucun dossier lié.', + noFiles: 'Aucun fichier dans ce dossier.', + print: 'Imprimer maintenant', + addToQueue: 'Ajouter à la file', }, bom: { title: 'BOM (Liste matériel)', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index beae8cd66..3fe18c3ba 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2888,6 +2888,9 @@ export default { forQuickAccess: 'a questo progetto per accesso rapido.', fileCount: '{{count}} file', empty: 'Nessuna cartella collegata. Vai a Gestore file e collega una cartella a questo progetto.', + noFiles: 'Nessun file in questa cartella.', + print: 'Stampa ora', + addToQueue: 'Aggiungi alla coda', }, bom: { title: 'Distinta materiali', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 8c3323bbd..e9128c067 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2901,6 +2901,9 @@ export default { forQuickAccess: 'してクイックアクセスできるようにします。', fileCount: '{{count}}ファイル', empty: '<空>', + noFiles: 'このフォルダにファイルはありません。', + print: '今すぐ印刷', + addToQueue: 'キューに追加', }, bom: { title: '部品表', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 1ecde0232..5d2d1f89d 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2888,6 +2888,9 @@ export default { forQuickAccess: 'a este projeto para acesso rápido.', fileCount: '{{count}} arquivo(s)', empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.', + noFiles: 'Nenhum arquivo nesta pasta.', + print: 'Imprimir agora', + addToQueue: 'Adicionar à fila', }, bom: { title: 'Lista de Materiais', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 7f956578b..254df4b00 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2888,6 +2888,9 @@ export default { forQuickAccess: '到此项目以便快速访问。', fileCount: '{{count}} 个文件', empty: '未链接文件夹。前往文件管理器将文件夹链接到此项目。', + noFiles: '此文件夹中没有文件。', + print: '立即打印', + addToQueue: '加入队列', }, bom: { title: '材料清单', diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index b67312bf8..a99bb512c 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -677,7 +677,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, // Helper to check if a file is sliced (printable) function isSlicedFilename(filename: string): boolean { const lower = filename.toLowerCase(); - return lower.endsWith('.gcode') || lower.includes('.gcode.'); + return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf'); } // File Card diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 209783325..7663eb559 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import DOMPurify from 'dompurify'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -31,21 +31,31 @@ import { FolderOpen, Download, Pencil, + Play, + CalendarPlus, + FileBox, } from 'lucide-react'; import { api } from '../api/client'; import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date'; -import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client'; +import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate, LibraryFileListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { RichTextEditor } from '../components/RichTextEditor'; import { ConfirmModal } from '../components/ConfirmModal'; +import { PrintModal } from '../components/PrintModal'; // Project edit modal (reused from ProjectsPage) import { ProjectModal } from './ProjectsPage'; import { getCurrencySymbol } from '../utils/currency'; +// Returns true for sliced (printable) files: .gcode and .gcode.3mf +function isSlicedFilename(filename: string): boolean { + const lower = filename.toLowerCase(); + return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf'); +} + function formatFilament(grams: number): string { if (grams >= 1000) { return `${(grams / 1000).toFixed(2)}kg`; @@ -201,6 +211,8 @@ export function ProjectDetailPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingNotes, setEditingNotes] = useState(false); const [notesContent, setNotesContent] = useState(''); + const [printFile, setPrintFile] = useState(null); + const [scheduleFile, setScheduleFile] = useState(null); const projectId = parseInt(id || '0', 10); @@ -239,6 +251,26 @@ export function ProjectDetailPage() { enabled: projectId > 0, }); + // Single bulk query — replaces the previous N+1 useQueries pattern + const { data: allProjectFiles, isLoading: projectFilesLoading } = useQuery({ + queryKey: ['project-files', projectId], + queryFn: () => api.getLibraryFiles(null, false, projectId), + enabled: projectId > 0, + }); + + // Group files by folder_id for the section-based render + const filesByFolder = useMemo(() => { + const map = new Map(); + for (const file of allProjectFiles ?? []) { + if (file.folder_id != null) { + const arr = map.get(file.folder_id) ?? []; + arr.push(file); + map.set(file.folder_id, arr); + } + } + return map; + }, [allProjectFiles]); + const currency = getCurrencySymbol(settings?.currency || 'USD'); const timeFormat: TimeFormat = settings?.time_format || 'system'; @@ -473,7 +505,7 @@ export function ProjectDetailPage() {
@@ -837,7 +869,7 @@ export function ProjectDetailPage() { - {/* Files section - linked folders from File Manager */} + {/* Files section - linked folders from File Manager with printable files */}
@@ -855,27 +887,102 @@ export function ProjectDetailPage() {

{linkedFolders && linkedFolders.length > 0 ? ( -
- {linkedFolders.map((folder) => ( - -
- -
-

- {folder.name} +

+ {linkedFolders.map((folder) => { + const files = filesByFolder.get(folder.id) ?? []; + const isLoading = projectFilesLoading; + + return ( +
+ {/* Folder header — links to File Manager */} + +
+ +
+

{folder.name}

+

+ {t('projectDetail.files.fileCount', { count: folder.file_count })} +

+
+
+ + + + {/* File list within the folder */} + {isLoading ? ( +
+ +
+ ) : files.length === 0 ? ( +

+ {t('projectDetail.files.noFiles')}

-

- {t('projectDetail.files.fileCount', { count: folder.file_count })} -

-
+ ) : ( +
+ {files.map((file) => { + const printable = isSlicedFilename(file.filename); + return ( +
+ {/* Thumbnail */} +
+ {file.thumbnail_path ? ( + {file.print_name + ) : ( + + )} +
+ + {/* Name + type badge */} +
+

+ {file.print_name || file.filename} +

+ + {file.file_type.toUpperCase()} + +
+ + {/* Print actions for sliced files */} + {printable && ( +
+ + +
+ )} +
+ ); + })} +
+ )}
- - - ))} + ); + })}
) : (

@@ -1066,7 +1173,7 @@ export function ProjectDetailPage() { onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)} disabled={updateBomMutation.isPending || !hasPermission('projects:update')} title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined} - className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${ + className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${ item.is_complete ? 'bg-status-ok border-status-ok text-white' : hasPermission('projects:update') @@ -1095,7 +1202,7 @@ export function ProjectDetailPage() {