ProjectDetailPage.tsx 70 KB

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