ProjectDetailPage.tsx 61 KB

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