import { useState, useRef } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Edit3, Loader2, Package, Clock, CheckCircle, XCircle, ListTodo, Printer, ChevronRight, FileText, Tag, Calendar, AlertTriangle, Save, X, Paperclip, Upload, Download, Trash2, File, Plus, History, FolderTree, Copy, Layers, ExternalLink, ShoppingCart, } from 'lucide-react'; import { api } from '../api/client'; import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; import { useToast } from '../contexts/ToastContext'; 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`; } function StatusBadge({ status }: { status: string }) { 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; return ( {status.charAt(0).toUpperCase() + status.slice(1)} ); } function StatCard({ icon: Icon, label, value, subValue, color = 'text-bambu-gray', }: { icon: React.ElementType; label: string; value: string | number; subValue?: string; color?: string; }) { return (

{label}

{value}

{subValue &&

{subValue}

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

No prints in this project yet

); } 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 }: { priority: string }) { const config = { low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' }, normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' }, high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' }, urgent: { color: 'bg-red-500/20 text-red-400', label: '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 ''; const date = new Date(dateString); return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } function getDueDateStatus(dateString: string | null): { color: string; label: string } | null { if (!dateString) return null; const dueDate = new Date(dateString); 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: 'Overdue' }; if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' }; if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` }; return { color: 'text-bambu-gray', label: `${diffDays} days left` }; } export function ProjectDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [showEditModal, setShowEditModal] = useState(false); const [editingNotes, setEditingNotes] = useState(false); const [notesContent, setNotesContent] = useState(''); const [uploadingAttachment, setUploadingAttachment] = useState(false); const fileInputRef = useRef(null); 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 currency = settings?.currency || '$'; 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('Project updated', '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(''); }; const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setUploadingAttachment(true); try { const result = await api.uploadProjectAttachment(projectId, file); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); showToast(`Uploaded: ${result.original_name}`, 'success'); } catch (error) { showToast((error as Error).message, 'error'); } finally { setUploadingAttachment(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; const handleDeleteAttachment = (filename: string, originalName: string) => { setConfirmModal({ isOpen: true, title: 'Delete Attachment', message: `Are you sure you want to delete "${originalName}"?`, onConfirm: async () => { setConfirmModal(prev => ({ ...prev, isOpen: false })); try { await api.deleteProjectAttachment(projectId, filename); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); showToast('Attachment deleted', 'success'); } catch (error) { showToast((error as Error).message, 'error'); } }, }); }; const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; // 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); // 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('Part added', 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const updateBomMutation = useMutation({ mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) => api.updateBOMItem(projectId, itemId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] }); queryClient.invalidateQueries({ queryKey: ['project', projectId] }); }, 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('Part removed', '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: 'Delete Part', message: `Are you sure you want to delete "${itemName}"?`, onConfirm: () => { setConfirmModal(prev => ({ ...prev, isOpen: false })); deleteBomMutation.mutate(itemId); }, }); }; // Template handlers const createTemplateMutation = useMutation({ mutationFn: () => api.createTemplateFromProject(projectId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); showToast('Template created', 'success'); }, onError: (error: Error) => showToast(error.message, 'error'), }); const formatTimelineDate = (timestamp: string) => { const date = new Date(timestamp); return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); }; if (projectLoading) { return (
); } if (projectError || !project) { return (

{projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}

); } const stats = project.stats; const progressPercent = stats?.progress_percent ?? 0; const successRate = stats && stats.total_archives > 0 ? ((stats.completed_prints / stats.total_archives) * 100).toFixed(0) : null; return (
{/* Breadcrumb */}
Projects {project.name}
{/* Header */}

{project.name}

{project.description && (

{project.description}

)}
{/* Progress bar (if target set) */} {project.target_count && (
Progress {stats?.completed_prints || 0} / {project.target_count} prints
= 100 ? '#22c55e' : project.color || '#6b7280', }} />
{progressPercent.toFixed(0)}% complete {project.target_count - (stats?.completed_prints || 0) > 0 && ( {project.target_count - (stats?.completed_prints || 0)} remaining )}
)} {/* Stats grid */} {stats && (
0 ? `${stats.failed_prints} failed` : undefined} color="text-blue-400" />
)} {/* Cost tracking */} {stats && (stats.estimated_cost > 0 || project.budget) && (

Cost Tracking

Filament Cost

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

{stats.total_energy_kwh > 0 && (

Energy

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

)} {project.budget && ( <>

Budget

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

Remaining

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

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

Sub-projects ({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 && (
Part of: {project.parent_name}
)} {/* Meta info row - Tags, Due Date, Priority */} {(project.tags || project.due_date || project.priority !== 'normal') && (
{/* Priority */} {project.priority && project.priority !== 'normal' && (
Priority:
)} {/* Due Date */} {project.due_date && (
{formatDate(project.due_date)} {getDueDateStatus(project.due_date) && ( ({getDueDateStatus(project.due_date)!.label}) )}
)} {/* Tags */} {project.tags && (
{project.tags.split(',').map((tag, index) => ( {tag.trim()} ))}
)}
)} {/* Notes section */}

Notes

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

No notes yet. Click Edit to add notes.

)} {/* Attachments section */}

Attachments ({project.attachments?.length || 0})

Upload any file: images (PNG, JPG), PDFs, STL files, or documents.

{project.attachments && project.attachments.length > 0 ? (
{project.attachments.map((attachment) => (

{attachment.original_name}

{formatFileSize(attachment.size)}

))}
) : (

No attachments yet. Click Upload to add files.

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

Bill of Materials {stats && stats.bom_total_items > 0 && ( ({stats.bom_completed_items}/{stats.bom_total_items} acquired) )}

{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="Part name (e.g., M3x8 screws)" 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="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={`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="Sourcing URL (optional)" /> 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="Remarks (optional)" />
)} {bomLoading ? (
) : bomItems && bomItems.length > 0 ? (
{bomItems .filter(item => !hideBomCompleted || !item.is_complete) .map((item) => (

{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) && (
Total cost: {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
)}
) : (

No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.

)}
{/* Timeline Section */}

Activity Timeline

{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)}

))}
) : (

No activity yet.

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

Queue

View all
{stats.in_progress_prints > 0 && ( {stats.in_progress_prints} printing )} {stats.queued_prints > 0 && ( {stats.queued_prints} queued )}
)} {/* Archives section */}

Prints ({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 }))} /> )}
); }