import { useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, Edit3, Loader2, Package, Clock, CheckCircle, XCircle, ListTodo, Printer, ChevronRight, FileText, Tag, Calendar, AlertTriangle, Save, X, Trash2, Plus, History, FolderTree, Copy, Layers, ExternalLink, ShoppingCart, FolderOpen, Download, Pencil, } from 'lucide-react'; import { api } from '../api/client'; import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date'; import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } 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'; // Project edit modal (reused from ProjectsPage) import { ProjectModal } from './ProjectsPage'; function formatDuration(hours: number): string { if (hours < 1) { return `${Math.round(hours * 60)}m`; } const h = Math.floor(hours); const m = Math.round((hours - h) * 60); return m > 0 ? `${h}h ${m}m` : `${h}h`; } function formatFilament(grams: number): string { if (grams >= 1000) { return `${(grams / 1000).toFixed(2)}kg`; } return `${Math.round(grams)}g`; } type TFunction = (key: string, options?: Record) => string; function StatusBadge({ status, t }: { status: string; t: TFunction }) { const colors = { active: 'bg-bambu-green/20 text-bambu-green', completed: 'bg-blue-500/20 text-blue-400', archived: 'bg-bambu-gray/20 text-bambu-gray', }; const color = colors[status as keyof typeof colors] || colors.active; const labels: Record = { active: t('projectDetail.status.active'), completed: t('projectDetail.status.completed'), archived: t('projectDetail.status.archived'), }; return ( {labels[status] || status.charAt(0).toUpperCase() + status.slice(1)} ); } function StatCard({ icon: Icon, label, value, subValue, hint, color = 'text-bambu-gray', }: { icon: React.ElementType; label: string; value: string | number; subValue?: string; hint?: string; color?: string; }) { return (

{label}

{value}

{subValue &&

{subValue}

}
); } function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) { if (archives.length === 0) { return (

{t('projectDetail.noPrints')}

); } return (
{archives.map((archive) => ( {archive.thumbnail_path ? ( {archive.print_name ) : (
)} {/* Status overlay */} {archive.status === 'failed' && (
)} {archive.status === 'completed' && (
)} {/* Name overlay on hover */}

{archive.print_name || 'Unknown'}

))}
); } function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) { const config = { low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') }, normal: { color: 'bg-blue-500/20 text-blue-400', label: t('projectDetail.priority.normal') }, high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') }, urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') }, }; const { color, label } = config[priority as keyof typeof config] || config.normal; return ( {priority === 'urgent' && } {label} ); } function formatDate(dateString: string | null): string { if (!dateString) return ''; return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' }); } function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null { if (!dateString) return null; const dueDate = parseUTCDate(dateString); if (!dueDate) return null; const now = new Date(); const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays < 0) return { color: 'text-red-400', label: t('projectDetail.dueDate.overdue') }; if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') }; if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) }; return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) }; } export function ProjectDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const [showEditModal, setShowEditModal] = useState(false); const [editingNotes, setEditingNotes] = useState(false); const [notesContent, setNotesContent] = useState(''); const projectId = parseInt(id || '0', 10); const { data: project, isLoading: projectLoading, error: projectError } = useQuery({ queryKey: ['project', projectId], queryFn: () => api.getProject(projectId), enabled: projectId > 0, }); const { data: archives, isLoading: archivesLoading } = useQuery({ queryKey: ['project-archives', projectId], queryFn: () => api.getProjectArchives(projectId), enabled: projectId > 0, }); const { data: bomItems, isLoading: bomLoading } = useQuery({ queryKey: ['project-bom', projectId], queryFn: () => api.getProjectBOM(projectId), enabled: projectId > 0, }); const { data: timeline, isLoading: timelineLoading } = useQuery({ queryKey: ['project-timeline', projectId], queryFn: () => api.getProjectTimeline(projectId, 20), enabled: projectId > 0, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const { data: linkedFolders } = useQuery({ queryKey: ['project-folders', projectId], queryFn: () => api.getLibraryFoldersByProject(projectId), enabled: projectId > 0, }); const currency = settings?.currency || '$'; const timeFormat: TimeFormat = settings?.time_format || 'system'; const updateMutation = useMutation({ mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project', projectId] }); queryClient.invalidateQueries({ queryKey: ['projects'] }); setShowEditModal(false); setEditingNotes(false); showToast(t('projectDetail.toast.projectUpdated'), 'success'); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const handleStartEditNotes = () => { setNotesContent(project?.notes || ''); setEditingNotes(true); }; const handleSaveNotes = () => { updateMutation.mutate({ notes: notesContent }); }; const handleCancelNotes = () => { setEditingNotes(false); setNotesContent(''); }; // BOM handlers const [newBomName, setNewBomName] = useState(''); const [newBomQty, setNewBomQty] = useState(1); const [newBomPrice, setNewBomPrice] = useState(''); const [newBomUrl, setNewBomUrl] = useState(''); const [newBomRemarks, setNewBomRemarks] = useState(''); const [showBomForm, setShowBomForm] = useState(false); const [hideBomCompleted, setHideBomCompleted] = useState(false); const [editingBomItem, setEditingBomItem] = useState(null); const [editBomName, setEditBomName] = useState(''); const [editBomQty, setEditBomQty] = useState(1); const [editBomPrice, setEditBomPrice] = useState(''); const [editBomUrl, setEditBomUrl] = useState(''); const [editBomRemarks, setEditBomRemarks] = useState(''); // Confirm modal state const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; title: string; message: string; onConfirm: () => void; }>({ isOpen: false, title: '', message: '', onConfirm: () => {} }); const createBomMutation = useMutation({ mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] }); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); setNewBomName(''); setNewBomQty(1); setNewBomPrice(''); setNewBomUrl(''); setNewBomRemarks(''); setShowBomForm(false); showToast(t('projectDetail.toast.partAdded'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const updateBomMutation = useMutation({ mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) => api.updateBOMItem(projectId, itemId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] }); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); setEditingBomItem(null); }, onError: (error: Error) => showToast(error.message, 'error'), }); const deleteBomMutation = useMutation({ mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] }); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); showToast(t('projectDetail.toast.partRemoved'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const handleAddBomItem = (e: React.FormEvent) => { e.preventDefault(); if (!newBomName.trim()) return; createBomMutation.mutate({ name: newBomName.trim(), quantity_needed: newBomQty, unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined, sourcing_url: newBomUrl.trim() || undefined, remarks: newBomRemarks.trim() || undefined, }); }; const handleToggleAcquired = (item: BOMItem) => { const newQty = item.is_complete ? 0 : item.quantity_needed; updateBomMutation.mutate({ itemId: item.id, data: { quantity_acquired: newQty }, }); }; const handleDeleteBomItem = (itemId: number, itemName: string) => { setConfirmModal({ isOpen: true, title: t('projectDetail.bom.deletePart'), message: t('projectDetail.bom.deleteConfirm', { name: itemName }), onConfirm: () => { setConfirmModal(prev => ({ ...prev, isOpen: false })); deleteBomMutation.mutate(itemId); }, }); }; const handleEditBomItem = (item: BOMItem) => { setEditingBomItem(item); setEditBomName(item.name); setEditBomQty(item.quantity_needed); setEditBomPrice(item.unit_price?.toString() || ''); setEditBomUrl(item.sourcing_url || ''); setEditBomRemarks(item.remarks || ''); }; const handleSaveBomEdit = (e: React.FormEvent) => { e.preventDefault(); if (!editingBomItem || !editBomName.trim()) return; updateBomMutation.mutate({ itemId: editingBomItem.id, data: { name: editBomName.trim(), quantity_needed: editBomQty, unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined, sourcing_url: editBomUrl.trim() || undefined, remarks: editBomRemarks.trim() || undefined, }, }); }; const handleCancelBomEdit = () => { setEditingBomItem(null); }; const handleExportProject = async () => { try { // Fetch ZIP file directly const response = await fetch(`/api/v1/projects/${projectId}/export`); if (!response.ok) { throw new Error(t('projectDetail.toast.exportFailed')); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Get filename from Content-Disposition header or use default const contentDisposition = response.headers.get('Content-Disposition'); const filenameMatch = contentDisposition?.match(/filename="(.+)"/); a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`; a.click(); URL.revokeObjectURL(url); showToast(t('projectDetail.toast.projectExported'), 'success'); } catch (error) { showToast((error as Error).message, 'error'); } }; // Template handlers const createTemplateMutation = useMutation({ mutationFn: () => api.createTemplateFromProject(projectId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast(t('projectDetail.toast.templateCreated'), 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const formatTimelineDate = (timestamp: string) => { return formatDateTime(timestamp, timeFormat, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; if (projectLoading) { return (
); } if (projectError || !project) { return (

{projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}

); } const stats = project.stats; // Plates progress: total_archives / target_count const platesProgressPercent = stats?.progress_percent ?? 0; // Parts progress: completed_prints / target_parts_count const partsProgressPercent = stats?.parts_progress_percent ?? 0; return (
{/* Breadcrumb */}
{t('navigation.projects')} {project.name}
{/* Header */}

{project.name}

{project.description && (

{project.description}

)}
{/* Progress bars (if targets set) */} {(project.target_count || project.target_parts_count) && ( {/* Plates progress */} {project.target_count && (
{t('projectDetail.progress.platesProgress')} {stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
= 100 ? '#22c55e' : project.color || '#6b7280', }} />
{t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })} {stats?.remaining_prints != null && stats.remaining_prints > 0 && ( {t('projectDetail.progress.remaining', { count: stats.remaining_prints })} )}
)} {/* Parts progress */} {project.target_parts_count && (
{t('projectDetail.progress.partsProgress')} {stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
= 100 ? '#22c55e' : project.color || '#6b7280', }} />
{t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })} {stats?.remaining_parts != null && stats.remaining_parts > 0 && ( {t('projectDetail.progress.remaining', { count: stats.remaining_parts })} )}
)} )} {/* Stats grid */} {stats && (

{t('projectDetail.stats.printJobs')}

{stats.total_archives} {t('projectDetail.stats.total')}

{stats.failed_prints > 0 && (

{t('projectDetail.stats.failed', { count: stats.failed_prints })}

)}

{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}

)} {/* Cost tracking */} {stats && (stats.estimated_cost > 0 || project.budget) && (

{t('projectDetail.cost.title')}

{t('projectDetail.cost.filamentCost')}

{currency}{stats.estimated_cost.toFixed(2)}

{stats.total_energy_kwh > 0 && (

{t('projectDetail.cost.energy')}

{stats.total_energy_kwh.toFixed(2)} kWh {stats.total_energy_cost > 0 && ( ({currency}{stats.total_energy_cost.toFixed(2)}) )}

)} {project.budget && ( <>

{t('projectDetail.cost.budget')}

{currency}{project.budget.toFixed(2)}

{t('projectDetail.cost.remaining')}

= 0 ? 'text-bambu-green' : 'text-red-400'}`}> {currency}{(project.budget - stats.estimated_cost).toFixed(2)}

)}
)} {/* Sub-projects */} {project.children && project.children.length > 0 && (

{t('projectDetail.subProjects.title', { count: project.children.length })}

{project.children.map((child) => (
{child.name} {child.status}
{child.progress_percent !== null && ( {child.progress_percent.toFixed(0)}% )} ))}
)} {/* Parent project link */} {project.parent_id && project.parent_name && (
{t('projectDetail.partOf')} {project.parent_name}
)} {/* Meta info row - Tags, Due Date, Priority */} {(project.tags || project.due_date || project.priority !== 'normal') && (
{/* Priority */} {project.priority && project.priority !== 'normal' && (
{t('projectDetail.priorityLabel')}
)} {/* Due Date */} {project.due_date && (
{formatDate(project.due_date)} {getDueDateStatus(project.due_date, t) && ( ({getDueDateStatus(project.due_date, t)!.label}) )}
)} {/* Tags */} {project.tags && (
{project.tags.split(',').map((tag, index) => ( {tag.trim()} ))}
)}
)} {/* Notes section */}

{t('projectDetail.notes.title')}

{!editingNotes ? ( ) : (
)}
{editingNotes ? ( ) : project.notes ? (
) : (

{t('projectDetail.notes.empty')}

)} {/* Files section - linked folders from File Manager */}

{t('projectDetail.files.title')}

{t('projectDetail.files.linkFolders')} {' '}{t('projectDetail.files.forQuickAccess')}

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

{folder.name}

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

))}
) : (

{t('projectDetail.files.empty')}

)}
{/* BOM Section - Parts to source/purchase */}

{t('projectDetail.bom.title')} {stats && stats.bom_total_items > 0 && ( ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })}) )}

{bomItems && bomItems.some(item => item.is_complete) && ( )} {!showBomForm && ( )}
{/* Add BOM item form */} {showBomForm && (
setNewBomName(e.target.value)} className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.partNamePlaceholder')} autoFocus />
setNewBomQty(parseInt(e.target.value) || 1)} className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green" min="1" placeholder={t('projectDetail.bom.qty')} /> setNewBomPrice(e.target.value)} className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.price', { currency })} />
setNewBomUrl(e.target.value)} className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')} /> setNewBomRemarks(e.target.value)} className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.remarksPlaceholder')} />
)} {bomLoading ? (
) : bomItems && bomItems.length > 0 ? (
{bomItems .filter(item => !hideBomCompleted || !item.is_complete) .map((item) => (
{editingBomItem?.id === item.id ? ( // Edit form for this BOM item
setEditBomName(e.target.value)} className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.partName')} autoFocus />
setEditBomQty(parseInt(e.target.value) || 1)} className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green" min="1" placeholder={t('projectDetail.bom.qty')} /> setEditBomPrice(e.target.value)} className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.price', { currency })} />
setEditBomUrl(e.target.value)} className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')} /> setEditBomRemarks(e.target.value)} className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" placeholder={t('projectDetail.bom.remarksPlaceholder')} />
) : ( // Display mode

{item.name} x{item.quantity_needed}

{item.unit_price !== null && ( {currency}{(item.unit_price * item.quantity_needed).toFixed(2)} )}
{/* Sourcing URL */} {item.sourcing_url && ( e.stopPropagation()} > {(() => { try { return new URL(item.sourcing_url).hostname.replace('www.', ''); } catch { return item.sourcing_url; } })()} )} {/* Remarks */} {item.remarks && (

{item.remarks}

)}
)}
))} {/* BOM Total */} {bomItems.some(item => item.unit_price !== null) && (
{t('projectDetail.bom.totalCost')} {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
)}
) : (

{t('projectDetail.bom.empty')}

)}
{/* Timeline Section */}

{t('projectDetail.timeline.title')}

{timelineLoading ? (
) : timeline && timeline.length > 0 ? (
{timeline.slice(0, 10).map((event, index) => (
{event.event_type === 'print_completed' && } {event.event_type === 'print_failed' && } {event.event_type === 'print_started' && } {event.event_type === 'queued' && } {event.event_type === 'project_created' && }

{event.title}

{event.description && (

{event.description}

)}

{formatTimelineDate(event.timestamp)}

))}
) : (

{t('projectDetail.timeline.empty')}

)}
{/* Template action */} {!project.is_template && (
)} {/* Queue section */} {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (

{t('projectDetail.queue.title')}

{t('projectDetail.queue.viewAll')}
{stats.in_progress_prints > 0 && ( {t('projectDetail.queue.printing', { count: stats.in_progress_prints })} )} {stats.queued_prints > 0 && ( {t('projectDetail.queue.queued', { count: stats.queued_prints })} )}
)} {/* Archives section */}

{t('projectDetail.prints.title', { count: archives?.length || 0 })}

{archivesLoading ? (
) : ( )}
{/* Edit Modal */} {showEditModal && ( setShowEditModal(false)} onSave={(data) => updateMutation.mutate(data as ProjectUpdate)} isLoading={updateMutation.isPending} /> )} {/* Confirm Modal */} {confirmModal.isOpen && ( setConfirmModal(prev => ({ ...prev, isOpen: false }))} /> )}
); }