ProjectDetailPage.tsx 70 KB

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