From f95b2acd7db622f2d4ca3acc5462f130d55a4bb2 Mon Sep 17 00:00:00 2001 From: lietschaend <51763234+legend813@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:54:27 +0200 Subject: [PATCH] Feature: print files directly from project view (closes #930) (#932) * feat: print files directly from project view (closes #930) Show printable files from linked library folders directly in the project detail page, with Print Now and Add to Queue buttons per file. Removes the detour through the File Manager for common reprint workflows. --- backend/app/api/routes/library.py | 15 +- backend/app/api/routes/print_queue.py | 8 + backend/app/schemas/library.py | 2 + backend/app/schemas/print_queue.py | 2 + backend/app/services/archive.py | 4 + backend/app/services/background_dispatch.py | 6 + backend/app/services/print_scheduler.py | 1 + .../__tests__/components/PrintModal.test.tsx | 103 ++++++++ .../pages/ProjectDetailPage.test.tsx | 225 ++++++++++++++++++ frontend/src/api/client.ts | 8 +- frontend/src/components/PrintModal/index.tsx | 5 + frontend/src/components/PrintModal/types.ts | 2 + frontend/src/i18n/locales/de.ts | 3 + frontend/src/i18n/locales/en.ts | 3 + frontend/src/i18n/locales/fr.ts | 3 + frontend/src/i18n/locales/it.ts | 3 + frontend/src/i18n/locales/ja.ts | 3 + frontend/src/i18n/locales/pt-BR.ts | 3 + frontend/src/i18n/locales/zh-CN.ts | 3 + frontend/src/pages/FileManagerPage.tsx | 2 +- frontend/src/pages/ProjectDetailPage.tsx | 193 ++++++++++++--- 21 files changed, 566 insertions(+), 31 deletions(-) create mode 100644 frontend/src/__tests__/pages/ProjectDetailPage.test.tsx 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() {