Files
bambuddy/frontend/src/pages/ProjectDetailPage.tsx
T
maziggy 2e405afcd1 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
File Manager as a source-only project with no Print button, seemingly at
random.

Nothing was ever lost from the file. The download serves the stored
bytes verbatim and the G-code was still in the zip; the two sides simply
asked different questions. Archives looked inside the file. The library
looked at the filename. So a sliced 3MF stored as Foo.3mf rather than
Foo.gcode.3mf earned the badge and lost the Print button, and which one
you got depended on how the print had reached the printer -- a slicer's
LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched
print does not.

Both sides now ask one shared predicate about the zip itself, and every
route into the library classifies on content. Only the central directory
is read, and only when the name has not already settled it, so ingest
costs nothing extra -- the external scan opens each 3MF for its
thumbnail regardless. Rows already stored are re-checked once, internal
ones only: an external row points at a mount that may be slow or absent,
and startup is the worst place to discover that.

The Slice action moves with it. Its refusal to slice an output was as
name-bound as the Print gate, and without that a file that correctly
gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:14:26 +02:00

1609 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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,
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, 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';
import { isSlicedLibraryFile } from '../utils/libraryFiles';
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, unknown>) => string;
function StatusBadge({ status, t }: { status: string; t: TFunction }) {
const colors = {
active: 'bg-bambu-green/20 text-bambu-green',
completed: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
archived: 'bg-bambu-gray/20 text-bambu-gray',
};
const color = colors[status as keyof typeof colors] || colors.active;
const labels: Record<string, string> = {
active: t('projectDetail.status.active'),
completed: t('projectDetail.status.completed'),
archived: t('projectDetail.status.archived'),
};
return (
<span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
{labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
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 (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3" title={hint}>
<div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-sm text-bambu-gray">{label}</p>
<p className="text-xl font-semibold text-white">{value}</p>
{subValue && <p className="text-xs text-bambu-gray/70">{subValue}</p>}
</div>
</div>
</CardContent>
</Card>
);
}
function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
if (archives.length === 0) {
return (
<div className="text-center py-8 text-bambu-gray">
<Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>{t('projectDetail.noPrints')}</p>
</div>
);
}
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{archives.map((archive) => (
<Link
key={archive.id}
to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
>
{archive.thumbnail_path ? (
<img
src={api.getArchiveThumbnail(archive.id)}
alt={archive.print_name || 'Print'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Package className="w-8 h-8" />
</div>
)}
{/* Status overlay */}
{archive.status === 'failed' && (
<div className="absolute inset-0 bg-red-500/30 flex items-center justify-center">
<XCircle className="w-8 h-8 text-white" />
</div>
)}
{archive.status === 'completed' && (
<div className="absolute top-1 right-1">
<CheckCircle className="w-4 h-4 text-bambu-green" />
</div>
)}
{/* Name overlay on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-xs text-white truncate">{archive.print_name || 'Unknown'}</p>
</div>
</Link>
))}
</div>
);
}
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-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400', label: t('projectDetail.priority.normal') },
high: { color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400', label: t('projectDetail.priority.high') },
urgent: { color: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400', label: t('projectDetail.priority.urgent') },
};
const { color, label } = config[priority as keyof typeof config] || config.normal;
return (
<span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>
{priority === 'urgent' && <AlertTriangle className="w-3 h-3" />}
{label}
</span>
);
}
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-700 dark:text-red-400', label: t('projectDetail.dueDate.overdue') };
if (diffDays === 0) return { color: 'text-orange-700 dark:text-orange-400', label: t('projectDetail.dueDate.today') };
if (diffDays <= 3) return { color: 'text-yellow-700 dark: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 [printFile, setPrintFile] = useState<LibraryFileListItem | null>(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 { data: linkedFolders } = useQuery({
queryKey: ['project-folders', projectId],
queryFn: () => api.getLibraryFoldersByProject(projectId),
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<number, LibraryFileListItem[]>();
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]);
// Per-file completed-run counts (#1897); a file absent from the response has 0
const { data: fileProgress } = useQuery({
queryKey: ['project-file-progress', projectId],
queryFn: () => api.getProjectFileProgress(projectId),
enabled: projectId > 0,
});
const progressByFileId = useMemo(() => {
const map = new Map<number, number>();
for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count);
return map;
}, [fileProgress]);
// Complete sets (#1897): the number of finished assemblies — the minimum
// completed count across the project's printable files, capped at the target.
const completeSets = useMemo(() => {
const target = project?.target_sets;
if (!target || !allProjectFiles) return null;
const printable = allProjectFiles.filter((f) => isSlicedLibraryFile(f));
if (printable.length === 0) return null;
return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
}, [project?.target_sets, allProjectFiles, progressByFileId]);
const currency = getCurrencySymbol(settings?.currency || 'USD');
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<BOMItem | null>(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 {
const { blob, filename } = await api.exportProjectZip(Number(projectId));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `${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 (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
if (projectError || !project) {
return (
<div className="text-center py-24">
<p className="text-bambu-gray">
{projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
</p>
<Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
{t('projectDetail.backToProjects')}
</Button>
</div>
);
}
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 (
<div className="p-4 md:p-8 space-y-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Link to="/projects" className="hover:text-white transition-colors">
{t('nav.projects')}
</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{project.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/projects')}
className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
>
<ArrowLeft className="w-5 h-5 text-bambu-gray" />
</button>
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full shrink-0"
style={{ backgroundColor: project.color || '#6b7280' }}
/>
<div>
<h1 className="text-2xl font-bold text-white">{project.name}</h1>
{project.description && (
<p className="text-bambu-gray mt-1">{project.description}</p>
)}
</div>
</div>
<StatusBadge status={project.status} t={t} />
</div>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleExportProject}
disabled={!hasPermission('projects:read')}
title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
>
<Download className="w-4 h-4 mr-2" />
{t('projectDetail.export')}
</Button>
<Button
onClick={() => setShowEditModal(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
>
<Edit3 className="w-4 h-4 mr-2" />
{t('common.edit')}
</Button>
</div>
</div>
{/* Progress bars (if targets set) */}
{(project.target_count || project.target_parts_count || project.target_sets) && (
<Card>
<CardContent className="p-4 space-y-4">
{/* Plates progress */}
{project.target_count && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
<span className="text-sm font-medium text-white">
{stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(platesProgressPercent, 100)}%`,
backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-bambu-gray/70">
{t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
</span>
{stats?.remaining_prints != null && stats.remaining_prints > 0 && (
<span className="text-xs text-bambu-gray/70">
{t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
</span>
)}
</div>
</div>
)}
{/* Parts progress */}
{project.target_parts_count && (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
<span className="text-sm font-medium text-white">
{stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(partsProgressPercent, 100)}%`,
backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
<div className="flex justify-between mt-1">
<span className="text-xs text-bambu-gray/70">
{t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
</span>
{stats?.remaining_parts != null && stats.remaining_parts > 0 && (
<span className="text-xs text-bambu-gray/70">
{t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
</span>
)}
</div>
</div>
)}
{/* Complete sets progress (#1897): min per-file completed count */}
{project.target_sets ? (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">{t('projectDetail.progress.setsProgress')}</span>
<span className="text-sm font-medium text-white">
{completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(((completeSets ?? 0) / project.target_sets) * 100, 100)}%`,
backgroundColor: (completeSets ?? 0) >= project.target_sets ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
<p className="text-xs text-bambu-gray/70 mt-1">{t('projectDetail.progress.setsHint')}</p>
</div>
) : null}
</CardContent>
</Card>
)}
{/* Stats grid */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
<Package className="w-5 h-5" />
</div>
<div>
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
<p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">{t('projectDetail.stats.total')}</span></p>
{stats.failed_prints > 0 && (
<p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
)}
<p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
</div>
</div>
</CardContent>
</Card>
<StatCard
icon={Clock}
label={t('projectDetail.stats.printTime')}
value={formatDurationFromHours(stats.total_print_time_hours)}
color="text-yellow-600 dark:text-yellow-400"
/>
<StatCard
icon={Printer}
label={t('projectDetail.stats.filamentUsed')}
value={formatFilament(stats.total_filament_grams)}
color="text-purple-600 dark:text-purple-400"
/>
</div>
)}
{/* #1264: the whole programme in one place. Deliberately a separate card
from the stats grid above — the figures there are this project's own
prints, and the two must not read as one set. The API sends
rollup_stats only when there is a sub-project, so there is never an
identical pair on screen. */}
{project.rollup_stats && (
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
<FolderTree className="w-5 h-5" />
{t('projectDetail.rollup.title', { count: project.descendant_count })}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printJobs')}</p>
<p className="text-lg font-semibold text-white">{project.rollup_stats.total_archives}</p>
<p className="text-sm text-bambu-gray">
{t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })}
</p>
</div>
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printTime')}</p>
<p className="text-lg font-semibold text-white">
{formatDurationFromHours(project.rollup_stats.total_print_time_hours)}
</p>
</div>
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.filamentUsed')}</p>
<p className="text-lg font-semibold text-white">
{formatFilament(project.rollup_stats.total_filament_grams)}
</p>
</div>
{(() => {
const rollupCost =
project.rollup_stats.estimated_cost +
project.rollup_stats.total_energy_cost +
project.rollup_stats.bom_cost;
if (rollupCost <= 0) return null;
return (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
<p className="text-lg font-semibold text-bambu-green">
{currency}{rollupCost.toFixed(2)}
</p>
</div>
);
})()}
</div>
{project.rollup_stats.progress_percent !== null && (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-bambu-gray">{t('projectDetail.rollup.progress')}</span>
<span className="text-sm font-medium text-white">
{t('projectDetail.rollup.percentComplete', {
percent: project.rollup_stats.progress_percent.toFixed(0),
})}
</span>
</div>
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
<div
className="h-full transition-all duration-500"
style={{
width: `${Math.min(project.rollup_stats.progress_percent, 100)}%`,
backgroundColor:
project.rollup_stats.progress_percent >= 100 ? '#22c55e' : project.color || '#6b7280',
}}
/>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Cost tracking */}
{stats && (() => {
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
})() && (
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white mb-3">
{t('projectDetail.cost.title')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
<p className="text-lg font-semibold text-white">
{currency}{stats.estimated_cost.toFixed(2)}
</p>
</div>
{stats.total_energy_kwh > 0 && (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
<p className="text-lg font-semibold text-white">
{stats.total_energy_kwh.toFixed(3)} kWh
{stats.total_energy_cost > 0 && (
<span className="text-sm text-bambu-gray ml-1">
({currency}{stats.total_energy_cost.toFixed(2)})
</span>
)}
</p>
</div>
)}
{(() => {
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
if (totalCost <= 0) return null;
return (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
<p className="text-lg font-semibold text-bambu-green">
{currency}{totalCost.toFixed(2)}
</p>
{stats.bom_cost > 0 && (
<p className="text-xs text-bambu-gray/70">{t('projectDetail.cost.includesBom')}</p>
)}
</div>
);
})()}
{project.budget !== null && (() => {
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
const remaining = project.budget - totalCost;
return (
<div>
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
<p className="text-sm text-bambu-gray">
{t('projectDetail.cost.total')}: <span className="text-white font-semibold">{currency}{project.budget.toFixed(2)}</span>
</p>
<p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-700 dark:text-red-400'}`}>
{t('projectDetail.cost.remaining')}: <span className="font-semibold">{currency}{remaining.toFixed(2)}</span>
</p>
</div>
);
})()}
</div>
</CardContent>
</Card>
)}
{/* Sub-projects */}
{project.children && project.children.length > 0 && (
<Card>
<CardContent className="p-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
<FolderTree className="w-5 h-5" />
{t('projectDetail.subProjects.title', { count: project.children.length })}
</h2>
<div className="space-y-2">
{project.children.map((child) => (
<Link
key={child.id}
to={`/projects/${child.id}`}
className="flex items-center justify-between gap-4 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: child.color || '#6b7280' }}
/>
<span className="text-white truncate">{child.name}</span>
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
}`}>
{child.status}
</span>
{/* A branch of its own, so its figures cover more than the
one project named on this row (#1264). */}
{child.descendant_count > 0 && (
<span className="text-xs text-bambu-gray flex-shrink-0 inline-flex items-center gap-1">
<FolderTree className="w-3 h-3" />
{child.descendant_count}
</span>
)}
</div>
{/* Each row carries its own branch's roll-up, so the rows add
up to the card above minus this project's own prints. */}
<div className="flex items-center gap-4 text-sm text-bambu-gray flex-shrink-0">
{child.total_archives > 0 && (
<span className="hidden sm:inline">
{t('projectDetail.subProjects.jobs', { count: child.total_archives })}
</span>
)}
{child.total_filament_grams > 0 && (
<span className="hidden md:inline">{formatFilament(child.total_filament_grams)}</span>
)}
{child.total_cost > 0 && (
<span className="hidden md:inline">{currency}{child.total_cost.toFixed(2)}</span>
)}
{child.progress_percent !== null && (
<span>{child.progress_percent.toFixed(0)}%</span>
)}
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Parent project link */}
{project.parent_id && project.parent_name && (
<div className="flex items-center gap-2 text-sm">
<Layers className="w-4 h-4 text-bambu-gray" />
<span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
<Link
to={`/projects/${project.parent_id}`}
className="text-bambu-green hover:underline"
>
{project.parent_name}
</Link>
</div>
)}
{/* Meta info row - Tags, Due Date, Priority */}
{(project.tags || project.due_date || project.priority !== 'normal') && (
<div className="flex flex-wrap items-center gap-4">
{/* Priority */}
{project.priority && project.priority !== 'normal' && (
<div className="flex items-center gap-2">
<span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
<PriorityBadge priority={project.priority} t={t} />
</div>
)}
{/* Due Date */}
{project.due_date && (
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
{getDueDateStatus(project.due_date, t) && (
<span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
({getDueDateStatus(project.due_date, t)!.label})
</span>
)}
</div>
)}
{/* Tags */}
{project.tags && (
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-bambu-gray" />
<div className="flex flex-wrap gap-1">
{project.tags.split(',').map((tag, index) => (
<span
key={index}
className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
>
{tag.trim()}
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Notes section */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('projectDetail.notes.title')}
</h2>
{!editingNotes ? (
<Button
variant="secondary"
size="sm"
onClick={handleStartEditNotes}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
>
<Edit3 className="w-4 h-4 mr-1" />
{t('common.edit')}
</Button>
) : (
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={handleCancelNotes}
disabled={updateMutation.isPending}
>
<X className="w-4 h-4 mr-1" />
{t('common.cancel')}
</Button>
<Button
size="sm"
onClick={handleSaveNotes}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
{t('common.save')}
</Button>
</div>
)}
</div>
{editingNotes ? (
<RichTextEditor
content={notesContent}
onChange={setNotesContent}
placeholder={t('projectDetail.notes.placeholder')}
/>
) : project.notes ? (
<div
className="prose prose-invert prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(project.notes) }}
/>
) : (
<p className="text-bambu-gray/70 text-sm italic">
{t('projectDetail.notes.empty')}
</p>
)}
</CardContent>
</Card>
{/* Files section - linked folders from File Manager with printable files */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5" />
{t('projectDetail.files.title')}
</h2>
</div>
<p className="text-xs text-bambu-gray mb-3">
<Link to="/files" className="text-bambu-green hover:underline">
{t('projectDetail.files.linkFolders')}
</Link>
{' '}{t('projectDetail.files.forQuickAccess')}
</p>
{linkedFolders && linkedFolders.length > 0 ? (
<div className="space-y-4">
{linkedFolders.map((folder) => {
const files = filesByFolder.get(folder.id) ?? [];
const isLoading = projectFilesLoading;
return (
<div key={folder.id}>
{/* Folder header — links to File Manager */}
<Link
to={`/files?folder=${folder.id}`}
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
>
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
<div className="min-w-0">
<p className="text-sm text-white truncate">{folder.name}</p>
<p className="text-xs text-bambu-gray">
{t('projectDetail.files.fileCount', { count: folder.file_count })}
</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
</Link>
{/* File list within the folder */}
{isLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
) : files.length === 0 ? (
<p className="text-bambu-gray/60 text-xs italic px-3">
{t('projectDetail.files.noFiles')}
</p>
) : (
<div className="space-y-1 pl-3">
{files.map((file) => {
const printable = isSlicedLibraryFile(file);
return (
<div
key={file.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
{/* Thumbnail */}
<div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
{file.thumbnail_path ? (
<img
src={api.getLibraryFileThumbnailUrl(file.id)}
alt={file.print_name || file.filename}
className="w-full h-full object-cover"
/>
) : (
<FileBox className="w-5 h-5 text-bambu-gray/40" />
)}
</div>
{/* Name + type badge */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate" title={file.print_name || file.filename}>
{file.print_name || file.filename}
</p>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
: (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{file.file_type.toUpperCase()}
</span>
</div>
{/* Per-file print progress (#1897) */}
{printable && (() => {
const done = progressByFileId.get(file.id) ?? 0;
const target = project.target_sets;
if (!target) {
// No copies-per-file target — show a plain printed-count badge
return done > 0 ? (
<span
className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
title={t('projectDetail.files.printedCount', { count: done })}
>
{done}×
</span>
) : null;
}
const pct = Math.min((done / target) * 100, 100);
const textColor =
done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
const barColor =
done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
return (
<div
className="shrink-0 w-20"
title={t('projectDetail.files.progressTooltip', { done, target })}
>
<p className={`text-xs font-medium text-right ${textColor}`}>
{done} / {target}
</p>
<div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
<div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})()}
{/* Print actions for sliced files */}
{printable && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setPrintFile(file)}
title={t('common.print')}
className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
>
<Printer className="w-4 h-4" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
{t('projectDetail.files.empty')}
</p>
)}
</CardContent>
</Card>
{/* BOM Section - Parts to source/purchase */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<ShoppingCart className="w-5 h-5" />
{t('projectDetail.bom.title')}
{stats && stats.bom_total_items > 0 && (
<span className="text-sm font-normal text-bambu-gray">
({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
</span>
)}
</h2>
<div className="flex items-center gap-2">
{bomItems && bomItems.some(item => item.is_complete) && (
<button
onClick={() => setHideBomCompleted(!hideBomCompleted)}
className={`text-xs px-2 py-1 rounded transition-colors ${
hideBomCompleted
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
}`}
>
{hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
</button>
)}
{!showBomForm && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowBomForm(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
>
<Plus className="w-4 h-4 mr-1" />
{t('projectDetail.bom.addPart')}
</Button>
)}
</div>
</div>
{/* Add BOM item form */}
{showBomForm && (
<form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
value={newBomName}
onChange={(e) => 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
/>
<div className="flex gap-2">
<input
type="number"
value={newBomQty}
onChange={(e) => 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')}
/>
<input
type="number"
step="0.01"
value={newBomPrice}
onChange={(e) => 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 })}
/>
</div>
</div>
<input
type="url"
value={newBomUrl}
onChange={(e) => 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')}
/>
<input
type="text"
value={newBomRemarks}
onChange={(e) => 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')}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
{t('common.cancel')}
</Button>
<Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
{createBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t('projectDetail.bom.addPart')
)}
</Button>
</div>
</form>
)}
{bomLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : bomItems && bomItems.length > 0 ? (
<div className="space-y-2">
{bomItems
.filter(item => !hideBomCompleted || !item.is_complete)
.map((item) => (
<div
key={item.id}
className={`p-3 rounded-lg transition-colors ${
item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
}`}
>
{editingBomItem?.id === item.id ? (
// Edit form for this BOM item
<form onSubmit={handleSaveBomEdit} className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
value={editBomName}
onChange={(e) => 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
/>
<div className="flex gap-2">
<input
type="number"
value={editBomQty}
onChange={(e) => 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')}
/>
<input
type="number"
step="0.01"
value={editBomPrice}
onChange={(e) => 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 })}
/>
</div>
</div>
<input
type="url"
value={editBomUrl}
onChange={(e) => 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')}
/>
<input
type="text"
value={editBomRemarks}
onChange={(e) => 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')}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
{t('common.cancel')}
</Button>
<Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
{updateBomMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t('common.save')
)}
</Button>
</div>
</form>
) : (
// Display mode
<div className="flex items-start gap-3">
<button
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 shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
: hasPermission('projects:update')
? 'border-bambu-gray hover:border-bambu-green'
: 'border-bambu-gray/50 cursor-not-allowed'
}`}
>
{item.is_complete && <CheckCircle className="w-3 h-3" />}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
{item.name}
<span className="text-bambu-gray font-normal ml-2">
x{item.quantity_needed}
</span>
</p>
{item.unit_price !== null && (
<span className="text-xs text-bambu-green whitespace-nowrap">
{currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Sourcing URL */}
{item.sourcing_url && (
<a
href={item.sourcing_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-1 text-xs text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3 h-3 shrink-0" />
<span className="truncate">
{(() => {
try {
return new URL(item.sourcing_url).hostname.replace('www.', '');
} catch {
return item.sourcing_url;
}
})()}
</span>
</a>
)}
{/* Remarks */}
{item.remarks && (
<p className="mt-1 text-xs text-bambu-gray/80 italic">
{item.remarks}
</p>
)}
</div>
</div>
)}
</div>
))}
{/* BOM Total */}
{stats && stats.bom_cost > 0 && (
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
<span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
<span className="text-white font-medium">
{currency}{stats.bom_cost.toFixed(2)}
</span>
</div>
)}
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
{t('projectDetail.bom.empty')}
</p>
)}
</CardContent>
</Card>
{/* Timeline Section */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<History className="w-5 h-5" />
{t('projectDetail.timeline.title')}
</h2>
</div>
{timelineLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : timeline && timeline.length > 0 ? (
<div className="space-y-3">
{timeline.slice(0, 10).map((event, index) => (
<div key={index} className="flex gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
event.event_type === 'print_started' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
{event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
{event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
{event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
{event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{event.title}</p>
{event.description && (
<p className="text-xs text-bambu-gray truncate">{event.description}</p>
)}
<p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-bambu-gray/70 text-sm italic">
{t('projectDetail.timeline.empty')}
</p>
)}
</CardContent>
</Card>
{/* Template action */}
{!project.is_template && (
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => createTemplateMutation.mutate()}
disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
>
{createTemplateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Copy className="w-4 h-4 mr-2" />
)}
{t('projectDetail.template.saveAsTemplate')}
</Button>
</div>
)}
{/* Queue section */}
{stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<ListTodo className="w-5 h-5" />
{t('projectDetail.queue.title')}
</h2>
<Link
to={`/queue?project=${projectId}`}
className="text-sm text-bambu-green hover:underline"
>
{t('projectDetail.queue.viewAll')}
</Link>
</div>
<div className="flex items-center gap-4 text-sm">
{stats.in_progress_prints > 0 && (
<span className="text-yellow-700 dark:text-yellow-400">
{t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
</span>
)}
{stats.queued_prints > 0 && (
<span className="text-bambu-gray">
{t('projectDetail.queue.queued', { count: stats.queued_prints })}
</span>
)}
</div>
</CardContent>
</Card>
)}
{/* Archives section */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Package className="w-5 h-5" />
{t('projectDetail.prints.title', { count: archives?.length || 0 })}
</h2>
</div>
{archivesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
</div>
) : (
<ArchiveGrid archives={archives || []} t={t} />
)}
</div>
{/* Edit Modal */}
{showEditModal && (
<ProjectModal
t={t}
currencySymbol={currency}
project={{
...project,
archive_count: stats?.total_archives || 0,
total_items: stats?.total_items || 0,
completed_count: stats?.completed_prints || 0,
failed_count: stats?.failed_prints || 0,
queue_count: stats?.queued_prints || 0,
progress_percent: stats?.progress_percent || null,
// Required by ProjectListItem, but nothing in the dialog reads it —
// the parent picker works off the project list it fetches itself.
// Guarded like every other read of `children` on this page (#1264).
child_count: project.children?.length ?? 0,
archives: [],
}}
onClose={() => setShowEditModal(false)}
onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
isLoading={updateMutation.isPending}
/>
)}
{/* Confirm Modal */}
{confirmModal.isOpen && (
<ConfirmModal
title={confirmModal.title}
message={confirmModal.message}
confirmText={t('common.delete')}
variant="danger"
onConfirm={confirmModal.onConfirm}
onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
/>
)}
{/* Print from project */}
{printFile && (
<PrintModal
mode="create"
libraryFileId={printFile.id}
archiveName={printFile.print_name || printFile.filename}
projectId={projectId}
onClose={() => setPrintFile(null)}
onSuccess={() => {
setPrintFile(null);
queryClient.invalidateQueries({ queryKey: ['archives'] });
queryClient.invalidateQueries({ queryKey: ['queue'] });
}}
/>
)}
</div>
);
}