| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311 |
- import { useState } from 'react';
- import { useParams, useNavigate, Link } from 'react-router-dom';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import {
- ArrowLeft,
- Edit3,
- Loader2,
- Package,
- Clock,
- CheckCircle,
- XCircle,
- ListTodo,
- Printer,
- ChevronRight,
- FileText,
- Tag,
- Calendar,
- AlertTriangle,
- Save,
- X,
- Trash2,
- Plus,
- History,
- FolderTree,
- Copy,
- Layers,
- ExternalLink,
- ShoppingCart,
- FolderOpen,
- Download,
- Pencil,
- } from 'lucide-react';
- import { api } from '../api/client';
- import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
- import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
- import { Card, CardContent } from '../components/Card';
- import { Button } from '../components/Button';
- import { useToast } from '../contexts/ToastContext';
- import { useAuth } from '../contexts/AuthContext';
- import { RichTextEditor } from '../components/RichTextEditor';
- import { ConfirmModal } from '../components/ConfirmModal';
- // Project edit modal (reused from ProjectsPage)
- import { ProjectModal } from './ProjectsPage';
- function formatDuration(hours: number): string {
- if (hours < 1) {
- return `${Math.round(hours * 60)}m`;
- }
- const h = Math.floor(hours);
- const m = Math.round((hours - h) * 60);
- return m > 0 ? `${h}h ${m}m` : `${h}h`;
- }
- function formatFilament(grams: number): string {
- if (grams >= 1000) {
- return `${(grams / 1000).toFixed(2)}kg`;
- }
- return `${Math.round(grams)}g`;
- }
- function StatusBadge({ status }: { status: string }) {
- const colors = {
- active: 'bg-bambu-green/20 text-bambu-green',
- completed: 'bg-blue-500/20 text-blue-400',
- archived: 'bg-bambu-gray/20 text-bambu-gray',
- };
- const color = colors[status as keyof typeof colors] || colors.active;
- return (
- <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
- {status.charAt(0).toUpperCase() + status.slice(1)}
- </span>
- );
- }
- function StatCard({
- icon: Icon,
- label,
- value,
- subValue,
- hint,
- color = 'text-bambu-gray',
- }: {
- icon: React.ElementType;
- label: string;
- value: string | number;
- subValue?: string;
- hint?: string;
- color?: string;
- }) {
- return (
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center gap-3" title={hint}>
- <div className={`p-2 rounded-lg bg-bambu-dark ${color}`}>
- <Icon className="w-5 h-5" />
- </div>
- <div>
- <p className="text-sm text-bambu-gray">{label}</p>
- <p className="text-xl font-semibold text-white">{value}</p>
- {subValue && <p className="text-xs text-bambu-gray/70">{subValue}</p>}
- </div>
- </div>
- </CardContent>
- </Card>
- );
- }
- function ArchiveGrid({ archives }: { archives: Archive[] }) {
- if (archives.length === 0) {
- return (
- <div className="text-center py-8 text-bambu-gray">
- <Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
- <p>No prints in this project yet</p>
- </div>
- );
- }
- return (
- <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
- {archives.map((archive) => (
- <Link
- key={archive.id}
- to={`/archives?search=${encodeURIComponent(archive.print_name || '')}`}
- className="group relative aspect-square rounded-lg bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden hover:border-bambu-green transition-colors"
- >
- {archive.thumbnail_path ? (
- <img
- src={api.getArchiveThumbnail(archive.id)}
- alt={archive.print_name || 'Print'}
- className="w-full h-full object-cover"
- />
- ) : (
- <div className="w-full h-full flex items-center justify-center text-bambu-gray">
- <Package className="w-8 h-8" />
- </div>
- )}
- {/* Status overlay */}
- {archive.status === 'failed' && (
- <div className="absolute inset-0 bg-red-500/30 flex items-center justify-center">
- <XCircle className="w-8 h-8 text-white" />
- </div>
- )}
- {archive.status === 'completed' && (
- <div className="absolute top-1 right-1">
- <CheckCircle className="w-4 h-4 text-bambu-green" />
- </div>
- )}
- {/* Name overlay on hover */}
- <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">
- <p className="text-xs text-white truncate">{archive.print_name || 'Unknown'}</p>
- </div>
- </Link>
- ))}
- </div>
- );
- }
- function PriorityBadge({ priority }: { priority: string }) {
- const config = {
- low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
- normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
- high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
- urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
- };
- const { color, label } = config[priority as keyof typeof config] || config.normal;
- return (
- <span className={`px-2 py-1 rounded text-xs font-medium flex items-center gap-1 ${color}`}>
- {priority === 'urgent' && <AlertTriangle className="w-3 h-3" />}
- {label}
- </span>
- );
- }
- function formatDate(dateString: string | null): string {
- if (!dateString) return '';
- return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
- }
- function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
- if (!dateString) return null;
- const dueDate = parseUTCDate(dateString);
- if (!dueDate) return null;
- const now = new Date();
- const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
- if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
- if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
- if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
- return { color: 'text-bambu-gray', label: `${diffDays} days left` };
- }
- export function ProjectDetailPage() {
- const { id } = useParams<{ id: string }>();
- const navigate = useNavigate();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const { hasPermission } = useAuth();
- const [showEditModal, setShowEditModal] = useState(false);
- const [editingNotes, setEditingNotes] = useState(false);
- const [notesContent, setNotesContent] = useState('');
- const projectId = parseInt(id || '0', 10);
- const { data: project, isLoading: projectLoading, error: projectError } = useQuery({
- queryKey: ['project', projectId],
- queryFn: () => api.getProject(projectId),
- enabled: projectId > 0,
- });
- const { data: archives, isLoading: archivesLoading } = useQuery({
- queryKey: ['project-archives', projectId],
- queryFn: () => api.getProjectArchives(projectId),
- enabled: projectId > 0,
- });
- const { data: bomItems, isLoading: bomLoading } = useQuery({
- queryKey: ['project-bom', projectId],
- queryFn: () => api.getProjectBOM(projectId),
- enabled: projectId > 0,
- });
- const { data: timeline, isLoading: timelineLoading } = useQuery({
- queryKey: ['project-timeline', projectId],
- queryFn: () => api.getProjectTimeline(projectId, 20),
- enabled: projectId > 0,
- });
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: api.getSettings,
- });
- const { data: linkedFolders } = useQuery({
- queryKey: ['project-folders', projectId],
- queryFn: () => api.getLibraryFoldersByProject(projectId),
- enabled: projectId > 0,
- });
- const currency = settings?.currency || '$';
- const timeFormat: TimeFormat = settings?.time_format || 'system';
- const updateMutation = useMutation({
- mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['project', projectId] });
- queryClient.invalidateQueries({ queryKey: ['projects'] });
- setShowEditModal(false);
- setEditingNotes(false);
- showToast('Project updated', 'success');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const handleStartEditNotes = () => {
- setNotesContent(project?.notes || '');
- setEditingNotes(true);
- };
- const handleSaveNotes = () => {
- updateMutation.mutate({ notes: notesContent });
- };
- const handleCancelNotes = () => {
- setEditingNotes(false);
- setNotesContent('');
- };
- // BOM handlers
- const [newBomName, setNewBomName] = useState('');
- const [newBomQty, setNewBomQty] = useState(1);
- const [newBomPrice, setNewBomPrice] = useState('');
- const [newBomUrl, setNewBomUrl] = useState('');
- const [newBomRemarks, setNewBomRemarks] = useState('');
- const [showBomForm, setShowBomForm] = useState(false);
- const [hideBomCompleted, setHideBomCompleted] = useState(false);
- const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
- const [editBomName, setEditBomName] = useState('');
- const [editBomQty, setEditBomQty] = useState(1);
- const [editBomPrice, setEditBomPrice] = useState('');
- const [editBomUrl, setEditBomUrl] = useState('');
- const [editBomRemarks, setEditBomRemarks] = useState('');
- // Confirm modal state
- const [confirmModal, setConfirmModal] = useState<{
- isOpen: boolean;
- title: string;
- message: string;
- onConfirm: () => void;
- }>({ isOpen: false, title: '', message: '', onConfirm: () => {} });
- const createBomMutation = useMutation({
- mutationFn: (data: BOMItemCreate) => api.createBOMItem(projectId, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
- queryClient.invalidateQueries({ queryKey: ['project', projectId] });
- setNewBomName('');
- setNewBomQty(1);
- setNewBomPrice('');
- setNewBomUrl('');
- setNewBomRemarks('');
- setShowBomForm(false);
- showToast('Part added', 'success');
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const updateBomMutation = useMutation({
- mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
- api.updateBOMItem(projectId, itemId, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
- queryClient.invalidateQueries({ queryKey: ['project', projectId] });
- setEditingBomItem(null);
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const deleteBomMutation = useMutation({
- mutationFn: (itemId: number) => api.deleteBOMItem(projectId, itemId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
- queryClient.invalidateQueries({ queryKey: ['project', projectId] });
- showToast('Part removed', 'success');
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const handleAddBomItem = (e: React.FormEvent) => {
- e.preventDefault();
- if (!newBomName.trim()) return;
- createBomMutation.mutate({
- name: newBomName.trim(),
- quantity_needed: newBomQty,
- unit_price: newBomPrice ? parseFloat(newBomPrice) : undefined,
- sourcing_url: newBomUrl.trim() || undefined,
- remarks: newBomRemarks.trim() || undefined,
- });
- };
- const handleToggleAcquired = (item: BOMItem) => {
- const newQty = item.is_complete ? 0 : item.quantity_needed;
- updateBomMutation.mutate({
- itemId: item.id,
- data: { quantity_acquired: newQty },
- });
- };
- const handleDeleteBomItem = (itemId: number, itemName: string) => {
- setConfirmModal({
- isOpen: true,
- title: 'Delete Part',
- message: `Are you sure you want to delete "${itemName}"?`,
- onConfirm: () => {
- setConfirmModal(prev => ({ ...prev, isOpen: false }));
- deleteBomMutation.mutate(itemId);
- },
- });
- };
- const handleEditBomItem = (item: BOMItem) => {
- setEditingBomItem(item);
- setEditBomName(item.name);
- setEditBomQty(item.quantity_needed);
- setEditBomPrice(item.unit_price?.toString() || '');
- setEditBomUrl(item.sourcing_url || '');
- setEditBomRemarks(item.remarks || '');
- };
- const handleSaveBomEdit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!editingBomItem || !editBomName.trim()) return;
- updateBomMutation.mutate({
- itemId: editingBomItem.id,
- data: {
- name: editBomName.trim(),
- quantity_needed: editBomQty,
- unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
- sourcing_url: editBomUrl.trim() || undefined,
- remarks: editBomRemarks.trim() || undefined,
- },
- });
- };
- const handleCancelBomEdit = () => {
- setEditingBomItem(null);
- };
- const handleExportProject = async () => {
- try {
- // Fetch ZIP file directly
- const response = await fetch(`/api/v1/projects/${projectId}/export`);
- if (!response.ok) {
- throw new Error('Export failed');
- }
- const blob = await response.blob();
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- // Get filename from Content-Disposition header or use default
- const contentDisposition = response.headers.get('Content-Disposition');
- const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
- a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
- a.click();
- URL.revokeObjectURL(url);
- showToast('Project exported', 'success');
- } catch (error) {
- showToast((error as Error).message, 'error');
- }
- };
- // Template handlers
- const createTemplateMutation = useMutation({
- mutationFn: () => api.createTemplateFromProject(projectId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['projects'] });
- showToast('Template created', 'success');
- },
- onError: (error: Error) => showToast(error.message, 'error'),
- });
- const formatTimelineDate = (timestamp: string) => {
- return formatDateTime(timestamp, timeFormat, {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- };
- if (projectLoading) {
- return (
- <div className="flex items-center justify-center py-24">
- <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
- </div>
- );
- }
- if (projectError || !project) {
- return (
- <div className="text-center py-24">
- <p className="text-bambu-gray">
- {projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
- </p>
- <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
- Back to Projects
- </Button>
- </div>
- );
- }
- const stats = project.stats;
- // Plates progress: total_archives / target_count
- const platesProgressPercent = stats?.progress_percent ?? 0;
- // Parts progress: completed_prints / target_parts_count
- const partsProgressPercent = stats?.parts_progress_percent ?? 0;
- return (
- <div className="p-4 md:p-8 space-y-8">
- {/* Breadcrumb */}
- <div className="flex items-center gap-2 text-sm text-bambu-gray">
- <Link to="/projects" className="hover:text-white transition-colors">
- Projects
- </Link>
- <ChevronRight className="w-4 h-4" />
- <span className="text-white">{project.name}</span>
- </div>
- {/* Header */}
- <div className="flex items-start justify-between">
- <div className="flex items-center gap-4">
- <button
- onClick={() => navigate('/projects')}
- className="p-2 rounded-lg bg-bambu-card hover:bg-bambu-dark-tertiary transition-colors"
- >
- <ArrowLeft className="w-5 h-5 text-bambu-gray" />
- </button>
- <div className="flex items-center gap-3">
- <div
- className="w-4 h-4 rounded-full flex-shrink-0"
- style={{ backgroundColor: project.color || '#6b7280' }}
- />
- <div>
- <h1 className="text-2xl font-bold text-white">{project.name}</h1>
- {project.description && (
- <p className="text-bambu-gray mt-1">{project.description}</p>
- )}
- </div>
- </div>
- <StatusBadge status={project.status} />
- </div>
- <div className="flex gap-2">
- <Button
- variant="secondary"
- onClick={handleExportProject}
- disabled={!hasPermission('projects:read')}
- title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
- >
- <Download className="w-4 h-4 mr-2" />
- Export
- </Button>
- <Button
- onClick={() => setShowEditModal(true)}
- disabled={!hasPermission('projects:update')}
- title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
- >
- <Edit3 className="w-4 h-4 mr-2" />
- Edit
- </Button>
- </div>
- </div>
- {/* Progress bars (if targets set) */}
- {(project.target_count || project.target_parts_count) && (
- <Card>
- <CardContent className="p-4 space-y-4">
- {/* Plates progress */}
- {project.target_count && (
- <div>
- <div className="flex items-center justify-between mb-2">
- <span className="text-sm text-bambu-gray">Plates Progress</span>
- <span className="text-sm font-medium text-white">
- {stats?.total_archives || 0} / {project.target_count} print jobs
- </span>
- </div>
- <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
- <div
- className="h-full transition-all duration-500"
- style={{
- width: `${Math.min(platesProgressPercent, 100)}%`,
- backgroundColor: platesProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
- }}
- />
- </div>
- <div className="flex justify-between mt-1">
- <span className="text-xs text-bambu-gray/70">
- {platesProgressPercent.toFixed(0)}% complete
- </span>
- {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
- <span className="text-xs text-bambu-gray/70">
- {stats.remaining_prints} remaining
- </span>
- )}
- </div>
- </div>
- )}
- {/* Parts progress */}
- {project.target_parts_count && (
- <div>
- <div className="flex items-center justify-between mb-2">
- <span className="text-sm text-bambu-gray">Parts Progress</span>
- <span className="text-sm font-medium text-white">
- {stats?.completed_prints || 0} / {project.target_parts_count} parts
- </span>
- </div>
- <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
- <div
- className="h-full transition-all duration-500"
- style={{
- width: `${Math.min(partsProgressPercent, 100)}%`,
- backgroundColor: partsProgressPercent >= 100 ? '#22c55e' : project.color || '#6b7280',
- }}
- />
- </div>
- <div className="flex justify-between mt-1">
- <span className="text-xs text-bambu-gray/70">
- {partsProgressPercent.toFixed(0)}% complete
- </span>
- {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
- <span className="text-xs text-bambu-gray/70">
- {stats.remaining_parts} remaining
- </span>
- )}
- </div>
- </div>
- )}
- </CardContent>
- </Card>
- )}
- {/* Stats grid */}
- {stats && (
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center gap-3">
- <div className="p-2 rounded-lg bg-bambu-dark text-bambu-green">
- <Package className="w-5 h-5" />
- </div>
- <div>
- <p className="text-sm text-bambu-gray">Print Jobs</p>
- <p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
- {stats.failed_prints > 0 && (
- <p className="text-sm text-status-error">{stats.failed_prints} failed</p>
- )}
- <p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
- </div>
- </div>
- </CardContent>
- </Card>
- <StatCard
- icon={Clock}
- label="Print Time"
- value={formatDuration(stats.total_print_time_hours)}
- color="text-yellow-400"
- />
- <StatCard
- icon={Printer}
- label="Filament Used"
- value={formatFilament(stats.total_filament_grams)}
- color="text-purple-400"
- />
- </div>
- )}
- {/* Cost tracking */}
- {stats && (stats.estimated_cost > 0 || project.budget) && (
- <Card>
- <CardContent className="p-4">
- <h2 className="text-lg font-semibold text-white mb-3">
- Cost Tracking
- </h2>
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <div>
- <p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
- <p className="text-lg font-semibold text-white">
- {currency}{stats.estimated_cost.toFixed(2)}
- </p>
- </div>
- {stats.total_energy_kwh > 0 && (
- <div>
- <p className="text-xs text-bambu-gray uppercase">Energy</p>
- <p className="text-lg font-semibold text-white">
- {stats.total_energy_kwh.toFixed(2)} kWh
- {stats.total_energy_cost > 0 && (
- <span className="text-sm text-bambu-gray ml-1">
- ({currency}{stats.total_energy_cost.toFixed(2)})
- </span>
- )}
- </p>
- </div>
- )}
- {project.budget && (
- <>
- <div>
- <p className="text-xs text-bambu-gray uppercase">Budget</p>
- <p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
- </div>
- <div>
- <p className="text-xs text-bambu-gray uppercase">Remaining</p>
- <p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
- {currency}{(project.budget - stats.estimated_cost).toFixed(2)}
- </p>
- </div>
- </>
- )}
- </div>
- </CardContent>
- </Card>
- )}
- {/* Sub-projects */}
- {project.children && project.children.length > 0 && (
- <Card>
- <CardContent className="p-4">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
- <FolderTree className="w-5 h-5" />
- Sub-projects ({project.children.length})
- </h2>
- <div className="space-y-2">
- {project.children.map((child) => (
- <Link
- key={child.id}
- to={`/projects/${child.id}`}
- className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
- >
- <div className="flex items-center gap-3">
- <div
- className="w-3 h-3 rounded-full"
- style={{ backgroundColor: child.color || '#6b7280' }}
- />
- <span className="text-white">{child.name}</span>
- <span className={`text-xs px-2 py-0.5 rounded ${
- child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
- child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
- 'bg-blue-500/20 text-blue-400'
- }`}>
- {child.status}
- </span>
- </div>
- {child.progress_percent !== null && (
- <span className="text-sm text-bambu-gray">
- {child.progress_percent.toFixed(0)}%
- </span>
- )}
- </Link>
- ))}
- </div>
- </CardContent>
- </Card>
- )}
- {/* Parent project link */}
- {project.parent_id && project.parent_name && (
- <div className="flex items-center gap-2 text-sm">
- <Layers className="w-4 h-4 text-bambu-gray" />
- <span className="text-bambu-gray">Part of:</span>
- <Link
- to={`/projects/${project.parent_id}`}
- className="text-bambu-green hover:underline"
- >
- {project.parent_name}
- </Link>
- </div>
- )}
- {/* Meta info row - Tags, Due Date, Priority */}
- {(project.tags || project.due_date || project.priority !== 'normal') && (
- <div className="flex flex-wrap items-center gap-4">
- {/* Priority */}
- {project.priority && project.priority !== 'normal' && (
- <div className="flex items-center gap-2">
- <span className="text-xs text-bambu-gray uppercase">Priority:</span>
- <PriorityBadge priority={project.priority} />
- </div>
- )}
- {/* Due Date */}
- {project.due_date && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-bambu-gray" />
- <span className="text-sm text-white">{formatDate(project.due_date)}</span>
- {getDueDateStatus(project.due_date) && (
- <span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
- ({getDueDateStatus(project.due_date)!.label})
- </span>
- )}
- </div>
- )}
- {/* Tags */}
- {project.tags && (
- <div className="flex items-center gap-2">
- <Tag className="w-4 h-4 text-bambu-gray" />
- <div className="flex flex-wrap gap-1">
- {project.tags.split(',').map((tag, index) => (
- <span
- key={index}
- className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded"
- >
- {tag.trim()}
- </span>
- ))}
- </div>
- </div>
- )}
- </div>
- )}
- {/* Notes section */}
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center justify-between mb-3">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <FileText className="w-5 h-5" />
- Notes
- </h2>
- {!editingNotes ? (
- <Button
- variant="secondary"
- size="sm"
- onClick={handleStartEditNotes}
- disabled={!hasPermission('projects:update')}
- title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
- >
- <Edit3 className="w-4 h-4 mr-1" />
- Edit
- </Button>
- ) : (
- <div className="flex gap-2">
- <Button
- variant="secondary"
- size="sm"
- onClick={handleCancelNotes}
- disabled={updateMutation.isPending}
- >
- <X className="w-4 h-4 mr-1" />
- Cancel
- </Button>
- <Button
- size="sm"
- onClick={handleSaveNotes}
- disabled={updateMutation.isPending}
- >
- {updateMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin mr-1" />
- ) : (
- <Save className="w-4 h-4 mr-1" />
- )}
- Save
- </Button>
- </div>
- )}
- </div>
- {editingNotes ? (
- <RichTextEditor
- content={notesContent}
- onChange={setNotesContent}
- placeholder="Add notes about this project..."
- />
- ) : project.notes ? (
- <div
- className="prose prose-invert prose-sm max-w-none"
- dangerouslySetInnerHTML={{ __html: project.notes }}
- />
- ) : (
- <p className="text-bambu-gray/70 text-sm italic">
- No notes yet. Click Edit to add notes.
- </p>
- )}
- </CardContent>
- </Card>
- {/* Files section - linked folders from File Manager */}
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center justify-between mb-3">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <FolderOpen className="w-5 h-5" />
- Files
- </h2>
- </div>
- <p className="text-xs text-bambu-gray mb-3">
- <Link to="/files" className="text-bambu-green hover:underline">
- Link folders from the File Manager
- </Link>
- {' '}to this project for quick access.
- </p>
- {linkedFolders && linkedFolders.length > 0 ? (
- <div className="space-y-2">
- {linkedFolders.map((folder) => (
- <Link
- key={folder.id}
- to={`/files?folder=${folder.id}`}
- className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
- >
- <div className="flex items-center gap-3 min-w-0">
- <FolderOpen className="w-5 h-5 text-bambu-green flex-shrink-0" />
- <div className="min-w-0">
- <p className="text-sm text-white truncate">
- {folder.name}
- </p>
- <p className="text-xs text-bambu-gray">
- {folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
- </p>
- </div>
- </div>
- <ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
- </Link>
- ))}
- </div>
- ) : (
- <p className="text-bambu-gray/70 text-sm italic">
- No folders linked. Go to File Manager and link a folder to this project.
- </p>
- )}
- </CardContent>
- </Card>
- {/* BOM Section - Parts to source/purchase */}
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center justify-between mb-4">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <ShoppingCart className="w-5 h-5" />
- Bill of Materials
- {stats && stats.bom_total_items > 0 && (
- <span className="text-sm font-normal text-bambu-gray">
- ({stats.bom_completed_items}/{stats.bom_total_items} acquired)
- </span>
- )}
- </h2>
- <div className="flex items-center gap-2">
- {bomItems && bomItems.some(item => item.is_complete) && (
- <button
- onClick={() => setHideBomCompleted(!hideBomCompleted)}
- className={`text-xs px-2 py-1 rounded transition-colors ${
- hideBomCompleted
- ? 'bg-bambu-green/20 text-bambu-green'
- : 'bg-bambu-dark text-bambu-gray hover:text-white'
- }`}
- >
- {hideBomCompleted ? 'Show all' : 'Hide done'}
- </button>
- )}
- {!showBomForm && (
- <Button
- variant="secondary"
- size="sm"
- onClick={() => setShowBomForm(true)}
- disabled={!hasPermission('projects:update')}
- title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
- >
- <Plus className="w-4 h-4 mr-1" />
- Add Part
- </Button>
- )}
- </div>
- </div>
- {/* Add BOM item form */}
- {showBomForm && (
- <form onSubmit={handleAddBomItem} className="bg-bambu-dark rounded-lg p-4 mb-4 space-y-3">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
- <input
- type="text"
- value={newBomName}
- onChange={(e) => setNewBomName(e.target.value)}
- 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"
- placeholder="Part name (e.g., M3x8 screws)"
- autoFocus
- />
- <div className="flex gap-2">
- <input
- type="number"
- value={newBomQty}
- onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
- 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"
- min="1"
- placeholder="Qty"
- />
- <input
- type="number"
- step="0.01"
- value={newBomPrice}
- onChange={(e) => setNewBomPrice(e.target.value)}
- 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"
- placeholder={`Price (${currency})`}
- />
- </div>
- </div>
- <input
- type="url"
- value={newBomUrl}
- onChange={(e) => setNewBomUrl(e.target.value)}
- 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"
- placeholder="Sourcing URL (optional)"
- />
- <input
- type="text"
- value={newBomRemarks}
- onChange={(e) => setNewBomRemarks(e.target.value)}
- 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"
- placeholder="Remarks (optional)"
- />
- <div className="flex justify-end gap-2">
- <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
- Cancel
- </Button>
- <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
- {createBomMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- 'Add Part'
- )}
- </Button>
- </div>
- </form>
- )}
- {bomLoading ? (
- <div className="flex items-center justify-center py-4">
- <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
- </div>
- ) : bomItems && bomItems.length > 0 ? (
- <div className="space-y-2">
- {bomItems
- .filter(item => !hideBomCompleted || !item.is_complete)
- .map((item) => (
- <div
- key={item.id}
- className={`p-3 rounded-lg transition-colors ${
- item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
- }`}
- >
- {editingBomItem?.id === item.id ? (
- // Edit form for this BOM item
- <form onSubmit={handleSaveBomEdit} className="space-y-3">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
- <input
- type="text"
- value={editBomName}
- onChange={(e) => setEditBomName(e.target.value)}
- 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"
- placeholder="Part name"
- autoFocus
- />
- <div className="flex gap-2">
- <input
- type="number"
- value={editBomQty}
- onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
- 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"
- min="1"
- placeholder="Qty"
- />
- <input
- type="number"
- step="0.01"
- value={editBomPrice}
- onChange={(e) => setEditBomPrice(e.target.value)}
- 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"
- placeholder={`Price (${currency})`}
- />
- </div>
- </div>
- <input
- type="url"
- value={editBomUrl}
- onChange={(e) => setEditBomUrl(e.target.value)}
- 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"
- placeholder="Sourcing URL (optional)"
- />
- <input
- type="text"
- value={editBomRemarks}
- onChange={(e) => setEditBomRemarks(e.target.value)}
- 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"
- placeholder="Remarks (optional)"
- />
- <div className="flex justify-end gap-2">
- <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
- Cancel
- </Button>
- <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
- {updateBomMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- 'Save'
- )}
- </Button>
- </div>
- </form>
- ) : (
- // Display mode
- <div className="flex items-start gap-3">
- <button
- onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
- disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
- title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
- className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
- item.is_complete
- ? 'bg-status-ok border-status-ok text-white'
- : hasPermission('projects:update')
- ? 'border-bambu-gray hover:border-bambu-green'
- : 'border-bambu-gray/50 cursor-not-allowed'
- }`}
- >
- {item.is_complete && <CheckCircle className="w-3 h-3" />}
- </button>
- <div className="flex-1 min-w-0">
- <div className="flex items-center justify-between gap-2">
- <div className="flex items-center gap-2 min-w-0">
- <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
- {item.name}
- <span className="text-bambu-gray font-normal ml-2">
- x{item.quantity_needed}
- </span>
- </p>
- {item.unit_price !== null && (
- <span className="text-xs text-bambu-green whitespace-nowrap">
- {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
- </span>
- )}
- </div>
- <div className="flex items-center gap-1">
- <button
- onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
- disabled={!hasPermission('projects:update')}
- className={`p-1 rounded transition-colors flex-shrink-0 ${
- hasPermission('projects:update')
- ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
- : 'text-bambu-gray/50 cursor-not-allowed'
- }`}
- title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
- >
- <Pencil className="w-4 h-4" />
- </button>
- <button
- onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
- disabled={!hasPermission('projects:update')}
- className={`p-1 rounded transition-colors flex-shrink-0 ${
- hasPermission('projects:update')
- ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
- : 'text-bambu-gray/50 cursor-not-allowed'
- }`}
- title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
- >
- <Trash2 className="w-4 h-4" />
- </button>
- </div>
- </div>
- {/* Sourcing URL */}
- {item.sourcing_url && (
- <a
- href={item.sourcing_url}
- target="_blank"
- rel="noopener noreferrer"
- className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
- onClick={(e) => e.stopPropagation()}
- >
- <ExternalLink className="w-3 h-3 flex-shrink-0" />
- <span className="truncate">
- {(() => {
- try {
- return new URL(item.sourcing_url).hostname.replace('www.', '');
- } catch {
- return item.sourcing_url;
- }
- })()}
- </span>
- </a>
- )}
- {/* Remarks */}
- {item.remarks && (
- <p className="mt-1 text-xs text-bambu-gray/80 italic">
- {item.remarks}
- </p>
- )}
- </div>
- </div>
- )}
- </div>
- ))}
- {/* BOM Total */}
- {bomItems.some(item => item.unit_price !== null) && (
- <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
- <span className="text-bambu-gray">Total cost:</span>
- <span className="text-white font-medium">
- {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
- </span>
- </div>
- )}
- </div>
- ) : (
- <p className="text-bambu-gray/70 text-sm italic">
- No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
- </p>
- )}
- </CardContent>
- </Card>
- {/* Timeline Section */}
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center justify-between mb-3">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <History className="w-5 h-5" />
- Activity Timeline
- </h2>
- </div>
- {timelineLoading ? (
- <div className="flex items-center justify-center py-4">
- <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
- </div>
- ) : timeline && timeline.length > 0 ? (
- <div className="space-y-3">
- {timeline.slice(0, 10).map((event, index) => (
- <div key={index} className="flex gap-3">
- <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
- event.event_type === 'print_completed' ? 'bg-status-ok/20 text-status-ok' :
- event.event_type === 'print_failed' ? 'bg-status-error/20 text-status-error' :
- event.event_type === 'print_started' ? 'bg-yellow-500/20 text-yellow-400' :
- 'bg-bambu-dark-tertiary text-bambu-gray'
- }`}>
- {event.event_type === 'print_completed' && <CheckCircle className="w-4 h-4" />}
- {event.event_type === 'print_failed' && <XCircle className="w-4 h-4" />}
- {event.event_type === 'print_started' && <Printer className="w-4 h-4" />}
- {event.event_type === 'queued' && <ListTodo className="w-4 h-4" />}
- {event.event_type === 'project_created' && <Plus className="w-4 h-4" />}
- </div>
- <div className="flex-1 min-w-0">
- <p className="text-sm text-white">{event.title}</p>
- {event.description && (
- <p className="text-xs text-bambu-gray truncate">{event.description}</p>
- )}
- <p className="text-xs text-bambu-gray/70">{formatTimelineDate(event.timestamp)}</p>
- </div>
- </div>
- ))}
- </div>
- ) : (
- <p className="text-bambu-gray/70 text-sm italic">
- No activity yet.
- </p>
- )}
- </CardContent>
- </Card>
- {/* Template action */}
- {!project.is_template && (
- <div className="flex justify-end">
- <Button
- variant="secondary"
- size="sm"
- onClick={() => createTemplateMutation.mutate()}
- disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
- title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
- >
- {createTemplateMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin mr-2" />
- ) : (
- <Copy className="w-4 h-4 mr-2" />
- )}
- Save as Template
- </Button>
- </div>
- )}
- {/* Queue section */}
- {stats && (stats.queued_prints > 0 || stats.in_progress_prints > 0) && (
- <Card>
- <CardContent className="p-4">
- <div className="flex items-center justify-between mb-3">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <ListTodo className="w-5 h-5" />
- Queue
- </h2>
- <Link
- to={`/queue?project=${projectId}`}
- className="text-sm text-bambu-green hover:underline"
- >
- View all
- </Link>
- </div>
- <div className="flex items-center gap-4 text-sm">
- {stats.in_progress_prints > 0 && (
- <span className="text-yellow-400">
- {stats.in_progress_prints} printing
- </span>
- )}
- {stats.queued_prints > 0 && (
- <span className="text-bambu-gray">
- {stats.queued_prints} queued
- </span>
- )}
- </div>
- </CardContent>
- </Card>
- )}
- {/* Archives section */}
- <div>
- <div className="flex items-center justify-between mb-4">
- <h2 className="text-lg font-semibold text-white flex items-center gap-2">
- <Package className="w-5 h-5" />
- Prints ({archives?.length || 0})
- </h2>
- </div>
- {archivesLoading ? (
- <div className="flex items-center justify-center py-8">
- <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
- </div>
- ) : (
- <ArchiveGrid archives={archives || []} />
- )}
- </div>
- {/* Edit Modal */}
- {showEditModal && (
- <ProjectModal
- project={{
- ...project,
- archive_count: stats?.total_archives || 0,
- total_items: stats?.total_items || 0,
- completed_count: stats?.completed_prints || 0,
- failed_count: stats?.failed_prints || 0,
- queue_count: stats?.queued_prints || 0,
- progress_percent: stats?.progress_percent || null,
- archives: [],
- }}
- onClose={() => setShowEditModal(false)}
- onSave={(data) => updateMutation.mutate(data as ProjectUpdate)}
- isLoading={updateMutation.isPending}
- />
- )}
- {/* Confirm Modal */}
- {confirmModal.isOpen && (
- <ConfirmModal
- title={confirmModal.title}
- message={confirmModal.message}
- confirmText="Delete"
- variant="danger"
- onConfirm={confirmModal.onConfirm}
- onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}
- />
- )}
- </div>
- );
- }
|