ProjectDetailPage.tsx 44 KB

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