ProjectDetailPage.tsx 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514
  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. {/* Cost tracking */}
  630. {stats && (() => {
  631. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  632. return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
  633. })() && (
  634. <Card>
  635. <CardContent className="p-4">
  636. <h2 className="text-lg font-semibold text-white mb-3">
  637. {t('projectDetail.cost.title')}
  638. </h2>
  639. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  640. <div>
  641. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
  642. <p className="text-lg font-semibold text-white">
  643. {currency}{stats.estimated_cost.toFixed(2)}
  644. </p>
  645. </div>
  646. {stats.total_energy_kwh > 0 && (
  647. <div>
  648. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
  649. <p className="text-lg font-semibold text-white">
  650. {stats.total_energy_kwh.toFixed(3)} kWh
  651. {stats.total_energy_cost > 0 && (
  652. <span className="text-sm text-bambu-gray ml-1">
  653. ({currency}{stats.total_energy_cost.toFixed(2)})
  654. </span>
  655. )}
  656. </p>
  657. </div>
  658. )}
  659. {(() => {
  660. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  661. if (totalCost <= 0) return null;
  662. return (
  663. <div>
  664. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
  665. <p className="text-lg font-semibold text-bambu-green">
  666. {currency}{totalCost.toFixed(2)}
  667. </p>
  668. {stats.bom_cost > 0 && (
  669. <p className="text-xs text-bambu-gray/70">{t('projectDetail.cost.includesBom')}</p>
  670. )}
  671. </div>
  672. );
  673. })()}
  674. {project.budget !== null && (() => {
  675. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  676. const remaining = project.budget - totalCost;
  677. return (
  678. <div>
  679. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
  680. <p className="text-sm text-bambu-gray">
  681. {t('projectDetail.cost.total')}: <span className="text-white font-semibold">{currency}{project.budget.toFixed(2)}</span>
  682. </p>
  683. <p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-700 dark:text-red-400'}`}>
  684. {t('projectDetail.cost.remaining')}: <span className="font-semibold">{currency}{remaining.toFixed(2)}</span>
  685. </p>
  686. </div>
  687. );
  688. })()}
  689. </div>
  690. </CardContent>
  691. </Card>
  692. )}
  693. {/* Sub-projects */}
  694. {project.children && project.children.length > 0 && (
  695. <Card>
  696. <CardContent className="p-4">
  697. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
  698. <FolderTree className="w-5 h-5" />
  699. {t('projectDetail.subProjects.title', { count: project.children.length })}
  700. </h2>
  701. <div className="space-y-2">
  702. {project.children.map((child) => (
  703. <Link
  704. key={child.id}
  705. to={`/projects/${child.id}`}
  706. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  707. >
  708. <div className="flex items-center gap-3">
  709. <div
  710. className="w-3 h-3 rounded-full"
  711. style={{ backgroundColor: child.color || '#6b7280' }}
  712. />
  713. <span className="text-white">{child.name}</span>
  714. <span className={`text-xs px-2 py-0.5 rounded ${
  715. child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
  716. child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
  717. 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  718. }`}>
  719. {child.status}
  720. </span>
  721. </div>
  722. {child.progress_percent !== null && (
  723. <span className="text-sm text-bambu-gray">
  724. {child.progress_percent.toFixed(0)}%
  725. </span>
  726. )}
  727. </Link>
  728. ))}
  729. </div>
  730. </CardContent>
  731. </Card>
  732. )}
  733. {/* Parent project link */}
  734. {project.parent_id && project.parent_name && (
  735. <div className="flex items-center gap-2 text-sm">
  736. <Layers className="w-4 h-4 text-bambu-gray" />
  737. <span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
  738. <Link
  739. to={`/projects/${project.parent_id}`}
  740. className="text-bambu-green hover:underline"
  741. >
  742. {project.parent_name}
  743. </Link>
  744. </div>
  745. )}
  746. {/* Meta info row - Tags, Due Date, Priority */}
  747. {(project.tags || project.due_date || project.priority !== 'normal') && (
  748. <div className="flex flex-wrap items-center gap-4">
  749. {/* Priority */}
  750. {project.priority && project.priority !== 'normal' && (
  751. <div className="flex items-center gap-2">
  752. <span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
  753. <PriorityBadge priority={project.priority} t={t} />
  754. </div>
  755. )}
  756. {/* Due Date */}
  757. {project.due_date && (
  758. <div className="flex items-center gap-2">
  759. <Calendar className="w-4 h-4 text-bambu-gray" />
  760. <span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
  761. {getDueDateStatus(project.due_date, t) && (
  762. <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
  763. ({getDueDateStatus(project.due_date, t)!.label})
  764. </span>
  765. )}
  766. </div>
  767. )}
  768. {/* Tags */}
  769. {project.tags && (
  770. <div className="flex items-center gap-2">
  771. <Tag className="w-4 h-4 text-bambu-gray" />
  772. <div className="flex flex-wrap gap-1">
  773. {project.tags.split(',').map((tag, index) => (
  774. <span
  775. key={index}
  776. className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
  777. >
  778. {tag.trim()}
  779. </span>
  780. ))}
  781. </div>
  782. </div>
  783. )}
  784. </div>
  785. )}
  786. {/* Notes section */}
  787. <Card>
  788. <CardContent className="p-4">
  789. <div className="flex items-center justify-between mb-3">
  790. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  791. <FileText className="w-5 h-5" />
  792. {t('projectDetail.notes.title')}
  793. </h2>
  794. {!editingNotes ? (
  795. <Button
  796. variant="secondary"
  797. size="sm"
  798. onClick={handleStartEditNotes}
  799. disabled={!hasPermission('projects:update')}
  800. title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
  801. >
  802. <Edit3 className="w-4 h-4 mr-1" />
  803. {t('common.edit')}
  804. </Button>
  805. ) : (
  806. <div className="flex gap-2">
  807. <Button
  808. variant="secondary"
  809. size="sm"
  810. onClick={handleCancelNotes}
  811. disabled={updateMutation.isPending}
  812. >
  813. <X className="w-4 h-4 mr-1" />
  814. {t('common.cancel')}
  815. </Button>
  816. <Button
  817. size="sm"
  818. onClick={handleSaveNotes}
  819. disabled={updateMutation.isPending}
  820. >
  821. {updateMutation.isPending ? (
  822. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  823. ) : (
  824. <Save className="w-4 h-4 mr-1" />
  825. )}
  826. {t('common.save')}
  827. </Button>
  828. </div>
  829. )}
  830. </div>
  831. {editingNotes ? (
  832. <RichTextEditor
  833. content={notesContent}
  834. onChange={setNotesContent}
  835. placeholder={t('projectDetail.notes.placeholder')}
  836. />
  837. ) : project.notes ? (
  838. <div
  839. className="prose prose-invert prose-sm max-w-none"
  840. dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(project.notes) }}
  841. />
  842. ) : (
  843. <p className="text-bambu-gray/70 text-sm italic">
  844. {t('projectDetail.notes.empty')}
  845. </p>
  846. )}
  847. </CardContent>
  848. </Card>
  849. {/* Files section - linked folders from File Manager with printable files */}
  850. <Card>
  851. <CardContent className="p-4">
  852. <div className="flex items-center justify-between mb-3">
  853. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  854. <FolderOpen className="w-5 h-5" />
  855. {t('projectDetail.files.title')}
  856. </h2>
  857. </div>
  858. <p className="text-xs text-bambu-gray mb-3">
  859. <Link to="/files" className="text-bambu-green hover:underline">
  860. {t('projectDetail.files.linkFolders')}
  861. </Link>
  862. {' '}{t('projectDetail.files.forQuickAccess')}
  863. </p>
  864. {linkedFolders && linkedFolders.length > 0 ? (
  865. <div className="space-y-4">
  866. {linkedFolders.map((folder) => {
  867. const files = filesByFolder.get(folder.id) ?? [];
  868. const isLoading = projectFilesLoading;
  869. return (
  870. <div key={folder.id}>
  871. {/* Folder header — links to File Manager */}
  872. <Link
  873. to={`/files?folder=${folder.id}`}
  874. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
  875. >
  876. <div className="flex items-center gap-3 min-w-0">
  877. <FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
  878. <div className="min-w-0">
  879. <p className="text-sm text-white truncate">{folder.name}</p>
  880. <p className="text-xs text-bambu-gray">
  881. {t('projectDetail.files.fileCount', { count: folder.file_count })}
  882. </p>
  883. </div>
  884. </div>
  885. <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
  886. </Link>
  887. {/* File list within the folder */}
  888. {isLoading ? (
  889. <div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
  890. <Loader2 className="w-4 h-4 animate-spin" />
  891. </div>
  892. ) : files.length === 0 ? (
  893. <p className="text-bambu-gray/60 text-xs italic px-3">
  894. {t('projectDetail.files.noFiles')}
  895. </p>
  896. ) : (
  897. <div className="space-y-1 pl-3">
  898. {files.map((file) => {
  899. const printable = isSlicedFilename(file.filename);
  900. return (
  901. <div
  902. key={file.id}
  903. className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  904. >
  905. {/* Thumbnail */}
  906. <div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
  907. {file.thumbnail_path ? (
  908. <img
  909. src={api.getLibraryFileThumbnailUrl(file.id)}
  910. alt={file.print_name || file.filename}
  911. className="w-full h-full object-cover"
  912. />
  913. ) : (
  914. <FileBox className="w-5 h-5 text-bambu-gray/40" />
  915. )}
  916. </div>
  917. {/* Name + type badge */}
  918. <div className="flex-1 min-w-0">
  919. <p className="text-sm text-white truncate" title={file.print_name || file.filename}>
  920. {file.print_name || file.filename}
  921. </p>
  922. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
  923. file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
  924. : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  925. : 'bg-bambu-gray/20 text-bambu-gray'
  926. }`}>
  927. {file.file_type.toUpperCase()}
  928. </span>
  929. </div>
  930. {/* Per-file print progress (#1897) */}
  931. {printable && (() => {
  932. const done = progressByFileId.get(file.id) ?? 0;
  933. const target = project.target_sets;
  934. if (!target) {
  935. // No copies-per-file target — show a plain printed-count badge
  936. return done > 0 ? (
  937. <span
  938. className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-bambu-dark text-bambu-gray"
  939. title={t('projectDetail.files.printedCount', { count: done })}
  940. >
  941. {done}×
  942. </span>
  943. ) : null;
  944. }
  945. const pct = Math.min((done / target) * 100, 100);
  946. const textColor =
  947. done >= target ? 'text-status-ok' : done > 0 ? 'text-status-warning' : 'text-bambu-gray';
  948. const barColor =
  949. done >= target ? 'bg-status-ok' : done > 0 ? 'bg-status-warning' : 'bg-bambu-gray';
  950. return (
  951. <div
  952. className="shrink-0 w-20"
  953. title={t('projectDetail.files.progressTooltip', { done, target })}
  954. >
  955. <p className={`text-xs font-medium text-right ${textColor}`}>
  956. {done} / {target}
  957. </p>
  958. <div className="h-1 bg-bambu-dark rounded-full overflow-hidden mt-1">
  959. <div className={`h-full ${barColor}`} style={{ width: `${pct}%` }} />
  960. </div>
  961. </div>
  962. );
  963. })()}
  964. {/* Print actions for sliced files */}
  965. {printable && (
  966. <div className="flex items-center gap-1 shrink-0">
  967. <button
  968. onClick={() => setPrintFile(file)}
  969. title={t('common.print')}
  970. className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
  971. >
  972. <Printer className="w-4 h-4" />
  973. </button>
  974. </div>
  975. )}
  976. </div>
  977. );
  978. })}
  979. </div>
  980. )}
  981. </div>
  982. );
  983. })}
  984. </div>
  985. ) : (
  986. <p className="text-bambu-gray/70 text-sm italic">
  987. {t('projectDetail.files.empty')}
  988. </p>
  989. )}
  990. </CardContent>
  991. </Card>
  992. {/* BOM Section - Parts to source/purchase */}
  993. <Card>
  994. <CardContent className="p-4">
  995. <div className="flex items-center justify-between mb-4">
  996. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  997. <ShoppingCart className="w-5 h-5" />
  998. {t('projectDetail.bom.title')}
  999. {stats && stats.bom_total_items > 0 && (
  1000. <span className="text-sm font-normal text-bambu-gray">
  1001. ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
  1002. </span>
  1003. )}
  1004. </h2>
  1005. <div className="flex items-center gap-2">
  1006. {bomItems && bomItems.some(item => item.is_complete) && (
  1007. <button
  1008. onClick={() => setHideBomCompleted(!hideBomCompleted)}
  1009. className={`text-xs px-2 py-1 rounded transition-colors ${
  1010. hideBomCompleted
  1011. ? 'bg-bambu-green/20 text-bambu-green'
  1012. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  1013. }`}
  1014. >
  1015. {hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
  1016. </button>
  1017. )}
  1018. {!showBomForm && (
  1019. <Button
  1020. variant="secondary"
  1021. size="sm"
  1022. onClick={() => setShowBomForm(true)}
  1023. disabled={!hasPermission('projects:update')}
  1024. title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
  1025. >
  1026. <Plus className="w-4 h-4 mr-1" />
  1027. {t('projectDetail.bom.addPart')}
  1028. </Button>
  1029. )}
  1030. </div>
  1031. </div>
  1032. {/* Add BOM item form */}
  1033. {showBomForm && (
  1034. <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
  1035. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  1036. <input
  1037. type="text"
  1038. value={newBomName}
  1039. onChange={(e) => setNewBomName(e.target.value)}
  1040. 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"
  1041. placeholder={t('projectDetail.bom.partNamePlaceholder')}
  1042. autoFocus
  1043. />
  1044. <div className="flex gap-2">
  1045. <input
  1046. type="number"
  1047. value={newBomQty}
  1048. onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
  1049. 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"
  1050. min="1"
  1051. placeholder={t('projectDetail.bom.qty')}
  1052. />
  1053. <input
  1054. type="number"
  1055. step="0.01"
  1056. value={newBomPrice}
  1057. onChange={(e) => setNewBomPrice(e.target.value)}
  1058. 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"
  1059. placeholder={t('projectDetail.bom.price', { currency })}
  1060. />
  1061. </div>
  1062. </div>
  1063. <input
  1064. type="url"
  1065. value={newBomUrl}
  1066. onChange={(e) => setNewBomUrl(e.target.value)}
  1067. 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"
  1068. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  1069. />
  1070. <input
  1071. type="text"
  1072. value={newBomRemarks}
  1073. onChange={(e) => setNewBomRemarks(e.target.value)}
  1074. 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"
  1075. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1076. />
  1077. <div className="flex justify-end gap-2">
  1078. <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
  1079. {t('common.cancel')}
  1080. </Button>
  1081. <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
  1082. {createBomMutation.isPending ? (
  1083. <Loader2 className="w-4 h-4 animate-spin" />
  1084. ) : (
  1085. t('projectDetail.bom.addPart')
  1086. )}
  1087. </Button>
  1088. </div>
  1089. </form>
  1090. )}
  1091. {bomLoading ? (
  1092. <div className="flex items-center justify-center py-4">
  1093. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1094. </div>
  1095. ) : bomItems && bomItems.length > 0 ? (
  1096. <div className="space-y-2">
  1097. {bomItems
  1098. .filter(item => !hideBomCompleted || !item.is_complete)
  1099. .map((item) => (
  1100. <div
  1101. key={item.id}
  1102. className={`p-3 rounded-lg transition-colors ${
  1103. item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
  1104. }`}
  1105. >
  1106. {editingBomItem?.id === item.id ? (
  1107. // Edit form for this BOM item
  1108. <form onSubmit={handleSaveBomEdit} className="space-y-3">
  1109. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  1110. <input
  1111. type="text"
  1112. value={editBomName}
  1113. onChange={(e) => setEditBomName(e.target.value)}
  1114. 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"
  1115. placeholder={t('projectDetail.bom.partName')}
  1116. autoFocus
  1117. />
  1118. <div className="flex gap-2">
  1119. <input
  1120. type="number"
  1121. value={editBomQty}
  1122. onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
  1123. 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"
  1124. min="1"
  1125. placeholder={t('projectDetail.bom.qty')}
  1126. />
  1127. <input
  1128. type="number"
  1129. step="0.01"
  1130. value={editBomPrice}
  1131. onChange={(e) => setEditBomPrice(e.target.value)}
  1132. 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"
  1133. placeholder={t('projectDetail.bom.price', { currency })}
  1134. />
  1135. </div>
  1136. </div>
  1137. <input
  1138. type="url"
  1139. value={editBomUrl}
  1140. onChange={(e) => setEditBomUrl(e.target.value)}
  1141. 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"
  1142. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  1143. />
  1144. <input
  1145. type="text"
  1146. value={editBomRemarks}
  1147. onChange={(e) => setEditBomRemarks(e.target.value)}
  1148. 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"
  1149. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1150. />
  1151. <div className="flex justify-end gap-2">
  1152. <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
  1153. {t('common.cancel')}
  1154. </Button>
  1155. <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
  1156. {updateBomMutation.isPending ? (
  1157. <Loader2 className="w-4 h-4 animate-spin" />
  1158. ) : (
  1159. t('common.save')
  1160. )}
  1161. </Button>
  1162. </div>
  1163. </form>
  1164. ) : (
  1165. // Display mode
  1166. <div className="flex items-start gap-3">
  1167. <button
  1168. onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
  1169. disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
  1170. title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
  1171. className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
  1172. item.is_complete
  1173. ? 'bg-status-ok border-status-ok text-white'
  1174. : hasPermission('projects:update')
  1175. ? 'border-bambu-gray hover:border-bambu-green'
  1176. : 'border-bambu-gray/50 cursor-not-allowed'
  1177. }`}
  1178. >
  1179. {item.is_complete && <CheckCircle className="w-3 h-3" />}
  1180. </button>
  1181. <div className="flex-1 min-w-0">
  1182. <div className="flex items-center justify-between gap-2">
  1183. <div className="flex items-center gap-2 min-w-0">
  1184. <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
  1185. {item.name}
  1186. <span className="text-bambu-gray font-normal ml-2">
  1187. x{item.quantity_needed}
  1188. </span>
  1189. </p>
  1190. {item.unit_price !== null && (
  1191. <span className="text-xs text-bambu-green whitespace-nowrap">
  1192. {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
  1193. </span>
  1194. )}
  1195. </div>
  1196. <div className="flex items-center gap-1">
  1197. <button
  1198. onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
  1199. disabled={!hasPermission('projects:update')}
  1200. className={`p-1 rounded transition-colors shrink-0 ${
  1201. hasPermission('projects:update')
  1202. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1203. : 'text-bambu-gray/50 cursor-not-allowed'
  1204. }`}
  1205. title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
  1206. >
  1207. <Pencil className="w-4 h-4" />
  1208. </button>
  1209. <button
  1210. onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
  1211. disabled={!hasPermission('projects:update')}
  1212. className={`p-1 rounded transition-colors shrink-0 ${
  1213. hasPermission('projects:update')
  1214. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
  1215. : 'text-bambu-gray/50 cursor-not-allowed'
  1216. }`}
  1217. title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
  1218. >
  1219. <Trash2 className="w-4 h-4" />
  1220. </button>
  1221. </div>
  1222. </div>
  1223. {/* Sourcing URL */}
  1224. {item.sourcing_url && (
  1225. <a
  1226. href={item.sourcing_url}
  1227. target="_blank"
  1228. rel="noopener noreferrer"
  1229. 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"
  1230. onClick={(e) => e.stopPropagation()}
  1231. >
  1232. <ExternalLink className="w-3 h-3 shrink-0" />
  1233. <span className="truncate">
  1234. {(() => {
  1235. try {
  1236. return new URL(item.sourcing_url).hostname.replace('www.', '');
  1237. } catch {
  1238. return item.sourcing_url;
  1239. }
  1240. })()}
  1241. </span>
  1242. </a>
  1243. )}
  1244. {/* Remarks */}
  1245. {item.remarks && (
  1246. <p className="mt-1 text-xs text-bambu-gray/80 italic">
  1247. {item.remarks}
  1248. </p>
  1249. )}
  1250. </div>
  1251. </div>
  1252. )}
  1253. </div>
  1254. ))}
  1255. {/* BOM Total */}
  1256. {stats && stats.bom_cost > 0 && (
  1257. <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
  1258. <span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
  1259. <span className="text-white font-medium">
  1260. {currency}{stats.bom_cost.toFixed(2)}
  1261. </span>
  1262. </div>
  1263. )}
  1264. </div>
  1265. ) : (
  1266. <p className="text-bambu-gray/70 text-sm italic">
  1267. {t('projectDetail.bom.empty')}
  1268. </p>
  1269. )}
  1270. </CardContent>
  1271. </Card>
  1272. {/* Timeline Section */}
  1273. <Card>
  1274. <CardContent className="p-4">
  1275. <div className="flex items-center justify-between mb-3">
  1276. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1277. <History className="w-5 h-5" />
  1278. {t('projectDetail.timeline.title')}
  1279. </h2>
  1280. </div>
  1281. {timelineLoading ? (
  1282. <div className="flex items-center justify-center py-4">
  1283. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1284. </div>
  1285. ) : timeline && timeline.length > 0 ? (
  1286. <div className="space-y-3">
  1287. {timeline.slice(0, 10).map((event, index) => (
  1288. <div key={index} className="flex gap-3">
  1289. <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
  1290. event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
  1291. event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
  1292. event.event_type === 'print_started' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
  1293. 'bg-bambu-dark-tertiary text-bambu-gray'
  1294. }`}>
  1295. {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
  1296. {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
  1297. {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
  1298. {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
  1299. {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
  1300. </div>
  1301. <div className="flex-1 min-w-0">
  1302. <p className="text-sm text-white">{event.title}</p>
  1303. {event.description && (
  1304. <p className="text-xs text-bambu-gray truncate">{event.description}</p>
  1305. )}
  1306. <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
  1307. </div>
  1308. </div>
  1309. ))}
  1310. </div>
  1311. ) : (
  1312. <p className="text-bambu-gray/70 text-sm italic">
  1313. {t('projectDetail.timeline.empty')}
  1314. </p>
  1315. )}
  1316. </CardContent>
  1317. </Card>
  1318. {/* Template action */}
  1319. {!project.is_template && (
  1320. <div className="flex justify-end">
  1321. <Button
  1322. variant="secondary"
  1323. size="sm"
  1324. onClick={() => createTemplateMutation.mutate()}
  1325. disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
  1326. title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
  1327. >
  1328. {createTemplateMutation.isPending ? (
  1329. <Loader2 className="w-4 h-4 animate-spin mr-2" />
  1330. ) : (
  1331. <Copy className="w-4 h-4 mr-2" />
  1332. )}
  1333. {t('projectDetail.template.saveAsTemplate')}
  1334. </Button>
  1335. </div>
  1336. )}
  1337. {/* Queue section */}
  1338. {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
  1339. <Card>
  1340. <CardContent className="p-4">
  1341. <div className="flex items-center justify-between mb-3">
  1342. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1343. <ListTodo className="w-5 h-5" />
  1344. {t('projectDetail.queue.title')}
  1345. </h2>
  1346. <Link
  1347. to={`/queue?project=${projectId}`}
  1348. className="text-sm text-bambu-green hover:underline"
  1349. >
  1350. {t('projectDetail.queue.viewAll')}
  1351. </Link>
  1352. </div>
  1353. <div className="flex items-center gap-4 text-sm">
  1354. {stats.in_progress_prints > 0 && (
  1355. <span className="text-yellow-700 dark:text-yellow-400">
  1356. {t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
  1357. </span>
  1358. )}
  1359. {stats.queued_prints > 0 && (
  1360. <span className="text-bambu-gray">
  1361. {t('projectDetail.queue.queued', { count: stats.queued_prints })}
  1362. </span>
  1363. )}
  1364. </div>
  1365. </CardContent>
  1366. </Card>
  1367. )}
  1368. {/* Archives section */}
  1369. <div>
  1370. <div className="flex items-center justify-between mb-4">
  1371. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1372. <Package className="w-5 h-5" />
  1373. {t('projectDetail.prints.title', { count: archives?.length || 0 })}
  1374. </h2>
  1375. </div>
  1376. {archivesLoading ? (
  1377. <div className="flex items-center justify-center py-8">
  1378. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1379. </div>
  1380. ) : (
  1381. <ArchiveGrid archives={archives || []} t={t} />
  1382. )}
  1383. </div>
  1384. {/* Edit Modal */}
  1385. {showEditModal && (
  1386. <ProjectModal
  1387. t={t}
  1388. currencySymbol={currency}
  1389. project={{
  1390. ...project,
  1391. archive_count: stats?.total_archives || 0,
  1392. total_items: stats?.total_items || 0,
  1393. completed_count: stats?.completed_prints || 0,
  1394. failed_count: stats?.failed_prints || 0,
  1395. queue_count: stats?.queued_prints || 0,
  1396. progress_percent: stats?.progress_percent || null,
  1397. archives: [],
  1398. }}
  1399. onClose={() => setShowEditModal(false)}
  1400. onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
  1401. isLoading={updateMutation.isPending}
  1402. />
  1403. )}
  1404. {/* Confirm Modal */}
  1405. {confirmModal.isOpen && (
  1406. <ConfirmModal
  1407. title={confirmModal.title}
  1408. message={confirmModal.message}
  1409. confirmText={t('common.delete')}
  1410. variant="danger"
  1411. onConfirm={confirmModal.onConfirm}
  1412. onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
  1413. />
  1414. )}
  1415. {/* Print from project */}
  1416. {printFile && (
  1417. <PrintModal
  1418. mode="create"
  1419. libraryFileId={printFile.id}
  1420. archiveName={printFile.print_name || printFile.filename}
  1421. projectId={projectId}
  1422. onClose={() => setPrintFile(null)}
  1423. onSuccess={() => {
  1424. setPrintFile(null);
  1425. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1426. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1427. }}
  1428. />
  1429. )}
  1430. </div>
  1431. );
  1432. }