ProjectDetailPage.tsx 55 KB

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