Files
bambuddy/frontend/src/pages/ProjectDetailPage.tsx
lietschaend f95b2acd7d Feature: print files directly from project view (closes #930) (#932)
* feat: print files directly from project view (closes #930)

Show printable files from linked library folders directly in the project
detail page, with Print Now and Add to Queue buttons per file. Removes
the detour through the File Manager for common reprint workflows.
2026-04-10 12:54:27 +02:00

1460 lines
61 KiB
TypeScript

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,
Play,
CalendarPlus,
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';
// Returns true for sliced (printable) files: .gcode and .gcode.3mf
function isSlicedFilename(filename: string): boolean {
const lower = filename.toLowerCase();
return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
}
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-500/20 text-blue-400',
archived: 'bg-bambu-gray/20 text-bambu-gray',
};
const color = colors[status as keyof typeof colors] || colors.active;
const labels: Record<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-500/20 text-blue-400', label: t('projectDetail.priority.normal') },
high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') },
urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') },
};
const { color, label } = config[priority as keyof typeof config] || config.normal;
return (
<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-400', label: t('projectDetail.dueDate.overdue') };
if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') };
if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
}
export function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [showEditModal, setShowEditModal] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesContent, setNotesContent] = useState('');
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
const [scheduleFile, setScheduleFile] = 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]);
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) && (
<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>
)}
</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-400"
/>
<StatCard
icon={Printer}
label={t('projectDetail.stats.filamentUsed')}
value={formatFilament(stats.total_filament_grams)}
color="text-purple-400"
/>
</div>
)}
{/* 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-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 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: child.color || '#6b7280' }}
/>
<span className="text-white">{child.name}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
'bg-blue-500/20 text-blue-400'
}`}>
{child.status}
</span>
</div>
{child.progress_percent !== null && (
<span className="text-sm text-bambu-gray">
{child.progress_percent.toFixed(0)}%
</span>
)}
</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 = isSlicedFilename(file.filename);
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' ? 'bg-blue-500/20 text-blue-400'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{file.file_type.toUpperCase()}
</span>
</div>
{/* Print actions for sliced files */}
{printable && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => setPrintFile(file)}
title={t('projectDetail.files.print')}
className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
>
<Play className="w-4 h-4" />
</button>
<button
onClick={() => setScheduleFile(file)}
title={t('projectDetail.files.addToQueue')}
className="p-1.5 rounded hover:bg-blue-500/20 text-blue-400 transition-colors"
>
<CalendarPlus 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-400 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-500/20 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-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,
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 directly from project — reprint mode */}
{printFile && (
<PrintModal
mode="reprint"
libraryFileId={printFile.id}
archiveName={printFile.print_name || printFile.filename}
projectId={projectId}
onClose={() => setPrintFile(null)}
onSuccess={() => {
setPrintFile(null);
queryClient.invalidateQueries({ queryKey: ['archives'] });
}}
/>
)}
{/* Add to queue from project */}
{scheduleFile && (
<PrintModal
mode="add-to-queue"
libraryFileId={scheduleFile.id}
archiveName={scheduleFile.print_name || scheduleFile.filename}
projectId={projectId}
onClose={() => setScheduleFile(null)}
onSuccess={() => {
setScheduleFile(null);
queryClient.invalidateQueries({ queryKey: ['queue'] });
}}
/>
)}
</div>
);
}