ProjectDetailPage.tsx 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435
  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. const currency = getCurrencySymbol(settings?.currency || 'USD');
  245. const timeFormat: TimeFormat = settings?.time_format || 'system';
  246. const updateMutation = useMutation({
  247. mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
  248. onSuccess: () => {
  249. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  250. queryClient.invalidateQueries({ queryKey: ['projects'] });
  251. setShowEditModal(false);
  252. setEditingNotes(false);
  253. showToast(t('projectDetail.toast.projectUpdated'), 'success');
  254. },
  255. onError: (error: Error) => {
  256. showToast(error.message, 'error');
  257. },
  258. });
  259. const handleStartEditNotes = () => {
  260. setNotesContent(project?.notes || '');
  261. setEditingNotes(true);
  262. };
  263. const handleSaveNotes = () => {
  264. updateMutation.mutate({ notes: notesContent });
  265. };
  266. const handleCancelNotes = () => {
  267. setEditingNotes(false);
  268. setNotesContent('');
  269. };
  270. // BOM handlers
  271. const [newBomName, setNewBomName] = useState('');
  272. const [newBomQty, setNewBomQty] = useState(1);
  273. const [newBomPrice, setNewBomPrice] = useState('');
  274. const [newBomUrl, setNewBomUrl] = useState('');
  275. const [newBomRemarks, setNewBomRemarks] = useState('');
  276. const [showBomForm, setShowBomForm] = useState(false);
  277. const [hideBomCompleted, setHideBomCompleted] = useState(false);
  278. const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
  279. const [editBomName, setEditBomName] = useState('');
  280. const [editBomQty, setEditBomQty] = useState(1);
  281. const [editBomPrice, setEditBomPrice] = useState('');
  282. const [editBomUrl, setEditBomUrl] = useState('');
  283. const [editBomRemarks, setEditBomRemarks] = useState('');
  284. // Confirm modal state
  285. const [confirmModal, setConfirmModal] = useState<{
  286. isOpen: boolean;
  287. title: string;
  288. message: string;
  289. onConfirm: () => void;
  290. }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
  291. const createBomMutation = useMutation({
  292. mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
  293. onSuccess: () => {
  294. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  295. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  296. setNewBomName('');
  297. setNewBomQty(1);
  298. setNewBomPrice('');
  299. setNewBomUrl('');
  300. setNewBomRemarks('');
  301. setShowBomForm(false);
  302. showToast(t('projectDetail.toast.partAdded'), 'success');
  303. },
  304. onError: (error: Error) => showToast(error.message, 'error'),
  305. });
  306. const updateBomMutation = useMutation({
  307. mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
  308. api.updateBOMItem(projectId, itemId, data),
  309. onSuccess: () => {
  310. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  311. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  312. setEditingBomItem(null);
  313. },
  314. onError: (error: Error) => showToast(error.message, 'error'),
  315. });
  316. const deleteBomMutation = useMutation({
  317. mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),
  318. onSuccess: () => {
  319. queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
  320. queryClient.invalidateQueries({ queryKey: ['project', projectId] });
  321. showToast(t('projectDetail.toast.partRemoved'), 'success');
  322. },
  323. onError: (error: Error) => showToast(error.message, 'error'),
  324. });
  325. const handleAddBomItem = (e: React.FormEvent) => {
  326. e.preventDefault();
  327. if (!newBomName.trim()) return;
  328. createBomMutation.mutate({
  329. name: newBomName.trim(),
  330. quantity_needed: newBomQty,
  331. unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
  332. sourcing_url: newBomUrl.trim() || undefined,
  333. remarks: newBomRemarks.trim() || undefined,
  334. });
  335. };
  336. const handleToggleAcquired = (item: BOMItem) => {
  337. const newQty = item.is_complete ? 0 : item.quantity_needed;
  338. updateBomMutation.mutate({
  339. itemId: item.id,
  340. data: { quantity_acquired: newQty },
  341. });
  342. };
  343. const handleDeleteBomItem = (itemId: number, itemName: string) => {
  344. setConfirmModal({
  345. isOpen: true,
  346. title: t('projectDetail.bom.deletePart'),
  347. message: t('projectDetail.bom.deleteConfirm', { name: itemName }),
  348. onConfirm: () => {
  349. setConfirmModal(prev => ({ ...prev, isOpen: false }));
  350. deleteBomMutation.mutate(itemId);
  351. },
  352. });
  353. };
  354. const handleEditBomItem = (item: BOMItem) => {
  355. setEditingBomItem(item);
  356. setEditBomName(item.name);
  357. setEditBomQty(item.quantity_needed);
  358. setEditBomPrice(item.unit_price?.toString() || '');
  359. setEditBomUrl(item.sourcing_url || '');
  360. setEditBomRemarks(item.remarks || '');
  361. };
  362. const handleSaveBomEdit = (e: React.FormEvent) => {
  363. e.preventDefault();
  364. if (!editingBomItem || !editBomName.trim()) return;
  365. updateBomMutation.mutate({
  366. itemId: editingBomItem.id,
  367. data: {
  368. name: editBomName.trim(),
  369. quantity_needed: editBomQty,
  370. unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
  371. sourcing_url: editBomUrl.trim() || undefined,
  372. remarks: editBomRemarks.trim() || undefined,
  373. },
  374. });
  375. };
  376. const handleCancelBomEdit = () => {
  377. setEditingBomItem(null);
  378. };
  379. const handleExportProject = async () => {
  380. try {
  381. const { blob, filename } = await api.exportProjectZip(Number(projectId));
  382. const url = URL.createObjectURL(blob);
  383. const a = document.createElement('a');
  384. a.href = url;
  385. a.download = filename || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
  386. a.click();
  387. URL.revokeObjectURL(url);
  388. showToast(t('projectDetail.toast.projectExported'), 'success');
  389. } catch (error) {
  390. showToast((error as Error).message, 'error');
  391. }
  392. };
  393. // Template handlers
  394. const createTemplateMutation = useMutation({
  395. mutationFn: () => api.createTemplateFromProject(projectId),
  396. onSuccess: () => {
  397. queryClient.invalidateQueries({ queryKey: ['projects'] });
  398. showToast(t('projectDetail.toast.templateCreated'), 'success');
  399. },
  400. onError: (error: Error) => showToast(error.message, 'error'),
  401. });
  402. const formatTimelineDate = (timestamp: string) => {
  403. return formatDateTime(timestamp, timeFormat, {
  404. month: 'short',
  405. day: 'numeric',
  406. hour: '2-digit',
  407. minute: '2-digit',
  408. });
  409. };
  410. if (projectLoading) {
  411. return (
  412. <div className="flex items-center justify-center py-24">
  413. <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
  414. </div>
  415. );
  416. }
  417. if (projectError || !project) {
  418. return (
  419. <div className="text-center py-24">
  420. <p className="text-bambu-gray">
  421. {projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
  422. </p>
  423. <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
  424. {t('projectDetail.backToProjects')}
  425. </Button>
  426. </div>
  427. );
  428. }
  429. const stats = project.stats;
  430. // Plates progress: total_archives / target_count
  431. const platesProgressPercent = stats?.progress_percent ?? 0;
  432. // Parts progress: completed_prints / target_parts_count
  433. const partsProgressPercent = stats?.parts_progress_percent ?? 0;
  434. return (
  435. <div className="p-4 md:p-8 space-y-8">
  436. {/* Breadcrumb */}
  437. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  438. <Link to="/projects" className="hover:text-white transition-colors">
  439. {t('nav.projects')}
  440. </Link>
  441. <ChevronRight className="w-4 h-4" />
  442. <span className="text-white">{project.name}</span>
  443. </div>
  444. {/* Header */}
  445. <div className="flex items-start justify-between">
  446. <div className="flex items-center gap-4">
  447. <button
  448. onClick={() => navigate('/projects')}
  449. className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
  450. >
  451. <ArrowLeft className="w-5 h-5 text-bambu-gray" />
  452. </button>
  453. <div className="flex items-center gap-3">
  454. <div
  455. className="w-4 h-4 rounded-full shrink-0"
  456. style={{ backgroundColor: project.color || '#6b7280' }}
  457. />
  458. <div>
  459. <h1 className="text-2xl font-bold text-white">{project.name}</h1>
  460. {project.description && (
  461. <p className="text-bambu-gray mt-1">{project.description}</p>
  462. )}
  463. </div>
  464. </div>
  465. <StatusBadge status={project.status} t={t} />
  466. </div>
  467. <div className="flex gap-2">
  468. <Button
  469. variant="secondary"
  470. onClick={handleExportProject}
  471. disabled={!hasPermission('projects:read')}
  472. title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
  473. >
  474. <Download className="w-4 h-4 mr-2" />
  475. {t('projectDetail.export')}
  476. </Button>
  477. <Button
  478. onClick={() => setShowEditModal(true)}
  479. disabled={!hasPermission('projects:update')}
  480. title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
  481. >
  482. <Edit3 className="w-4 h-4 mr-2" />
  483. {t('common.edit')}
  484. </Button>
  485. </div>
  486. </div>
  487. {/* Progress bars (if targets set) */}
  488. {(project.target_count || project.target_parts_count) && (
  489. <Card>
  490. <CardContent className="p-4 space-y-4">
  491. {/* Plates progress */}
  492. {project.target_count && (
  493. <div>
  494. <div className="flex items-center justify-between mb-2">
  495. <span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
  496. <span className="text-sm font-medium text-white">
  497. {stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
  498. </span>
  499. </div>
  500. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  501. <div
  502. className="h-full transition-all duration-500"
  503. style={{
  504. width: `${Math.min(platesProgressPercent, 100)}%`,
  505. backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
  506. }}
  507. />
  508. </div>
  509. <div className="flex justify-between mt-1">
  510. <span className="text-xs text-bambu-gray/70">
  511. {t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
  512. </span>
  513. {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
  514. <span className="text-xs text-bambu-gray/70">
  515. {t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
  516. </span>
  517. )}
  518. </div>
  519. </div>
  520. )}
  521. {/* Parts progress */}
  522. {project.target_parts_count && (
  523. <div>
  524. <div className="flex items-center justify-between mb-2">
  525. <span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
  526. <span className="text-sm font-medium text-white">
  527. {stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
  528. </span>
  529. </div>
  530. <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
  531. <div
  532. className="h-full transition-all duration-500"
  533. style={{
  534. width: `${Math.min(partsProgressPercent, 100)}%`,
  535. backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
  536. }}
  537. />
  538. </div>
  539. <div className="flex justify-between mt-1">
  540. <span className="text-xs text-bambu-gray/70">
  541. {t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
  542. </span>
  543. {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
  544. <span className="text-xs text-bambu-gray/70">
  545. {t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
  546. </span>
  547. )}
  548. </div>
  549. </div>
  550. )}
  551. </CardContent>
  552. </Card>
  553. )}
  554. {/* Stats grid */}
  555. {stats && (
  556. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  557. <Card>
  558. <CardContent className="p-4">
  559. <div className="flex items-center gap-3">
  560. <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
  561. <Package className="w-5 h-5" />
  562. </div>
  563. <div>
  564. <p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
  565. <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>
  566. {stats.failed_prints > 0 && (
  567. <p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
  568. )}
  569. <p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
  570. </div>
  571. </div>
  572. </CardContent>
  573. </Card>
  574. <StatCard
  575. icon={Clock}
  576. label={t('projectDetail.stats.printTime')}
  577. value={formatDurationFromHours(stats.total_print_time_hours)}
  578. color="text-yellow-600 dark:text-yellow-400"
  579. />
  580. <StatCard
  581. icon={Printer}
  582. label={t('projectDetail.stats.filamentUsed')}
  583. value={formatFilament(stats.total_filament_grams)}
  584. color="text-purple-600 dark:text-purple-400"
  585. />
  586. </div>
  587. )}
  588. {/* Cost tracking */}
  589. {stats && (() => {
  590. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  591. return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
  592. })() && (
  593. <Card>
  594. <CardContent className="p-4">
  595. <h2 className="text-lg font-semibold text-white mb-3">
  596. {t('projectDetail.cost.title')}
  597. </h2>
  598. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  599. <div>
  600. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
  601. <p className="text-lg font-semibold text-white">
  602. {currency}{stats.estimated_cost.toFixed(2)}
  603. </p>
  604. </div>
  605. {stats.total_energy_kwh > 0 && (
  606. <div>
  607. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
  608. <p className="text-lg font-semibold text-white">
  609. {stats.total_energy_kwh.toFixed(3)} kWh
  610. {stats.total_energy_cost > 0 && (
  611. <span className="text-sm text-bambu-gray ml-1">
  612. ({currency}{stats.total_energy_cost.toFixed(2)})
  613. </span>
  614. )}
  615. </p>
  616. </div>
  617. )}
  618. {(() => {
  619. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  620. if (totalCost <= 0) return null;
  621. return (
  622. <div>
  623. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
  624. <p className="text-lg font-semibold text-bambu-green">
  625. {currency}{totalCost.toFixed(2)}
  626. </p>
  627. {stats.bom_cost > 0 && (
  628. <p className="text-xs text-bambu-gray/70">{t('projectDetail.cost.includesBom')}</p>
  629. )}
  630. </div>
  631. );
  632. })()}
  633. {project.budget !== null && (() => {
  634. const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
  635. const remaining = project.budget - totalCost;
  636. return (
  637. <div>
  638. <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
  639. <p className="text-sm text-bambu-gray">
  640. {t('projectDetail.cost.total')}: <span className="text-white font-semibold">{currency}{project.budget.toFixed(2)}</span>
  641. </p>
  642. <p className={`text-sm ${remaining >= 0 ? 'text-bambu-green' : 'text-red-700 dark:text-red-400'}`}>
  643. {t('projectDetail.cost.remaining')}: <span className="font-semibold">{currency}{remaining.toFixed(2)}</span>
  644. </p>
  645. </div>
  646. );
  647. })()}
  648. </div>
  649. </CardContent>
  650. </Card>
  651. )}
  652. {/* Sub-projects */}
  653. {project.children && project.children.length > 0 && (
  654. <Card>
  655. <CardContent className="p-4">
  656. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
  657. <FolderTree className="w-5 h-5" />
  658. {t('projectDetail.subProjects.title', { count: project.children.length })}
  659. </h2>
  660. <div className="space-y-2">
  661. {project.children.map((child) => (
  662. <Link
  663. key={child.id}
  664. to={`/projects/${child.id}`}
  665. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  666. >
  667. <div className="flex items-center gap-3">
  668. <div
  669. className="w-3 h-3 rounded-full"
  670. style={{ backgroundColor: child.color || '#6b7280' }}
  671. />
  672. <span className="text-white">{child.name}</span>
  673. <span className={`text-xs px-2 py-0.5 rounded ${
  674. child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
  675. child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
  676. 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  677. }`}>
  678. {child.status}
  679. </span>
  680. </div>
  681. {child.progress_percent !== null && (
  682. <span className="text-sm text-bambu-gray">
  683. {child.progress_percent.toFixed(0)}%
  684. </span>
  685. )}
  686. </Link>
  687. ))}
  688. </div>
  689. </CardContent>
  690. </Card>
  691. )}
  692. {/* Parent project link */}
  693. {project.parent_id && project.parent_name && (
  694. <div className="flex items-center gap-2 text-sm">
  695. <Layers className="w-4 h-4 text-bambu-gray" />
  696. <span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
  697. <Link
  698. to={`/projects/${project.parent_id}`}
  699. className="text-bambu-green hover:underline"
  700. >
  701. {project.parent_name}
  702. </Link>
  703. </div>
  704. )}
  705. {/* Meta info row - Tags, Due Date, Priority */}
  706. {(project.tags || project.due_date || project.priority !== 'normal') && (
  707. <div className="flex flex-wrap items-center gap-4">
  708. {/* Priority */}
  709. {project.priority && project.priority !== 'normal' && (
  710. <div className="flex items-center gap-2">
  711. <span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
  712. <PriorityBadge priority={project.priority} t={t} />
  713. </div>
  714. )}
  715. {/* Due Date */}
  716. {project.due_date && (
  717. <div className="flex items-center gap-2">
  718. <Calendar className="w-4 h-4 text-bambu-gray" />
  719. <span className="text-sm text-white">{formatDateOnly(project.due_date, { year: 'numeric', month: 'short', day: 'numeric' })}</span>
  720. {getDueDateStatus(project.due_date, t) && (
  721. <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
  722. ({getDueDateStatus(project.due_date, t)!.label})
  723. </span>
  724. )}
  725. </div>
  726. )}
  727. {/* Tags */}
  728. {project.tags && (
  729. <div className="flex items-center gap-2">
  730. <Tag className="w-4 h-4 text-bambu-gray" />
  731. <div className="flex flex-wrap gap-1">
  732. {project.tags.split(',').map((tag, index) => (
  733. <span
  734. key={index}
  735. className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
  736. >
  737. {tag.trim()}
  738. </span>
  739. ))}
  740. </div>
  741. </div>
  742. )}
  743. </div>
  744. )}
  745. {/* Notes section */}
  746. <Card>
  747. <CardContent className="p-4">
  748. <div className="flex items-center justify-between mb-3">
  749. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  750. <FileText className="w-5 h-5" />
  751. {t('projectDetail.notes.title')}
  752. </h2>
  753. {!editingNotes ? (
  754. <Button
  755. variant="secondary"
  756. size="sm"
  757. onClick={handleStartEditNotes}
  758. disabled={!hasPermission('projects:update')}
  759. title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
  760. >
  761. <Edit3 className="w-4 h-4 mr-1" />
  762. {t('common.edit')}
  763. </Button>
  764. ) : (
  765. <div className="flex gap-2">
  766. <Button
  767. variant="secondary"
  768. size="sm"
  769. onClick={handleCancelNotes}
  770. disabled={updateMutation.isPending}
  771. >
  772. <X className="w-4 h-4 mr-1" />
  773. {t('common.cancel')}
  774. </Button>
  775. <Button
  776. size="sm"
  777. onClick={handleSaveNotes}
  778. disabled={updateMutation.isPending}
  779. >
  780. {updateMutation.isPending ? (
  781. <Loader2 className="w-4 h-4 animate-spin mr-1" />
  782. ) : (
  783. <Save className="w-4 h-4 mr-1" />
  784. )}
  785. {t('common.save')}
  786. </Button>
  787. </div>
  788. )}
  789. </div>
  790. {editingNotes ? (
  791. <RichTextEditor
  792. content={notesContent}
  793. onChange={setNotesContent}
  794. placeholder={t('projectDetail.notes.placeholder')}
  795. />
  796. ) : project.notes ? (
  797. <div
  798. className="prose prose-invert prose-sm max-w-none"
  799. dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(project.notes) }}
  800. />
  801. ) : (
  802. <p className="text-bambu-gray/70 text-sm italic">
  803. {t('projectDetail.notes.empty')}
  804. </p>
  805. )}
  806. </CardContent>
  807. </Card>
  808. {/* Files section - linked folders from File Manager with printable files */}
  809. <Card>
  810. <CardContent className="p-4">
  811. <div className="flex items-center justify-between mb-3">
  812. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  813. <FolderOpen className="w-5 h-5" />
  814. {t('projectDetail.files.title')}
  815. </h2>
  816. </div>
  817. <p className="text-xs text-bambu-gray mb-3">
  818. <Link to="/files" className="text-bambu-green hover:underline">
  819. {t('projectDetail.files.linkFolders')}
  820. </Link>
  821. {' '}{t('projectDetail.files.forQuickAccess')}
  822. </p>
  823. {linkedFolders && linkedFolders.length > 0 ? (
  824. <div className="space-y-4">
  825. {linkedFolders.map((folder) => {
  826. const files = filesByFolder.get(folder.id) ?? [];
  827. const isLoading = projectFilesLoading;
  828. return (
  829. <div key={folder.id}>
  830. {/* Folder header — links to File Manager */}
  831. <Link
  832. to={`/files?folder=${folder.id}`}
  833. className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors mb-2"
  834. >
  835. <div className="flex items-center gap-3 min-w-0">
  836. <FolderOpen className="w-5 h-5 text-bambu-green shrink-0" />
  837. <div className="min-w-0">
  838. <p className="text-sm text-white truncate">{folder.name}</p>
  839. <p className="text-xs text-bambu-gray">
  840. {t('projectDetail.files.fileCount', { count: folder.file_count })}
  841. </p>
  842. </div>
  843. </div>
  844. <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
  845. </Link>
  846. {/* File list within the folder */}
  847. {isLoading ? (
  848. <div className="flex items-center gap-2 px-3 py-2 text-bambu-gray text-sm">
  849. <Loader2 className="w-4 h-4 animate-spin" />
  850. </div>
  851. ) : files.length === 0 ? (
  852. <p className="text-bambu-gray/60 text-xs italic px-3">
  853. {t('projectDetail.files.noFiles')}
  854. </p>
  855. ) : (
  856. <div className="space-y-1 pl-3">
  857. {files.map((file) => {
  858. const printable = isSlicedFilename(file.filename);
  859. return (
  860. <div
  861. key={file.id}
  862. className="flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  863. >
  864. {/* Thumbnail */}
  865. <div className="w-10 h-10 shrink-0 rounded bg-bambu-dark overflow-hidden flex items-center justify-center">
  866. {file.thumbnail_path ? (
  867. <img
  868. src={api.getLibraryFileThumbnailUrl(file.id)}
  869. alt={file.print_name || file.filename}
  870. className="w-full h-full object-cover"
  871. />
  872. ) : (
  873. <FileBox className="w-5 h-5 text-bambu-gray/40" />
  874. )}
  875. </div>
  876. {/* Name + type badge */}
  877. <div className="flex-1 min-w-0">
  878. <p className="text-sm text-white truncate" title={file.print_name || file.filename}>
  879. {file.print_name || file.filename}
  880. </p>
  881. <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
  882. file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
  883. : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
  884. : 'bg-bambu-gray/20 text-bambu-gray'
  885. }`}>
  886. {file.file_type.toUpperCase()}
  887. </span>
  888. </div>
  889. {/* Print actions for sliced files */}
  890. {printable && (
  891. <div className="flex items-center gap-1 shrink-0">
  892. <button
  893. onClick={() => setPrintFile(file)}
  894. title={t('common.print')}
  895. className="p-1.5 rounded hover:bg-bambu-green/20 text-bambu-green transition-colors"
  896. >
  897. <Printer className="w-4 h-4" />
  898. </button>
  899. </div>
  900. )}
  901. </div>
  902. );
  903. })}
  904. </div>
  905. )}
  906. </div>
  907. );
  908. })}
  909. </div>
  910. ) : (
  911. <p className="text-bambu-gray/70 text-sm italic">
  912. {t('projectDetail.files.empty')}
  913. </p>
  914. )}
  915. </CardContent>
  916. </Card>
  917. {/* BOM Section - Parts to source/purchase */}
  918. <Card>
  919. <CardContent className="p-4">
  920. <div className="flex items-center justify-between mb-4">
  921. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  922. <ShoppingCart className="w-5 h-5" />
  923. {t('projectDetail.bom.title')}
  924. {stats && stats.bom_total_items > 0 && (
  925. <span className="text-sm font-normal text-bambu-gray">
  926. ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
  927. </span>
  928. )}
  929. </h2>
  930. <div className="flex items-center gap-2">
  931. {bomItems && bomItems.some(item => item.is_complete) && (
  932. <button
  933. onClick={() => setHideBomCompleted(!hideBomCompleted)}
  934. className={`text-xs px-2 py-1 rounded transition-colors ${
  935. hideBomCompleted
  936. ? 'bg-bambu-green/20 text-bambu-green'
  937. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  938. }`}
  939. >
  940. {hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
  941. </button>
  942. )}
  943. {!showBomForm && (
  944. <Button
  945. variant="secondary"
  946. size="sm"
  947. onClick={() => setShowBomForm(true)}
  948. disabled={!hasPermission('projects:update')}
  949. title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
  950. >
  951. <Plus className="w-4 h-4 mr-1" />
  952. {t('projectDetail.bom.addPart')}
  953. </Button>
  954. )}
  955. </div>
  956. </div>
  957. {/* Add BOM item form */}
  958. {showBomForm && (
  959. <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
  960. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  961. <input
  962. type="text"
  963. value={newBomName}
  964. onChange={(e) => setNewBomName(e.target.value)}
  965. 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"
  966. placeholder={t('projectDetail.bom.partNamePlaceholder')}
  967. autoFocus
  968. />
  969. <div className="flex gap-2">
  970. <input
  971. type="number"
  972. value={newBomQty}
  973. onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
  974. 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"
  975. min="1"
  976. placeholder={t('projectDetail.bom.qty')}
  977. />
  978. <input
  979. type="number"
  980. step="0.01"
  981. value={newBomPrice}
  982. onChange={(e) => setNewBomPrice(e.target.value)}
  983. 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"
  984. placeholder={t('projectDetail.bom.price', { currency })}
  985. />
  986. </div>
  987. </div>
  988. <input
  989. type="url"
  990. value={newBomUrl}
  991. onChange={(e) => setNewBomUrl(e.target.value)}
  992. 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"
  993. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  994. />
  995. <input
  996. type="text"
  997. value={newBomRemarks}
  998. onChange={(e) => setNewBomRemarks(e.target.value)}
  999. 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"
  1000. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1001. />
  1002. <div className="flex justify-end gap-2">
  1003. <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
  1004. {t('common.cancel')}
  1005. </Button>
  1006. <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
  1007. {createBomMutation.isPending ? (
  1008. <Loader2 className="w-4 h-4 animate-spin" />
  1009. ) : (
  1010. t('projectDetail.bom.addPart')
  1011. )}
  1012. </Button>
  1013. </div>
  1014. </form>
  1015. )}
  1016. {bomLoading ? (
  1017. <div className="flex items-center justify-center py-4">
  1018. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1019. </div>
  1020. ) : bomItems && bomItems.length > 0 ? (
  1021. <div className="space-y-2">
  1022. {bomItems
  1023. .filter(item => !hideBomCompleted || !item.is_complete)
  1024. .map((item) => (
  1025. <div
  1026. key={item.id}
  1027. className={`p-3 rounded-lg transition-colors ${
  1028. item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
  1029. }`}
  1030. >
  1031. {editingBomItem?.id === item.id ? (
  1032. // Edit form for this BOM item
  1033. <form onSubmit={handleSaveBomEdit} className="space-y-3">
  1034. <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
  1035. <input
  1036. type="text"
  1037. value={editBomName}
  1038. onChange={(e) => setEditBomName(e.target.value)}
  1039. 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"
  1040. placeholder={t('projectDetail.bom.partName')}
  1041. autoFocus
  1042. />
  1043. <div className="flex gap-2">
  1044. <input
  1045. type="number"
  1046. value={editBomQty}
  1047. onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
  1048. 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"
  1049. min="1"
  1050. placeholder={t('projectDetail.bom.qty')}
  1051. />
  1052. <input
  1053. type="number"
  1054. step="0.01"
  1055. value={editBomPrice}
  1056. onChange={(e) => setEditBomPrice(e.target.value)}
  1057. 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"
  1058. placeholder={t('projectDetail.bom.price', { currency })}
  1059. />
  1060. </div>
  1061. </div>
  1062. <input
  1063. type="url"
  1064. value={editBomUrl}
  1065. onChange={(e) => setEditBomUrl(e.target.value)}
  1066. 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"
  1067. placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
  1068. />
  1069. <input
  1070. type="text"
  1071. value={editBomRemarks}
  1072. onChange={(e) => setEditBomRemarks(e.target.value)}
  1073. 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"
  1074. placeholder={t('projectDetail.bom.remarksPlaceholder')}
  1075. />
  1076. <div className="flex justify-end gap-2">
  1077. <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
  1078. {t('common.cancel')}
  1079. </Button>
  1080. <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
  1081. {updateBomMutation.isPending ? (
  1082. <Loader2 className="w-4 h-4 animate-spin" />
  1083. ) : (
  1084. t('common.save')
  1085. )}
  1086. </Button>
  1087. </div>
  1088. </form>
  1089. ) : (
  1090. // Display mode
  1091. <div className="flex items-start gap-3">
  1092. <button
  1093. onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
  1094. disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
  1095. title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
  1096. className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors shrink-0 ${
  1097. item.is_complete
  1098. ? 'bg-status-ok border-status-ok text-white'
  1099. : hasPermission('projects:update')
  1100. ? 'border-bambu-gray hover:border-bambu-green'
  1101. : 'border-bambu-gray/50 cursor-not-allowed'
  1102. }`}
  1103. >
  1104. {item.is_complete && <CheckCircle className="w-3 h-3" />}
  1105. </button>
  1106. <div className="flex-1 min-w-0">
  1107. <div className="flex items-center justify-between gap-2">
  1108. <div className="flex items-center gap-2 min-w-0">
  1109. <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
  1110. {item.name}
  1111. <span className="text-bambu-gray font-normal ml-2">
  1112. x{item.quantity_needed}
  1113. </span>
  1114. </p>
  1115. {item.unit_price !== null && (
  1116. <span className="text-xs text-bambu-green whitespace-nowrap">
  1117. {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
  1118. </span>
  1119. )}
  1120. </div>
  1121. <div className="flex items-center gap-1">
  1122. <button
  1123. onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
  1124. disabled={!hasPermission('projects:update')}
  1125. className={`p-1 rounded transition-colors shrink-0 ${
  1126. hasPermission('projects:update')
  1127. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
  1128. : 'text-bambu-gray/50 cursor-not-allowed'
  1129. }`}
  1130. title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
  1131. >
  1132. <Pencil className="w-4 h-4" />
  1133. </button>
  1134. <button
  1135. onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
  1136. disabled={!hasPermission('projects:update')}
  1137. className={`p-1 rounded transition-colors shrink-0 ${
  1138. hasPermission('projects:update')
  1139. ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
  1140. : 'text-bambu-gray/50 cursor-not-allowed'
  1141. }`}
  1142. title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
  1143. >
  1144. <Trash2 className="w-4 h-4" />
  1145. </button>
  1146. </div>
  1147. </div>
  1148. {/* Sourcing URL */}
  1149. {item.sourcing_url && (
  1150. <a
  1151. href={item.sourcing_url}
  1152. target="_blank"
  1153. rel="noopener noreferrer"
  1154. 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"
  1155. onClick={(e) => e.stopPropagation()}
  1156. >
  1157. <ExternalLink className="w-3 h-3 shrink-0" />
  1158. <span className="truncate">
  1159. {(() => {
  1160. try {
  1161. return new URL(item.sourcing_url).hostname.replace('www.', '');
  1162. } catch {
  1163. return item.sourcing_url;
  1164. }
  1165. })()}
  1166. </span>
  1167. </a>
  1168. )}
  1169. {/* Remarks */}
  1170. {item.remarks && (
  1171. <p className="mt-1 text-xs text-bambu-gray/80 italic">
  1172. {item.remarks}
  1173. </p>
  1174. )}
  1175. </div>
  1176. </div>
  1177. )}
  1178. </div>
  1179. ))}
  1180. {/* BOM Total */}
  1181. {stats && stats.bom_cost > 0 && (
  1182. <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
  1183. <span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
  1184. <span className="text-white font-medium">
  1185. {currency}{stats.bom_cost.toFixed(2)}
  1186. </span>
  1187. </div>
  1188. )}
  1189. </div>
  1190. ) : (
  1191. <p className="text-bambu-gray/70 text-sm italic">
  1192. {t('projectDetail.bom.empty')}
  1193. </p>
  1194. )}
  1195. </CardContent>
  1196. </Card>
  1197. {/* Timeline Section */}
  1198. <Card>
  1199. <CardContent className="p-4">
  1200. <div className="flex items-center justify-between mb-3">
  1201. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1202. <History className="w-5 h-5" />
  1203. {t('projectDetail.timeline.title')}
  1204. </h2>
  1205. </div>
  1206. {timelineLoading ? (
  1207. <div className="flex items-center justify-center py-4">
  1208. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1209. </div>
  1210. ) : timeline && timeline.length > 0 ? (
  1211. <div className="space-y-3">
  1212. {timeline.slice(0, 10).map((event, index) => (
  1213. <div key={index} className="flex gap-3">
  1214. <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
  1215. event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
  1216. event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
  1217. event.event_type === 'print_started' ? 'bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400' :
  1218. 'bg-bambu-dark-tertiary text-bambu-gray'
  1219. }`}>
  1220. {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
  1221. {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
  1222. {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
  1223. {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
  1224. {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
  1225. </div>
  1226. <div className="flex-1 min-w-0">
  1227. <p className="text-sm text-white">{event.title}</p>
  1228. {event.description && (
  1229. <p className="text-xs text-bambu-gray truncate">{event.description}</p>
  1230. )}
  1231. <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
  1232. </div>
  1233. </div>
  1234. ))}
  1235. </div>
  1236. ) : (
  1237. <p className="text-bambu-gray/70 text-sm italic">
  1238. {t('projectDetail.timeline.empty')}
  1239. </p>
  1240. )}
  1241. </CardContent>
  1242. </Card>
  1243. {/* Template action */}
  1244. {!project.is_template && (
  1245. <div className="flex justify-end">
  1246. <Button
  1247. variant="secondary"
  1248. size="sm"
  1249. onClick={() => createTemplateMutation.mutate()}
  1250. disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
  1251. title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
  1252. >
  1253. {createTemplateMutation.isPending ? (
  1254. <Loader2 className="w-4 h-4 animate-spin mr-2" />
  1255. ) : (
  1256. <Copy className="w-4 h-4 mr-2" />
  1257. )}
  1258. {t('projectDetail.template.saveAsTemplate')}
  1259. </Button>
  1260. </div>
  1261. )}
  1262. {/* Queue section */}
  1263. {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
  1264. <Card>
  1265. <CardContent className="p-4">
  1266. <div className="flex items-center justify-between mb-3">
  1267. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1268. <ListTodo className="w-5 h-5" />
  1269. {t('projectDetail.queue.title')}
  1270. </h2>
  1271. <Link
  1272. to={`/queue?project=${projectId}`}
  1273. className="text-sm text-bambu-green hover:underline"
  1274. >
  1275. {t('projectDetail.queue.viewAll')}
  1276. </Link>
  1277. </div>
  1278. <div className="flex items-center gap-4 text-sm">
  1279. {stats.in_progress_prints > 0 && (
  1280. <span className="text-yellow-700 dark:text-yellow-400">
  1281. {t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
  1282. </span>
  1283. )}
  1284. {stats.queued_prints > 0 && (
  1285. <span className="text-bambu-gray">
  1286. {t('projectDetail.queue.queued', { count: stats.queued_prints })}
  1287. </span>
  1288. )}
  1289. </div>
  1290. </CardContent>
  1291. </Card>
  1292. )}
  1293. {/* Archives section */}
  1294. <div>
  1295. <div className="flex items-center justify-between mb-4">
  1296. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1297. <Package className="w-5 h-5" />
  1298. {t('projectDetail.prints.title', { count: archives?.length || 0 })}
  1299. </h2>
  1300. </div>
  1301. {archivesLoading ? (
  1302. <div className="flex items-center justify-center py-8">
  1303. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  1304. </div>
  1305. ) : (
  1306. <ArchiveGrid archives={archives || []} t={t} />
  1307. )}
  1308. </div>
  1309. {/* Edit Modal */}
  1310. {showEditModal && (
  1311. <ProjectModal
  1312. t={t}
  1313. currencySymbol={currency}
  1314. project={{
  1315. ...project,
  1316. archive_count: stats?.total_archives || 0,
  1317. total_items: stats?.total_items || 0,
  1318. completed_count: stats?.completed_prints || 0,
  1319. failed_count: stats?.failed_prints || 0,
  1320. queue_count: stats?.queued_prints || 0,
  1321. progress_percent: stats?.progress_percent || null,
  1322. archives: [],
  1323. }}
  1324. onClose={() => setShowEditModal(false)}
  1325. onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
  1326. isLoading={updateMutation.isPending}
  1327. />
  1328. )}
  1329. {/* Confirm Modal */}
  1330. {confirmModal.isOpen && (
  1331. <ConfirmModal
  1332. title={confirmModal.title}
  1333. message={confirmModal.message}
  1334. confirmText={t('common.delete')}
  1335. variant="danger"
  1336. onConfirm={confirmModal.onConfirm}
  1337. onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
  1338. />
  1339. )}
  1340. {/* Print from project */}
  1341. {printFile && (
  1342. <PrintModal
  1343. mode="create"
  1344. libraryFileId={printFile.id}
  1345. archiveName={printFile.print_name || printFile.filename}
  1346. projectId={projectId}
  1347. onClose={() => setPrintFile(null)}
  1348. onSuccess={() => {
  1349. setPrintFile(null);
  1350. queryClient.invalidateQueries({ queryKey: ['archives'] });
  1351. queryClient.invalidateQueries({ queryKey: ['queue'] });
  1352. }}
  1353. />
  1354. )}
  1355. </div>
  1356. );
  1357. }