ProjectDetailPage.tsx 70 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609
  1. import { useState, useMemo } from 'react';
  2. import DOMPurify from 'dompurify';
  3. import { useParams, useNavigate, Link } from 'react-router-dom';
  4. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  5. import { useTranslation } from 'react-i18next';
  6. import {
  7. ArrowLeft,
  8. Edit3,
  9. Loader2,
  10. Package,
  11. Clock,
  12. CheckCircle,
  13. XCircle,
  14. ListTodo,
  15. Printer,
  16. ChevronRight,
  17. FileText,
  18. Tag,
  19. Calendar,
  20. AlertTriangle,
  21. Save,
  22. X,
  23. Trash2,
  24. Plus,
  25. History,
  26. FolderTree,
  27. Copy,
  28. Layers,
  29. ExternalLink,
  30. ShoppingCart,
  31. FolderOpen,
  32. Download,
  33. Pencil,
  34. FileBox,
  35. } from 'lucide-react';
  36. import { api } from '../api/client';
  37. import { parseUTCDate, formatDateOnly, formatDateTime, formatDurationFromHours, type TimeFormat } from '../utils/date';
  38. import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate, LibraryFileListItem } from '../api/client';
  39. import { Card, CardContent } from '../components/Card';
  40. import { Button } from '../components/Button';
  41. import { useToast } from '../contexts/ToastContext';
  42. import { useAuth } from '../contexts/AuthContext';
  43. import { RichTextEditor } from '../components/RichTextEditor';
  44. import { ConfirmModal } from '../components/ConfirmModal';
  45. import { PrintModal } from '../components/PrintModal';
  46. // Project edit modal (reused from ProjectsPage)
  47. import { ProjectModal } from './ProjectsPage';
  48. import { getCurrencySymbol } from '../utils/currency';
  49. import { isSlicedLibraryFile } from '../utils/libraryFiles';
  50. import { NumberInput } from '../components/NumberInput';
  51. function formatFilament(grams: number): string {
  52. if (grams >= 1000) {
  53. return `${(grams / 1000).toFixed(2)}kg`;
  54. }
  55. return `${Math.round(grams)}g`;
  56. }
  57. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  58. function StatusBadge({ status, t }: { status: string; t: TFunction }) {
  59. const colors = {
  60. active: 'bg-bambu-green/20 text-bambu-green',
  61. completed: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400',
  62. archived: 'bg-bambu-gray/20 text-bambu-gray',
  63. };
  64. const color = colors[status as keyof typeof colors] || colors.active;
  65. const labels: Record<string, string> = {
  66. active: t('projectDetail.status.active'),
  67. completed: t('projectDetail.status.completed'),
  68. archived: t('projectDetail.status.archived'),
  69. };
  70. return (
  71. <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
  72. {labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}
  73. </span>
  74. );
  75. }
  76. function StatCard({
  77. icon: Icon,
  78. label,
  79. value,
  80. subValue,
  81. hint,
  82. color = 'text-bambu-gray',
  83. }: {
  84. icon: React.ElementType;
  85. label: string;
  86. value: string | number;
  87. subValue?: string;
  88. hint?: string;
  89. color?: string;
  90. }) {
  91. return (
  92. <Card>
  93. <CardContent className="p-4">
  94. <div className="flex items-center gap-3" title={hint}>
  95. <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
  96. <Icon className="w-5 h-5" />
  97. </div>
  98. <div>
  99. <p className="text-sm text-bambu-gray">{label}</p>
  100. <p className="text-xl font-semibold text-white">{value}</p>
  101. {subValue && <p className="text-xs text-bambu-gray/70">{subValue}</p>}
  102. </div>
  103. </div>
  104. </CardContent>
  105. </Card>
  106. );
  107. }
  108. function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
  109. if (archives.length === 0) {
  110. return (
  111. <div className="text-center py-8 text-bambu-gray">
  112. <Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
  113. <p>{t('projectDetail.noPrints')}</p>
  114. </div>
  115. );
  116. }
  117. return (
  118. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
  119. {archives.map((archive) => (
  120. <Link
  121. key={archive.id}
  122. to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
  123. className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
  124. >
  125. {archive.thumbnail_path ? (
  126. <img
  127. src={api.getArchiveThumbnail(archive.id)}
  128. alt={archive.print_name || 'Print'}
  129. className="w-full h-full object-cover"
  130. />
  131. ) : (
  132. <div className="w-full h-full flex items-center justify-center text-bambu-gray">
  133. <Package className="w-8 h-8" />
  134. </div>
  135. )}
  136. {/* Status overlay */}
  137. {archive.status === 'failed' && (
  138. <div className="absolute inset-0 bg-red-500/30 flex items-center justify-center">
  139. <XCircle className="w-8 h-8 text-white" />
  140. </div>
  141. )}
  142. {archive.status === 'completed' && (
  143. <div className="absolute top-1 right-1">
  144. <CheckCircle className="w-4 h-4 text-bambu-green" />
  145. </div>
  146. )}
  147. {/* Name overlay on hover */}
  148. <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">
  149. <p className="text-xs text-white truncate">{archive.print_name || 'Unknown'}</p>
  150. </div>
  151. </Link>
  152. ))}
  153. </div>
  154. );
  155. }
  156. function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
  157. const config = {
  158. low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') },
  159. normal: { color: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400', label: t('projectDetail.priority.normal') },
  160. high: { color: 'bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400', label: t('projectDetail.priority.high') },
  161. urgent: { color: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400', label: t('projectDetail.priority.urgent') },
  162. };
  163. const { color, label } = config[priority as keyof typeof config] || config.normal;
  164. return (
  165. <span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>
  166. {priority === 'urgent' && <AlertTriangle className="w-3 h-3" />}
  167. {label}
  168. </span>
  169. );
  170. }
  171. function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
  172. if (!dateString) return null;
  173. const dueDate = parseUTCDate(dateString);
  174. if (!dueDate) return null;
  175. const now = new Date();
  176. const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  177. if (diffDays < 0) return { color: 'text-red-700 dark:text-red-400', label: t('projectDetail.dueDate.overdue') };
  178. if (diffDays === 0) return { color: 'text-orange-700 dark:text-orange-400', label: t('projectDetail.dueDate.today') };
  179. if (diffDays <= 3) return { color: 'text-yellow-700 dark:text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
  180. return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
  181. }
  182. export function ProjectDetailPage() {
  183. const { id } = useParams<{ id: string }>();
  184. const navigate = useNavigate();
  185. const { t } = useTranslation();
  186. const queryClient = useQueryClient();
  187. const { showToast } = useToast();
  188. const { hasPermission } = useAuth();
  189. const [showEditModal, setShowEditModal] = useState(false);
  190. const [editingNotes, setEditingNotes] = useState(false);
  191. const [notesContent, setNotesContent] = useState('');
  192. const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
  193. const projectId = parseInt(id || '0', 10);
  194. const { data: project, isLoading: projectLoading, error: projectError } = useQuery({
  195. queryKey: ['project', projectId],
  196. queryFn: () => api.getProject(projectId),
  197. enabled: projectId > 0,
  198. });
  199. const { data: archives, isLoading: archivesLoading } = useQuery({
  200. queryKey: ['project-archives', projectId],
  201. queryFn: () => api.getProjectArchives(projectId),
  202. enabled: projectId > 0,
  203. });
  204. const { data: bomItems, isLoading: bomLoading } = useQuery({
  205. queryKey: ['project-bom', projectId],
  206. queryFn: () => api.getProjectBOM(projectId),
  207. enabled: projectId > 0,
  208. });
  209. const { data: timeline, isLoading: timelineLoading } = useQuery({
  210. queryKey: ['project-timeline', projectId],
  211. queryFn: () => api.getProjectTimeline(projectId, 20),
  212. enabled: projectId > 0,
  213. });
  214. const { data: settings } = useQuery({
  215. queryKey: ['settings'],
  216. queryFn: api.getSettings,
  217. });
  218. const { data: linkedFolders } = useQuery({
  219. queryKey: ['project-folders', projectId],
  220. queryFn: () => api.getLibraryFoldersByProject(projectId),
  221. enabled: projectId > 0,
  222. });
  223. // Single bulk query — replaces the previous N+1 useQueries pattern
  224. const { data: allProjectFiles, isLoading: projectFilesLoading } = useQuery({
  225. queryKey: ['project-files', projectId],
  226. queryFn: () => api.getLibraryFiles(null, false, projectId),
  227. enabled: projectId > 0,
  228. });
  229. // Group files by folder_id for the section-based render
  230. const filesByFolder = useMemo(() => {
  231. const map = new Map<number, LibraryFileListItem[]>();
  232. for (const file of allProjectFiles ?? []) {
  233. if (file.folder_id != null) {
  234. const arr = map.get(file.folder_id) ?? [];
  235. arr.push(file);
  236. map.set(file.folder_id, arr);
  237. }
  238. }
  239. return map;
  240. }, [allProjectFiles]);
  241. // Per-file completed-run counts (#1897); a file absent from the response has 0
  242. const { data: fileProgress } = useQuery({
  243. queryKey: ['project-file-progress', projectId],
  244. queryFn: () => api.getProjectFileProgress(projectId),
  245. enabled: projectId > 0,
  246. });
  247. const progressByFileId = useMemo(() => {
  248. const map = new Map<number, number>();
  249. for (const row of fileProgress ?? []) map.set(row.file_id, row.completed_count);
  250. return map;
  251. }, [fileProgress]);
  252. // Complete sets (#1897): the number of finished assemblies — the minimum
  253. // completed count across the project's printable files, capped at the target.
  254. const completeSets = useMemo(() => {
  255. const target = project?.target_sets;
  256. if (!target || !allProjectFiles) return null;
  257. const printable = allProjectFiles.filter((f) => isSlicedLibraryFile(f));
  258. if (printable.length === 0) return null;
  259. return Math.min(...printable.map((f) => Math.min(progressByFileId.get(f.id) ?? 0, target)));
  260. }, [project?.target_sets, allProjectFiles, progressByFileId]);
  261. const currency = getCurrencySymbol(settings?.currency || 'USD');
  262. const timeFormat: TimeFormat = settings?.time_format || 'system';
  263. const updateMutation = useMutation({
  264. mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
  265. onSuccess: () => {
  266. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  267. queryClient.invalidateQueries({ queryKey: ['projects'] });
  268. setShowEditModal(false);
  269. setEditingNotes(false);
  270. showToast(t('projectDetail.toast.projectUpdated'), 'success');
  271. },
  272. onError: (error: Error) => {
  273. showToast(error.message, 'error');
  274. },
  275. });
  276. const handleStartEditNotes = () => {
  277. setNotesContent(project?.notes || '');
  278. setEditingNotes(true);
  279. };
  280. const handleSaveNotes = () => {
  281. updateMutation.mutate({ notes: notesContent });
  282. };
  283. const handleCancelNotes = () => {
  284. setEditingNotes(false);
  285. setNotesContent('');
  286. };
  287. // BOM handlers
  288. const [newBomName, setNewBomName] = useState('');
  289. const [newBomQty, setNewBomQty] = useState(1);
  290. const [newBomPrice, setNewBomPrice] = useState('');
  291. const [newBomUrl, setNewBomUrl] = useState('');
  292. const [newBomRemarks, setNewBomRemarks] = useState('');
  293. const [showBomForm, setShowBomForm] = useState(false);
  294. const [hideBomCompleted, setHideBomCompleted] = useState(false);
  295. const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
  296. const [editBomName, setEditBomName] = useState('');
  297. const [editBomQty, setEditBomQty] = useState(1);
  298. const [editBomPrice, setEditBomPrice] = useState('');
  299. const [editBomUrl, setEditBomUrl] = useState('');
  300. const [editBomRemarks, setEditBomRemarks] = useState('');
  301. // Confirm modal state
  302. const [confirmModal, setConfirmModal] = useState<{
  303. isOpen: boolean;
  304. title: string;
  305. message: string;
  306. onConfirm: () => void;
  307. }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
  308. const createBomMutation = useMutation({
  309. mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
  310. onSuccess: () => {
  311. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  312. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  313. setNewBomName('');
  314. setNewBomQty(1);
  315. setNewBomPrice('');
  316. setNewBomUrl('');
  317. setNewBomRemarks('');
  318. setShowBomForm(false);
  319. showToast(t('projectDetail.toast.partAdded'), 'success');
  320. },
  321. onError: (error: Error) => showToast(error.message, 'error'),
  322. });
  323. const updateBomMutation = useMutation({
  324. mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
  325. api.updateBOMItem(projectId, itemId, data),
  326. onSuccess: () => {
  327. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  328. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  329. setEditingBomItem(null);
  330. },
  331. onError: (error: Error) => showToast(error.message, 'error'),
  332. });
  333. const deleteBomMutation = useMutation({
  334. mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),
  335. onSuccess: () => {
  336. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  337. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  338. showToast(t('projectDetail.toast.partRemoved'), 'success');
  339. },
  340. onError: (error: Error) => showToast(error.message, 'error'),
  341. });
  342. const handleAddBomItem = (e: React.FormEvent) => {
  343. e.preventDefault();
  344. if (!newBomName.trim()) return;
  345. createBomMutation.mutate({
  346. name: newBomName.trim(),
  347. quantity_needed: newBomQty,
  348. unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
  349. sourcing_url: newBomUrl.trim() || undefined,
  350. remarks: newBomRemarks.trim() || undefined,
  351. });
  352. };
  353. const handleToggleAcquired = (item: BOMItem) => {
  354. const newQty = item.is_complete ? 0 : item.quantity_needed;
  355. updateBomMutation.mutate({
  356. itemId: item.id,
  357. data: { quantity_acquired: newQty },
  358. });
  359. };
  360. const handleDeleteBomItem = (itemId: number, itemName: string) => {
  361. setConfirmModal({
  362. isOpen: true,
  363. title: t('projectDetail.bom.deletePart'),
  364. message: t('projectDetail.bom.deleteConfirm', { name: itemName }),
  365. onConfirm: () => {
  366. setConfirmModal(prev => ({ ...prev, isOpen: false }));
  367. deleteBomMutation.mutate(itemId);
  368. },
  369. });
  370. };
  371. const handleEditBomItem = (item: BOMItem) => {
  372. setEditingBomItem(item);
  373. setEditBomName(item.name);
  374. setEditBomQty(item.quantity_needed);
  375. setEditBomPrice(item.unit_price?.toString() || '');
  376. setEditBomUrl(item.sourcing_url || '');
  377. setEditBomRemarks(item.remarks || '');
  378. };
  379. const handleSaveBomEdit = (e: React.FormEvent) => {
  380. e.preventDefault();
  381. if (!editingBomItem || !editBomName.trim()) return;
  382. updateBomMutation.mutate({
  383. itemId: editingBomItem.id,
  384. data: {
  385. name: editBomName.trim(),
  386. quantity_needed: editBomQty,
  387. unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
  388. sourcing_url: editBomUrl.trim() || undefined,
  389. remarks: editBomRemarks.trim() || undefined,
  390. },
  391. });
  392. };
  393. const handleCancelBomEdit = () => {
  394. setEditingBomItem(null);
  395. };
  396. const handleExportProject = async () => {
  397. try {
  398. const { blob, filename } = await api.exportProjectZip(Number(projectId));
  399. const url = URL.createObjectURL(blob);
  400. const a = document.createElement('a');
  401. a.href = url;
  402. a.download = filename || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
  403. a.click();
  404. URL.revokeObjectURL(url);
  405. showToast(t('projectDetail.toast.projectExported'), 'success');
  406. } catch (error) {
  407. showToast((error as Error).message, 'error');
  408. }
  409. };
  410. // Template handlers
  411. const createTemplateMutation = useMutation({
  412. mutationFn: () => api.createTemplateFromProject(projectId),
  413. onSuccess: () => {
  414. queryClient.invalidateQueries({ queryKey: ['projects'] });
  415. showToast(t('projectDetail.toast.templateCreated'), 'success');
  416. },
  417. onError: (error: Error) => showToast(error.message, 'error'),
  418. });
  419. const formatTimelineDate = (timestamp: string) => {
  420. return formatDateTime(timestamp, timeFormat, {
  421. month: 'short',
  422. day: 'numeric',
  423. hour: '2-digit',
  424. minute: '2-digit',
  425. });
  426. };
  427. if (projectLoading) {
  428. return (
  429. <div className="flex items-center justify-center py-24">
  430. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  431. </div>
  432. );
  433. }
  434. if (projectError || !project) {
  435. return (
  436. <div className="text-center py-24">
  437. <p className="text-bambu-gray">
  438. {projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
  439. </p>
  440. <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
  441. {t('projectDetail.backToProjects')}
  442. </Button>
  443. </div>
  444. );
  445. }
  446. const stats = project.stats;
  447. // Plates progress: total_archives / target_count
  448. const platesProgressPercent = stats?.progress_percent ?? 0;
  449. // Parts progress: completed_prints / target_parts_count
  450. const partsProgressPercent = stats?.parts_progress_percent ?? 0;
  451. return (
  452. <div className="p-4 md:p-8 space-y-8">
  453. {/* Breadcrumb */}
  454. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  455. <Link to="/projects" className="hover:text-white transition-colors">
  456. {t('nav.projects')}
  457. </Link>
  458. <ChevronRight className="w-4 h-4" />
  459. <span className="text-white">{project.name}</span>
  460. </div>
  461. {/* Header */}
  462. <div className="flex items-start justify-between">
  463. <div className="flex items-center gap-4">
  464. <button
  465. onClick={() => navigate('/projects')}
  466. className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
  467. >
  468. <ArrowLeft className="w-5 h-5 text-bambu-gray" />
  469. </button>
  470. <div className="flex items-center gap-3">
  471. <div
  472. className="w-4 h-4 rounded-full shrink-0"
  473. style={{ backgroundColor: project.color || '#6b7280' }}
  474. />
  475. <div>
  476. <h1 className="text-2xl font-bold text-white">{project.name}</h1>
  477. {project.description && (
  478. <p className="text-bambu-gray mt-1">{project.description}</p>
  479. )}
  480. </div>
  481. </div>
  482. <StatusBadge status={project.status} t={t} />
  483. </div>
  484. <div className="flex gap-2">
  485. <Button
  486. variant="secondary"
  487. onClick={handleExportProject}
  488. disabled={!hasPermission('projects:read')}
  489. title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
  490. >
  491. <Download className="w-4 h-4 mr-2" />
  492. {t('projectDetail.export')}
  493. </Button>
  494. <Button
  495. onClick={() => setShowEditModal(true)}
  496. disabled={!hasPermission('projects:update')}
  497. title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
  498. >
  499. <Edit3 className="w-4 h-4 mr-2" />
  500. {t('common.edit')}
  501. </Button>
  502. </div>
  503. </div>
  504. {/* Progress bars (if targets set) */}
  505. {(project.target_count || project.target_parts_count || project.target_sets) && (
  506. <Card>
  507. <CardContent className="p-4 space-y-4">
  508. {/* Plates progress */}
  509. {project.target_count && (
  510. <div>
  511. <div className="flex items-center justify-between mb-2">
  512. <span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
  513. <span className="text-sm font-medium text-white">
  514. {stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
  515. </span>
  516. </div>
  517. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  518. <div
  519. className="h-full transition-all duration-500"
  520. style={{
  521. width: `${Math.min(platesProgressPercent, 100)}%`,
  522. backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
  523. }}
  524. />
  525. </div>
  526. <div className="flex justify-between mt-1">
  527. <span className="text-xs text-bambu-gray/70">
  528. {t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
  529. </span>
  530. {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
  531. <span className="text-xs text-bambu-gray/70">
  532. {t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
  533. </span>
  534. )}
  535. </div>
  536. </div>
  537. )}
  538. {/* Parts progress */}
  539. {project.target_parts_count && (
  540. <div>
  541. <div className="flex items-center justify-between mb-2">
  542. <span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
  543. <span className="text-sm font-medium text-white">
  544. {stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
  545. </span>
  546. </div>
  547. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  548. <div
  549. className="h-full transition-all duration-500"
  550. style={{
  551. width: `${Math.min(partsProgressPercent, 100)}%`,
  552. backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
  553. }}
  554. />
  555. </div>
  556. <div className="flex justify-between mt-1">
  557. <span className="text-xs text-bambu-gray/70">
  558. {t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
  559. </span>
  560. {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
  561. <span className="text-xs text-bambu-gray/70">
  562. {t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
  563. </span>
  564. )}
  565. </div>
  566. </div>
  567. )}
  568. {/* Complete sets progress (#1897): min per-file completed count */}
  569. {project.target_sets ? (
  570. <div>
  571. <div className="flex items-center justify-between mb-2">
  572. <span className="text-sm text-bambu-gray">{t('projectDetail.progress.setsProgress')}</span>
  573. <span className="text-sm font-medium text-white">
  574. {completeSets ?? 0} / {project.target_sets} {t('projectDetail.progress.sets')}
  575. </span>
  576. </div>
  577. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  578. <div
  579. className="h-full transition-all duration-500"
  580. style={{
  581. width: `${Math.min(((completeSets ?? 0) / project.target_sets) * 100, 100)}%`,
  582. backgroundColor: (completeSets ?? 0) >= project.target_sets ? '#22c55e' : project.color || '#6b7280',
  583. }}
  584. />
  585. </div>
  586. <p className="text-xs text-bambu-gray/70 mt-1">{t('projectDetail.progress.setsHint')}</p>
  587. </div>
  588. ) : null}
  589. </CardContent>
  590. </Card>
  591. )}
  592. {/* Stats grid */}
  593. {stats && (
  594. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  595. <Card>
  596. <CardContent className="p-4">
  597. <div className="flex items-center gap-3">
  598. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  599. <Package className="w-5 h-5" />
  600. </div>
  601. <div>
  602. <p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
  603. <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>
  604. {stats.failed_prints > 0 && (
  605. <p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
  606. )}
  607. <p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
  608. </div>
  609. </div>
  610. </CardContent>
  611. </Card>
  612. <StatCard
  613. icon={Clock}
  614. label={t('projectDetail.stats.printTime')}
  615. value={formatDurationFromHours(stats.total_print_time_hours)}
  616. color="text-yellow-600 dark:text-yellow-400"
  617. />
  618. <StatCard
  619. icon={Printer}
  620. label={t('projectDetail.stats.filamentUsed')}
  621. value={formatFilament(stats.total_filament_grams)}
  622. color="text-purple-600 dark:text-purple-400"
  623. />
  624. </div>
  625. )}
  626. {/* #1264: the whole programme in one place. Deliberately a separate card
  627. from the stats grid above — the figures there are this project's own
  628. prints, and the two must not read as one set. The API sends
  629. rollup_stats only when there is a sub-project, so there is never an
  630. identical pair on screen. */}
  631. {project.rollup_stats && (
  632. <Card>
  633. <CardContent className="p-4">
  634. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
  635. <FolderTree className="w-5 h-5" />
  636. {t('projectDetail.rollup.title', { count: project.descendant_count })}
  637. </h2>
  638. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  639. <div>
  640. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printJobs')}</p>
  641. <p className="text-lg font-semibold text-white">{project.rollup_stats.total_archives}</p>
  642. <p className="text-sm text-bambu-gray">
  643. {t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })}
  644. </p>
  645. </div>
  646. <div>
  647. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printTime')}</p>
  648. <p className="text-lg font-semibold text-white">
  649. {formatDurationFromHours(project.rollup_stats.total_print_time_hours)}
  650. </p>
  651. </div>
  652. <div>
  653. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.filamentUsed')}</p>
  654. <p className="text-lg font-semibold text-white">
  655. {formatFilament(project.rollup_stats.total_filament_grams)}
  656. </p>
  657. </div>
  658. {(() => {
  659. const rollupCost =
  660. project.rollup_stats.estimated_cost +
  661. project.rollup_stats.total_energy_cost +
  662. project.rollup_stats.bom_cost;
  663. if (rollupCost <= 0) return null;
  664. return (
  665. <div>
  666. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
  667. <p className="text-lg font-semibold text-bambu-green">
  668. {currency}{rollupCost.toFixed(2)}
  669. </p>
  670. </div>
  671. );
  672. })()}
  673. </div>
  674. {project.rollup_stats.progress_percent !== null && (
  675. <div className="mt-4">
  676. <div className="flex items-center justify-between mb-2">
  677. <span className="text-sm text-bambu-gray">{t('projectDetail.rollup.progress')}</span>
  678. <span className="text-sm font-medium text-white">
  679. {t('projectDetail.rollup.percentComplete', {
  680. percent: project.rollup_stats.progress_percent.toFixed(0),
  681. })}
  682. </span>
  683. </div>
  684. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  685. <div
  686. className="h-full transition-all duration-500"
  687. style={{
  688. width: `${Math.min(project.rollup_stats.progress_percent, 100)}%`,
  689. backgroundColor:
  690. project.rollup_stats.progress_percent >= 100 ? '#22c55e' : project.color || '#6b7280',
  691. }}
  692. />
  693. </div>
  694. </div>
  695. )}
  696. </CardContent>
  697. </Card>
  698. )}
  699. {/* Cost tracking */}
  700. {stats && (() => {
  701. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  702. return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
  703. })() && (
  704. <Card>
  705. <CardContent className="p-4">
  706. <h2 className="text-lg font-semibold text-white mb-3">
  707. {t('projectDetail.cost.title')}
  708. </h2>
  709. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  710. <div>
  711. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
  712. <p className="text-lg font-semibold text-white">
  713. {currency}{stats.estimated_cost.toFixed(2)}
  714. </p>
  715. </div>
  716. {stats.total_energy_kwh > 0 && (
  717. <div>
  718. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
  719. <p className="text-lg font-semibold text-white">
  720. {stats.total_energy_kwh.toFixed(3)} kWh
  721. {stats.total_energy_cost > 0 && (
  722. <span className="text-sm text-bambu-gray ml-1">
  723. ({currency}{stats.total_energy_cost.toFixed(2)})
  724. </span>
  725. )}
  726. </p>
  727. </div>
  728. )}
  729. {(() => {
  730. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  731. if (totalCost <= 0) return null;
  732. return (
  733. <div>
  734. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
  735. <p className="text-lg font-semibold text-bambu-green">
  736. {currency}{totalCost.toFixed(2)}
  737. </p>
  738. {stats.bom_cost > 0 && (
  739. <p className="text-xs text-bambu-gray/70">{t('projectDetail.cost.includesBom')}</p>
  740. )}
  741. </div>
  742. );
  743. })()}
  744. {project.budget !== null && (() => {
  745. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  746. const remaining = project.budget - totalCost;
  747. return (
  748. <div>
  749. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
  750. <p className="text-sm text-bambu-gray">
  751. {t('projectDetail.cost.total')}: <span className="text-white font-semibold">{currency}{project.budget.toFixed(2)}</span>
  752. </p>
  753. <p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-700 dark:text-red-400'}`}>
  754. {t('projectDetail.cost.remaining')}: <span className="font-semibold">{currency}{remaining.toFixed(2)}</span>
  755. </p>
  756. </div>
  757. );
  758. })()}
  759. </div>
  760. </CardContent>
  761. </Card>
  762. )}
  763. {/* Sub-projects */}
  764. {project.children && project.children.length > 0 && (
  765. <Card>
  766. <CardContent className="p-4">
  767. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
  768. <FolderTree className="w-5 h-5" />
  769. {t('projectDetail.subProjects.title', { count: project.children.length })}
  770. </h2>
  771. <div className="space-y-2">
  772. {project.children.map((child) => (
  773. <Link
  774. key={child.id}
  775. to={`/projects/${child.id}`}
  776. className="flex items-center justify-between gap-4 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  777. >
  778. <div className="flex items-center gap-3 min-w-0">
  779. <div
  780. className="w-3 h-3 rounded-full flex-shrink-0"
  781. style={{ backgroundColor: child.color || '#6b7280' }}
  782. />
  783. <span className="text-white truncate">{child.name}</span>
  784. <span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
  785. child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
  786. child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
  787. 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  788. }`}>
  789. {child.status}
  790. </span>
  791. {/* A branch of its own, so its figures cover more than the
  792. one project named on this row (#1264). */}
  793. {child.descendant_count > 0 && (
  794. <span className="text-xs text-bambu-gray flex-shrink-0 inline-flex items-center gap-1">
  795. <FolderTree className="w-3 h-3" />
  796. {child.descendant_count}
  797. </span>
  798. )}
  799. </div>
  800. {/* Each row carries its own branch's roll-up, so the rows add
  801. up to the card above minus this project's own prints. */}
  802. <div className="flex items-center gap-4 text-sm text-bambu-gray flex-shrink-0">
  803. {child.total_archives > 0 && (
  804. <span className="hidden sm:inline">
  805. {t('projectDetail.subProjects.jobs', { count: child.total_archives })}
  806. </span>
  807. )}
  808. {child.total_filament_grams > 0 && (
  809. <span className="hidden md:inline">{formatFilament(child.total_filament_grams)}</span>
  810. )}
  811. {child.total_cost > 0 && (
  812. <span className="hidden md:inline">{currency}{child.total_cost.toFixed(2)}</span>
  813. )}
  814. {child.progress_percent !== null && (
  815. <span>{child.progress_percent.toFixed(0)}%</span>
  816. )}
  817. </div>
  818. </Link>
  819. ))}
  820. </div>
  821. </CardContent>
  822. </Card>
  823. )}
  824. {/* Parent project link */}
  825. {project.parent_id && project.parent_name && (
  826. <div className="flex items-center gap-2 text-sm">
  827. <Layers className="w-4 h-4 text-bambu-gray" />
  828. <span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
  829. <Link
  830. to={`/projects/${project.parent_id}`}
  831. className="text-bambu-green hover:underline"
  832. >
  833. {project.parent_name}
  834. </Link>
  835. </div>
  836. )}
  837. {/* Meta info row - Tags, Due Date, Priority */}
  838. {(project.tags || project.due_date || project.priority !== 'normal') && (
  839. <div className="flex flex-wrap items-center gap-4">
  840. {/* Priority */}
  841. {project.priority && project.priority !== 'normal' && (
  842. <div className="flex items-center gap-2">
  843. <span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
  844. <PriorityBadge priority={project.priority} t={t} />
  845. </div>
  846. )}
  847. {/* Due Date */}
  848. {project.due_date && (
  849. <div className="flex items-center gap-2">
  850. <Calendar className="w-4 h-4 text-bambu-gray" />
  851. <span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
  852. {getDueDateStatus(project.due_date, t) && (
  853. <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
  854. ({getDueDateStatus(project.due_date, t)!.label})
  855. </span>
  856. )}
  857. </div>
  858. )}
  859. {/* Tags */}
  860. {project.tags && (
  861. <div className="flex items-center gap-2">
  862. <Tag className="w-4 h-4 text-bambu-gray" />
  863. <div className="flex flex-wrap gap-1">
  864. {project.tags.split(',').map((tag, index) => (
  865. <span
  866. key={index}
  867. className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
  868. >
  869. {tag.trim()}
  870. </span>
  871. ))}
  872. </div>
  873. </div>
  874. )}
  875. </div>
  876. )}
  877. {/* Notes section */}
  878. <Card>
  879. <CardContent className="p-4">
  880. <div className="flex items-center justify-between mb-3">
  881. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  882. <FileText className="w-5 h-5" />
  883. {t('projectDetail.notes.title')}
  884. </h2>
  885. {!editingNotes ? (
  886. <Button
  887. variant="secondary"
  888. size="sm"
  889. onClick={handleStartEditNotes}
  890. disabled={!hasPermission('projects:update')}
  891. title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
  892. >
  893. <Edit3 className="w-4 h-4 mr-1" />
  894. {t('common.edit')}
  895. </Button>
  896. ) : (
  897. <div className="flex gap-2">
  898. <Button
  899. variant="secondary"
  900. size="sm"
  901. onClick={handleCancelNotes}
  902. disabled={updateMutation.isPending}
  903. >
  904. <X className="w-4 h-4 mr-1" />
  905. {t('common.cancel')}
  906. </Button>
  907. <Button
  908. size="sm"
  909. onClick={handleSaveNotes}
  910. disabled={updateMutation.isPending}
  911. >
  912. {updateMutation.isPending ? (
  913. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  914. ) : (
  915. <Save className="w-4 h-4 mr-1" />
  916. )}
  917. {t('common.save')}
  918. </Button>
  919. </div>
  920. )}
  921. </div>
  922. {editingNotes ? (
  923. <RichTextEditor
  924. content={notesContent}
  925. onChange={setNotesContent}
  926. placeholder={t('projectDetail.notes.placeholder')}
  927. />
  928. ) : project.notes ? (
  929. <div
  930. className="prose prose-invert prose-sm max-w-none"
  931. dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(project.notes) }}
  932. />
  933. ) : (
  934. <p className="text-bambu-gray/70 text-sm italic">
  935. {t('projectDetail.notes.empty')}
  936. </p>
  937. )}
  938. </CardContent>
  939. </Card>
  940. {/* Files section - linked folders from File Manager with printable files */}
  941. <Card>
  942. <CardContent className="p-4">
  943. <div className="flex items-center justify-between mb-3">
  944. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  945. <FolderOpen className="w-5 h-5" />
  946. {t('projectDetail.files.title')}
  947. </h2>
  948. </div>
  949. <p className="text-xs text-bambu-gray mb-3">
  950. <Link to="/files" className="text-bambu-green hover:underline">
  951. {t('projectDetail.files.linkFolders')}
  952. </Link>
  953. {' '}{t('projectDetail.files.forQuickAccess')}
  954. </p>
  955. {linkedFolders && linkedFolders.length > 0 ? (
  956. <div className="space-y-4">
  957. {linkedFolders.map((folder) => {
  958. const files = filesByFolder.get(folder.id) ?? [];
  959. const isLoading = projectFilesLoading;
  960. return (
  961. <div key={folder.id}>
  962. {/* Folder header — links to File Manager */}
  963. <Link
  964. to={`/files?folder=${folder.id}`}
  965. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
  966. >
  967. <div className="flex items-center gap-3 min-w-0">
  968. <FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
  969. <div className="min-w-0">
  970. <p className="text-sm text-white truncate">{folder.name}</p>
  971. <p className="text-xs text-bambu-gray">
  972. {t('projectDetail.files.fileCount', { count: folder.file_count })}
  973. </p>
  974. </div>
  975. </div>
  976. <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
  977. </Link>
  978. {/* File list within the folder */}
  979. {isLoading ? (
  980. <div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
  981. <Loader2 className="w-4 h-4 animate-spin" />
  982. </div>
  983. ) : files.length === 0 ? (
  984. <p className="text-bambu-gray/60 text-xs italic px-3">
  985. {t('projectDetail.files.noFiles')}
  986. </p>
  987. ) : (
  988. <div className="space-y-1 pl-3">
  989. {files.map((file) => {
  990. const printable = isSlicedLibraryFile(file);
  991. return (
  992. <div
  993. key={file.id}
  994. className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  995. >
  996. {/* Thumbnail */}
  997. <div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
  998. {file.thumbnail_path ? (
  999. <img
  1000. src={api.getLibraryFileThumbnailUrl(file.id)}
  1001. alt={file.print_name || file.filename}
  1002. className="w-full h-full object-cover"
  1003. />
  1004. ) : (
  1005. <FileBox className="w-5 h-5 text-bambu-gray/40" />
  1006. )}
  1007. </div>
  1008. {/* Name + type badge */}
  1009. <div className="flex-1 min-w-0">
  1010. <p className="text-sm text-white truncate" title={file.print_name || file.filename}>
  1011. {file.print_name || file.filename}
  1012. </p>
  1013. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
  1014. file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
  1015. : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  1016. : 'bg-bambu-gray/20 text-bambu-gray'
  1017. }`}>
  1018. {file.file_type.toUpperCase()}
  1019. </span>
  1020. </div>
  1021. {/* Per-file print progress (#1897) */}
  1022. {printable && (() => {
  1023. const done = progressByFileId.get(file.id) ?? 0;
  1024. const target = project.target_sets;
  1025. if (!target) {
  1026. // No copies-per-file target — show a plain printed-count badge
  1027. return done > 0 ? (
  1028. <span
  1029. className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
  1030. title={t('projectDetail.files.printedCount', { count: done })}
  1031. >
  1032. {done}×
  1033. </span>
  1034. ) : null;
  1035. }
  1036. const pct = Math.min((done / target) * 100, 100);
  1037. const textColor =
  1038. done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
  1039. const barColor =
  1040. done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
  1041. return (
  1042. <div
  1043. className="shrink-0 w-20"
  1044. title={t('projectDetail.files.progressTooltip', { done, target })}
  1045. >
  1046. <p className={`text-xs font-medium text-right ${textColor}`}>
  1047. {done} / {target}
  1048. </p>
  1049. <div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
  1050. <div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
  1051. </div>
  1052. </div>
  1053. );
  1054. })()}
  1055. {/* Print actions for sliced files */}
  1056. {printable && (
  1057. <div className="flex items-center gap-1 shrink-0">
  1058. <button
  1059. onClick={() => setPrintFile(file)}
  1060. title={t('common.print')}
  1061. className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
  1062. >
  1063. <Printer className="w-4 h-4" />
  1064. </button>
  1065. </div>
  1066. )}
  1067. </div>
  1068. );
  1069. })}
  1070. </div>
  1071. )}
  1072. </div>
  1073. );
  1074. })}
  1075. </div>
  1076. ) : (
  1077. <p className="text-bambu-gray/70 text-sm italic">
  1078. {t('projectDetail.files.empty')}
  1079. </p>
  1080. )}
  1081. </CardContent>
  1082. </Card>
  1083. {/* BOM Section - Parts to source/purchase */}
  1084. <Card>
  1085. <CardContent className="p-4">
  1086. <div className="flex items-center justify-between mb-4">
  1087. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1088. <ShoppingCart className="w-5 h-5" />
  1089. {t('projectDetail.bom.title')}
  1090. {stats && stats.bom_total_items > 0 && (
  1091. <span className="text-sm font-normal text-bambu-gray">
  1092. ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
  1093. </span>
  1094. )}
  1095. </h2>
  1096. <div className="flex items-center gap-2">
  1097. {bomItems && bomItems.some(item => item.is_complete) && (
  1098. <button
  1099. onClick={() => setHideBomCompleted(!hideBomCompleted)}
  1100. className={`text-xs px-2 py-1 rounded transition-colors ${
  1101. hideBomCompleted
  1102. ? 'bg-bambu-green/20 text-bambu-green'
  1103. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  1104. }`}
  1105. >
  1106. {hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
  1107. </button>
  1108. )}
  1109. {!showBomForm && (
  1110. <Button
  1111. variant="secondary"
  1112. size="sm"
  1113. onClick={() => setShowBomForm(true)}
  1114. disabled={!hasPermission('projects:update')}
  1115. title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
  1116. >
  1117. <Plus className="w-4 h-4 mr-1" />
  1118. {t('projectDetail.bom.addPart')}
  1119. </Button>
  1120. )}
  1121. </div>
  1122. </div>
  1123. {/* Add BOM item form */}
  1124. {showBomForm && (
  1125. <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
  1126. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  1127. <input
  1128. type="text"
  1129. value={newBomName}
  1130. onChange={(e) => setNewBomName(e.target.value)}
  1131. 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"
  1132. placeholder={t('projectDetail.bom.partNamePlaceholder')}
  1133. autoFocus
  1134. />
  1135. <div className="flex gap-2">
  1136. <NumberInput
  1137. value={newBomQty}
  1138. onChange={setNewBomQty}
  1139. fallback={1}
  1140. 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"
  1141. min={1}
  1142. placeholder={t('projectDetail.bom.qty')}
  1143. />
  1144. <input
  1145. type="number"
  1146. step="0.01"
  1147. value={newBomPrice}
  1148. onChange={(e) => setNewBomPrice(e.target.value)}
  1149. 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"
  1150. placeholder={t('projectDetail.bom.price', { currency })}
  1151. />
  1152. </div>
  1153. </div>
  1154. <input
  1155. type="url"
  1156. value={newBomUrl}
  1157. onChange={(e) => setNewBomUrl(e.target.value)}
  1158. 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"
  1159. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  1160. />
  1161. <input
  1162. type="text"
  1163. value={newBomRemarks}
  1164. onChange={(e) => setNewBomRemarks(e.target.value)}
  1165. 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"
  1166. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1167. />
  1168. <div className="flex justify-end gap-2">
  1169. <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
  1170. {t('common.cancel')}
  1171. </Button>
  1172. <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
  1173. {createBomMutation.isPending ? (
  1174. <Loader2 className="w-4 h-4 animate-spin" />
  1175. ) : (
  1176. t('projectDetail.bom.addPart')
  1177. )}
  1178. </Button>
  1179. </div>
  1180. </form>
  1181. )}
  1182. {bomLoading ? (
  1183. <div className="flex items-center justify-center py-4">
  1184. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1185. </div>
  1186. ) : bomItems && bomItems.length > 0 ? (
  1187. <div className="space-y-2">
  1188. {bomItems
  1189. .filter(item => !hideBomCompleted || !item.is_complete)
  1190. .map((item) => (
  1191. <div
  1192. key={item.id}
  1193. className={`p-3 rounded-lg transition-colors ${
  1194. item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
  1195. }`}
  1196. >
  1197. {editingBomItem?.id === item.id ? (
  1198. // Edit form for this BOM item
  1199. <form onSubmit={handleSaveBomEdit} className="space-y-3">
  1200. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  1201. <input
  1202. type="text"
  1203. value={editBomName}
  1204. onChange={(e) => setEditBomName(e.target.value)}
  1205. 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"
  1206. placeholder={t('projectDetail.bom.partName')}
  1207. autoFocus
  1208. />
  1209. <div className="flex gap-2">
  1210. <NumberInput
  1211. value={editBomQty}
  1212. onChange={setEditBomQty}
  1213. fallback={1}
  1214. 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"
  1215. min={1}
  1216. placeholder={t('projectDetail.bom.qty')}
  1217. />
  1218. <input
  1219. type="number"
  1220. step="0.01"
  1221. value={editBomPrice}
  1222. onChange={(e) => setEditBomPrice(e.target.value)}
  1223. 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"
  1224. placeholder={t('projectDetail.bom.price', { currency })}
  1225. />
  1226. </div>
  1227. </div>
  1228. <input
  1229. type="url"
  1230. value={editBomUrl}
  1231. onChange={(e) => setEditBomUrl(e.target.value)}
  1232. 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"
  1233. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  1234. />
  1235. <input
  1236. type="text"
  1237. value={editBomRemarks}
  1238. onChange={(e) => setEditBomRemarks(e.target.value)}
  1239. 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"
  1240. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1241. />
  1242. <div className="flex justify-end gap-2">
  1243. <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
  1244. {t('common.cancel')}
  1245. </Button>
  1246. <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
  1247. {updateBomMutation.isPending ? (
  1248. <Loader2 className="w-4 h-4 animate-spin" />
  1249. ) : (
  1250. t('common.save')
  1251. )}
  1252. </Button>
  1253. </div>
  1254. </form>
  1255. ) : (
  1256. // Display mode
  1257. <div className="flex items-start gap-3">
  1258. <button
  1259. onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
  1260. disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
  1261. title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
  1262. className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
  1263. item.is_complete
  1264. ? 'bg-status-ok border-status-ok text-white'
  1265. : hasPermission('projects:update')
  1266. ? 'border-bambu-gray hover:border-bambu-green'
  1267. : 'border-bambu-gray/50 cursor-not-allowed'
  1268. }`}
  1269. >
  1270. {item.is_complete && <CheckCircle className="w-3 h-3" />}
  1271. </button>
  1272. <div className="flex-1 min-w-0">
  1273. <div className="flex items-center justify-between gap-2">
  1274. <div className="flex items-center gap-2 min-w-0">
  1275. <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
  1276. {item.name}
  1277. <span className="text-bambu-gray font-normal ml-2">
  1278. x{item.quantity_needed}
  1279. </span>
  1280. </p>
  1281. {item.unit_price !== null && (
  1282. <span className="text-xs text-bambu-green whitespace-nowrap">
  1283. {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
  1284. </span>
  1285. )}
  1286. </div>
  1287. <div className="flex items-center gap-1">
  1288. <button
  1289. onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
  1290. disabled={!hasPermission('projects:update')}
  1291. className={`p-1 rounded transition-colors shrink-0 ${
  1292. hasPermission('projects:update')
  1293. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1294. : 'text-bambu-gray/50 cursor-not-allowed'
  1295. }`}
  1296. title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
  1297. >
  1298. <Pencil className="w-4 h-4" />
  1299. </button>
  1300. <button
  1301. onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
  1302. disabled={!hasPermission('projects:update')}
  1303. className={`p-1 rounded transition-colors shrink-0 ${
  1304. hasPermission('projects:update')
  1305. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
  1306. : 'text-bambu-gray/50 cursor-not-allowed'
  1307. }`}
  1308. title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
  1309. >
  1310. <Trash2 className="w-4 h-4" />
  1311. </button>
  1312. </div>
  1313. </div>
  1314. {/* Sourcing URL */}
  1315. {item.sourcing_url && (
  1316. <a
  1317. href={item.sourcing_url}
  1318. target="_blank"
  1319. rel="noopener noreferrer"
  1320. className="flex items-center gap-1 mt-1 text-xs text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 transition-colors"
  1321. onClick={(e) => e.stopPropagation()}
  1322. >
  1323. <ExternalLink className="w-3 h-3 shrink-0" />
  1324. <span className="truncate">
  1325. {(() => {
  1326. try {
  1327. return new URL(item.sourcing_url).hostname.replace('www.', '');
  1328. } catch {
  1329. return item.sourcing_url;
  1330. }
  1331. })()}
  1332. </span>
  1333. </a>
  1334. )}
  1335. {/* Remarks */}
  1336. {item.remarks && (
  1337. <p className="mt-1 text-xs text-bambu-gray/80 italic">
  1338. {item.remarks}
  1339. </p>
  1340. )}
  1341. </div>
  1342. </div>
  1343. )}
  1344. </div>
  1345. ))}
  1346. {/* BOM Total */}
  1347. {stats && stats.bom_cost > 0 && (
  1348. <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
  1349. <span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
  1350. <span className="text-white font-medium">
  1351. {currency}{stats.bom_cost.toFixed(2)}
  1352. </span>
  1353. </div>
  1354. )}
  1355. </div>
  1356. ) : (
  1357. <p className="text-bambu-gray/70 text-sm italic">
  1358. {t('projectDetail.bom.empty')}
  1359. </p>
  1360. )}
  1361. </CardContent>
  1362. </Card>
  1363. {/* Timeline Section */}
  1364. <Card>
  1365. <CardContent className="p-4">
  1366. <div className="flex items-center justify-between mb-3">
  1367. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1368. <History className="w-5 h-5" />
  1369. {t('projectDetail.timeline.title')}
  1370. </h2>
  1371. </div>
  1372. {timelineLoading ? (
  1373. <div className="flex items-center justify-center py-4">
  1374. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1375. </div>
  1376. ) : timeline && timeline.length > 0 ? (
  1377. <div className="space-y-3">
  1378. {timeline.slice(0, 10).map((event, index) => (
  1379. <div key={index} className="flex gap-3">
  1380. <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
  1381. event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
  1382. event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
  1383. event.event_type === 'print_started' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
  1384. 'bg-bambu-dark-tertiary text-bambu-gray'
  1385. }`}>
  1386. {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
  1387. {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
  1388. {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
  1389. {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
  1390. {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
  1391. </div>
  1392. <div className="flex-1 min-w-0">
  1393. <p className="text-sm text-white">{event.title}</p>
  1394. {event.description && (
  1395. <p className="text-xs text-bambu-gray truncate">{event.description}</p>
  1396. )}
  1397. <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
  1398. </div>
  1399. </div>
  1400. ))}
  1401. </div>
  1402. ) : (
  1403. <p className="text-bambu-gray/70 text-sm italic">
  1404. {t('projectDetail.timeline.empty')}
  1405. </p>
  1406. )}
  1407. </CardContent>
  1408. </Card>
  1409. {/* Template action */}
  1410. {!project.is_template && (
  1411. <div className="flex justify-end">
  1412. <Button
  1413. variant="secondary"
  1414. size="sm"
  1415. onClick={() => createTemplateMutation.mutate()}
  1416. disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
  1417. title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
  1418. >
  1419. {createTemplateMutation.isPending ? (
  1420. <Loader2 className="w-4 h-4 animate-spin mr-2" />
  1421. ) : (
  1422. <Copy className="w-4 h-4 mr-2" />
  1423. )}
  1424. {t('projectDetail.template.saveAsTemplate')}
  1425. </Button>
  1426. </div>
  1427. )}
  1428. {/* Queue section */}
  1429. {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
  1430. <Card>
  1431. <CardContent className="p-4">
  1432. <div className="flex items-center justify-between mb-3">
  1433. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1434. <ListTodo className="w-5 h-5" />
  1435. {t('projectDetail.queue.title')}
  1436. </h2>
  1437. <Link
  1438. to={`/queue?project=${projectId}`}
  1439. className="text-sm text-bambu-green hover:underline"
  1440. >
  1441. {t('projectDetail.queue.viewAll')}
  1442. </Link>
  1443. </div>
  1444. <div className="flex items-center gap-4 text-sm">
  1445. {stats.in_progress_prints > 0 && (
  1446. <span className="text-yellow-700 dark:text-yellow-400">
  1447. {t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
  1448. </span>
  1449. )}
  1450. {stats.queued_prints > 0 && (
  1451. <span className="text-bambu-gray">
  1452. {t('projectDetail.queue.queued', { count: stats.queued_prints })}
  1453. </span>
  1454. )}
  1455. </div>
  1456. </CardContent>
  1457. </Card>
  1458. )}
  1459. {/* Archives section */}
  1460. <div>
  1461. <div className="flex items-center justify-between mb-4">
  1462. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1463. <Package className="w-5 h-5" />
  1464. {t('projectDetail.prints.title', { count: archives?.length || 0 })}
  1465. </h2>
  1466. </div>
  1467. {archivesLoading ? (
  1468. <div className="flex items-center justify-center py-8">
  1469. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1470. </div>
  1471. ) : (
  1472. <ArchiveGrid archives={archives || []} t={t} />
  1473. )}
  1474. </div>
  1475. {/* Edit Modal */}
  1476. {showEditModal && (
  1477. <ProjectModal
  1478. t={t}
  1479. currencySymbol={currency}
  1480. project={{
  1481. ...project,
  1482. archive_count: stats?.total_archives || 0,
  1483. total_items: stats?.total_items || 0,
  1484. completed_count: stats?.completed_prints || 0,
  1485. failed_count: stats?.failed_prints || 0,
  1486. queue_count: stats?.queued_prints || 0,
  1487. progress_percent: stats?.progress_percent || null,
  1488. // Required by ProjectListItem, but nothing in the dialog reads it —
  1489. // the parent picker works off the project list it fetches itself.
  1490. // Guarded like every other read of `children` on this page (#1264).
  1491. child_count: project.children?.length ?? 0,
  1492. archives: [],
  1493. }}
  1494. onClose={() => setShowEditModal(false)}
  1495. onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
  1496. isLoading={updateMutation.isPending}
  1497. />
  1498. )}
  1499. {/* Confirm Modal */}
  1500. {confirmModal.isOpen && (
  1501. <ConfirmModal
  1502. title={confirmModal.title}
  1503. message={confirmModal.message}
  1504. confirmText={t('common.delete')}
  1505. variant="danger"
  1506. onConfirm={confirmModal.onConfirm}
  1507. onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
  1508. />
  1509. )}
  1510. {/* Print from project */}
  1511. {printFile && (
  1512. <PrintModal
  1513. mode="create"
  1514. libraryFileId={printFile.id}
  1515. archiveName={printFile.print_name || printFile.filename}
  1516. projectId={projectId}
  1517. onClose={() => setPrintFile(null)}
  1518. onSuccess={() => {
  1519. setPrintFile(null);
  1520. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1521. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1522. }}
  1523. />
  1524. )}
  1525. </div>
  1526. );
  1527. }