ProjectDetailPage.tsx 52 KB

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