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 ? (

) : (
)}
{/* 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' && (
)}
{/* 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 */}
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 && (
)}
{bomLoading ? (
) : bomItems && bomItems.length > 0 ? (
{bomItems
.filter(item => !hideBomCompleted || !item.is_complete)
.map((item) => (
))}
{/* 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 }))}
/>
)}
);
}