| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459 |
- 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>
- );
- }
|