import { useState } 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,
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`;
}
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,
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 }: { 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 '';
return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
}
function getDueDateStatus(dateString: string | null): { 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: '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 { 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('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('');
};
// 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('Part added', '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('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);
},
});
};
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('Export failed');
}
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('Project exported', 'success');
} catch (error) {
showToast((error as Error).message, 'error');
}
};
// 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) => {
return formatDateTime(timestamp, timeFormat, {
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;
// 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 */}
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 && (
Plates Progress
{stats?.total_archives || 0} / {project.target_count} print jobs
= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
{platesProgressPercent.toFixed(0)}% complete
{stats?.remaining_prints != null && stats.remaining_prints > 0 && (
{stats.remaining_prints} remaining
)}
)}
{/* Parts progress */}
{project.target_parts_count && (
Parts Progress
{stats?.completed_prints || 0} / {project.target_parts_count} parts
= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
{partsProgressPercent.toFixed(0)}% complete
{stats?.remaining_parts != null && stats.remaining_parts > 0 && (
{stats.remaining_parts} remaining
)}
)}
)}
{/* Stats grid */}
{stats && (
Print Jobs
{stats.total_archives} total
{stats.failed_prints > 0 && (
{stats.failed_prints} failed
)}
{stats.completed_prints} parts printed
)}
{/* 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.
)}
{/* Files section - linked folders from File Manager */}
Files
Link folders from the File Manager
{' '}to this project for quick access.
{linkedFolders && linkedFolders.length > 0 ? (
{linkedFolders.map((folder) => (
{folder.name}
{folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
))}
) : (
No folders linked. Go to File Manager and link a folder to this project.
)}
{/* 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) => (
{editingBomItem?.id === item.id ? (
// Edit form for this BOM item
) : (
// 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) && (
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 }))}
/>
)}
);
}