ProjectDetailPage.tsx 54 KB

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