| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- import { useState } from 'react';
- import { useNavigate } from 'react-router-dom';
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
- import {
- FolderKanban,
- Loader2,
- Plus,
- Trash2,
- Edit3,
- Archive,
- ListTodo,
- Package,
- Clock,
- CheckCircle2,
- AlertTriangle,
- ChevronRight,
- MoreVertical,
- } from 'lucide-react';
- import { api } from '../api/client';
- import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
- import { Button } from '../components/Button';
- import { ConfirmModal } from '../components/ConfirmModal';
- import { useToast } from '../contexts/ToastContext';
- const PROJECT_COLORS = [
- '#ef4444', // red
- '#f97316', // orange
- '#eab308', // yellow
- '#22c55e', // green
- '#06b6d4', // cyan
- '#3b82f6', // blue
- '#8b5cf6', // violet
- '#ec4899', // pink
- '#6b7280', // gray
- ];
- interface ProjectModalProps {
- project?: ProjectListItem;
- onClose: () => void;
- onSave: (data: ProjectCreate | ProjectUpdate) => void;
- isLoading: boolean;
- }
- export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
- const [name, setName] = useState(project?.name || '');
- const [description, setDescription] = useState(project?.description || '');
- const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
- const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
- const [status, setStatus] = useState(project?.status || 'active');
- const [tags, setTags] = useState((project as ProjectListItem & { tags?: string })?.tags || '');
- const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
- const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- onSave({
- name: name.trim(),
- description: description.trim() || undefined,
- color,
- target_count: targetCount ? parseInt(targetCount, 10) : undefined,
- tags: tags.trim() || undefined,
- due_date: dueDate || undefined,
- priority,
- ...(project && { status }),
- });
- };
- return (
- <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
- <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
- <div className="p-4 border-b border-bambu-dark-tertiary">
- <h2 className="text-lg font-semibold text-white">
- {project ? 'Edit Project' : 'New Project'}
- </h2>
- </div>
- <form onSubmit={handleSubmit} className="p-4 space-y-4">
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Name
- </label>
- <input
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
- placeholder="e.g., Voron 2.4 Build"
- required
- />
- </div>
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Description
- </label>
- <textarea
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
- placeholder="Optional description..."
- rows={2}
- />
- </div>
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Color
- </label>
- <div className="flex gap-2 flex-wrap">
- {PROJECT_COLORS.map((c) => (
- <button
- key={c}
- type="button"
- onClick={() => setColor(c)}
- className={`w-8 h-8 rounded-full transition-transform ${
- color === c ? 'ring-2 ring-white ring-offset-2 ring-offset-bambu-dark-secondary scale-110' : ''
- }`}
- style={{ backgroundColor: c }}
- />
- ))}
- </div>
- </div>
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Target Print Count (optional)
- </label>
- <input
- type="number"
- value={targetCount}
- onChange={(e) => setTargetCount(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
- placeholder="e.g., 50 parts to print"
- min="1"
- />
- </div>
- {/* Tags */}
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Tags (comma-separated)
- </label>
- <input
- type="text"
- value={tags}
- onChange={(e) => setTags(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
- placeholder="e.g., voron, functional, gift"
- />
- </div>
- {/* Due Date and Priority in a row */}
- <div className="grid grid-cols-2 gap-4">
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Due Date
- </label>
- <input
- type="date"
- value={dueDate}
- onChange={(e) => setDueDate(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
- />
- </div>
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Priority
- </label>
- <select
- value={priority}
- onChange={(e) => setPriority(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
- >
- <option value="low">Low</option>
- <option value="normal">Normal</option>
- <option value="high">High</option>
- <option value="urgent">Urgent</option>
- </select>
- </div>
- </div>
- {project && (
- <div>
- <label className="block text-sm font-medium text-white mb-1">
- Status
- </label>
- <select
- value={status}
- onChange={(e) => setStatus(e.target.value)}
- className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
- >
- <option value="active">Active</option>
- <option value="completed">Completed</option>
- <option value="archived">Archived</option>
- </select>
- </div>
- )}
- <div className="flex justify-end gap-2 pt-2">
- <Button type="button" variant="secondary" onClick={onClose}>
- Cancel
- </Button>
- <Button type="submit" disabled={!name.trim() || isLoading}>
- {isLoading ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : project ? (
- 'Save'
- ) : (
- 'Create'
- )}
- </Button>
- </div>
- </form>
- </div>
- </div>
- );
- }
- interface ProjectCardProps {
- project: ProjectListItem;
- onClick: () => void;
- onEdit: () => void;
- onDelete: () => void;
- }
- function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
- const progressPercent = project.progress_percent ?? 0;
- const isCompleted = project.status === 'completed';
- const isArchived = project.status === 'archived';
- const [showActions, setShowActions] = useState(false);
- // Status icon and color
- const getStatusConfig = () => {
- if (isCompleted) return { icon: CheckCircle2, color: 'text-bambu-green', bg: 'bg-bambu-green/10' };
- if (isArchived) return { icon: Archive, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
- if (project.queue_count > 0) return { icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' };
- return { icon: FolderKanban, color: 'text-bambu-gray', bg: 'bg-bambu-gray/10' };
- };
- const statusConfig = getStatusConfig();
- return (
- <div
- className="group relative bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary hover:border-bambu-green/50 hover:shadow-lg hover:shadow-bambu-green/5 transition-all duration-300 cursor-pointer overflow-hidden"
- onClick={onClick}
- >
- {/* Color accent bar with glow */}
- <div
- className="absolute top-0 left-0 w-1.5 h-full"
- style={{
- backgroundColor: project.color || '#6b7280',
- boxShadow: `0 0 12px ${project.color || '#6b7280'}40`
- }}
- />
- <div className="p-5 pl-6">
- {/* Header */}
- <div className="flex items-start justify-between mb-4">
- <div className="flex items-center gap-3 min-w-0 flex-1">
- <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
- <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
- </div>
- <div className="min-w-0 flex-1">
- <div className="flex items-center gap-2 flex-wrap">
- <h3 className="font-semibold text-white truncate">{project.name}</h3>
- {project.target_count ? (
- <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
- progressPercent >= 100
- ? 'bg-bambu-green/20 text-bambu-green'
- : 'bg-bambu-dark text-bambu-gray'
- }`}>
- {project.total_items}/{project.target_count} items
- </span>
- ) : project.total_items > 0 ? (
- <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
- {project.total_items} item{project.total_items !== 1 ? 's' : ''}
- </span>
- ) : null}
- {isCompleted && (
- <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
- Done
- </span>
- )}
- {isArchived && (
- <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
- Archived
- </span>
- )}
- </div>
- {project.description && (
- <p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
- {project.description}
- </p>
- )}
- {/* Filament materials/colors */}
- {project.archives && project.archives.length > 0 && (() => {
- // Flatten comma-separated materials and deduplicate
- const allMaterials = project.archives
- .map(a => a.filament_type)
- .filter(Boolean)
- .flatMap(m => (m as string).split(',').map(s => s.trim()))
- .filter(Boolean);
- const materials = [...new Set(allMaterials)];
- // Flatten comma-separated colors and deduplicate
- const allColors = project.archives
- .map(a => a.filament_color)
- .filter(Boolean)
- .flatMap(c => (c as string).split(',').map(s => s.trim()))
- .filter(c => c.startsWith('#') || /^[0-9A-Fa-f]{6}$/.test(c));
- const colors = [...new Set(allColors)];
- if (materials.length === 0 && colors.length === 0) return null;
- return (
- <div className="flex items-center gap-2 mt-1.5">
- {/* Material types as text badges */}
- {materials.slice(0, 3).map((mat) => (
- <span key={mat} className="text-[10px] px-1.5 py-0.5 bg-bambu-dark text-bambu-gray rounded">
- {mat}
- </span>
- ))}
- {/* Colors as swatches */}
- {colors.length > 0 && (
- <div className="flex items-center gap-0.5">
- {colors.slice(0, 5).map((col) => (
- <div
- key={col}
- className="w-3 h-3 rounded-full border border-white/20"
- style={{ backgroundColor: col.startsWith('#') ? col : `#${col}` }}
- title={col}
- />
- ))}
- {colors.length > 5 && (
- <span className="text-[10px] text-bambu-gray ml-0.5">+{colors.length - 5}</span>
- )}
- </div>
- )}
- </div>
- );
- })()}
- </div>
- </div>
- {/* Actions menu */}
- <div className="relative" onClick={(e) => e.stopPropagation()}>
- <button
- className="p-1.5 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors opacity-0 group-hover:opacity-100"
- onClick={() => setShowActions(!showActions)}
- >
- <MoreVertical className="w-4 h-4" />
- </button>
- {showActions && (
- <>
- <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
- <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
- <button
- className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
- onClick={() => { onEdit(); setShowActions(false); }}
- >
- <Edit3 className="w-4 h-4" />
- Edit
- </button>
- <button
- className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
- onClick={() => { onDelete(); setShowActions(false); }}
- >
- <Trash2 className="w-4 h-4" />
- Delete
- </button>
- </div>
- </>
- )}
- </div>
- </div>
- {/* Progress section - show for all projects */}
- <div className="mb-4">
- {project.target_count ? (
- <>
- <div className="flex items-center justify-between text-xs mb-2">
- <span className="text-bambu-gray">Progress</span>
- <span className={progressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
- {project.total_items} / {project.target_count}
- </span>
- </div>
- <div className="h-2.5 bg-bambu-dark/80 rounded-full overflow-hidden backdrop-blur-sm">
- <div
- className="h-full transition-all duration-500 ease-out rounded-full relative"
- style={{
- width: `${Math.min(progressPercent, 100)}%`,
- background: progressPercent >= 100
- ? 'linear-gradient(90deg, #22c55e, #4ade80)'
- : `linear-gradient(90deg, ${project.color || '#6b7280'}, ${project.color || '#6b7280'}cc)`,
- boxShadow: `0 0 8px ${progressPercent >= 100 ? '#22c55e' : project.color || '#6b7280'}60`
- }}
- />
- </div>
- <div className="text-right text-xs text-bambu-gray/60 mt-1">
- {progressPercent.toFixed(0)}% complete
- </div>
- </>
- ) : project.total_items > 0 ? (
- <div className="flex items-center gap-4 text-xs">
- <div className="flex items-center gap-1.5 text-bambu-gray">
- <Archive className="w-3.5 h-3.5" />
- <span>{project.total_items} item{project.total_items !== 1 ? 's' : ''} completed</span>
- </div>
- {project.queue_count > 0 && (
- <div className="flex items-center gap-1.5 text-blue-400">
- <Clock className="w-3.5 h-3.5" />
- <span>{project.queue_count} in queue</span>
- </div>
- )}
- </div>
- ) : (
- <div className="text-xs text-bambu-gray/60 italic">
- No prints yet
- </div>
- )}
- </div>
- {/* Archive thumbnails - compact 4-column grid */}
- {project.archives && project.archives.length > 0 && (
- <div className="mb-4">
- <div className="grid grid-cols-4 gap-1.5">
- {project.archives.slice(0, 4).map((archive) => (
- <div
- key={archive.id}
- className="relative aspect-square rounded-lg bg-bambu-dark overflow-hidden border border-bambu-dark-tertiary"
- title={archive.print_name || 'Unknown'}
- >
- {archive.thumbnail_path ? (
- <img
- src={api.getArchiveThumbnail(archive.id)}
- alt={archive.print_name || ''}
- className="w-full h-full object-cover"
- />
- ) : (
- <div className="w-full h-full flex items-center justify-center text-bambu-gray/50">
- <Package className="w-6 h-6" />
- </div>
- )}
- {archive.status === 'failed' && (
- <div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
- <AlertTriangle className="w-4 h-4 text-white" />
- </div>
- )}
- </div>
- ))}
- </div>
- {project.archive_count > 4 && (
- <p className="text-xs text-bambu-gray mt-1.5 text-center">
- +{project.archive_count - 4} more
- </p>
- )}
- </div>
- )}
- {/* Stats footer */}
- <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
- <div className="flex items-center gap-4 text-xs text-bambu-gray">
- <div className="flex items-center gap-1.5" title="Total items printed">
- <Archive className="w-3.5 h-3.5" />
- <span>{project.total_items}</span>
- </div>
- {project.queue_count > 0 && (
- <div className="flex items-center gap-1.5 text-blue-400" title="In queue">
- <ListTodo className="w-3.5 h-3.5" />
- <span>{project.queue_count}</span>
- </div>
- )}
- </div>
- <ChevronRight className="w-4 h-4 text-bambu-gray/50 group-hover:text-bambu-gray transition-colors" />
- </div>
- </div>
- </div>
- );
- }
- export function ProjectsPage() {
- const navigate = useNavigate();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const [showModal, setShowModal] = useState(false);
- const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
- const [statusFilter, setStatusFilter] = useState<string>('active');
- const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
- const { data: projects, isLoading } = useQuery({
- queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
- queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
- });
- const createMutation = useMutation({
- mutationFn: (data: ProjectCreate) => api.createProject(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['projects'] });
- setShowModal(false);
- showToast('Project created', 'success');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const updateMutation = useMutation({
- mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
- api.updateProject(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['projects'] });
- setShowModal(false);
- setEditingProject(undefined);
- showToast('Project updated', 'success');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
- const deleteMutation = useMutation({
- mutationFn: (id: number) => api.deleteProject(id),
- onSuccess: () => {
- setDeleteConfirm(null);
- showToast('Project deleted', 'success');
- // Reload to refresh the list (React Query cache invalidation not working reliably)
- setTimeout(() => window.location.reload(), 100);
- },
- onError: (error: Error) => {
- setDeleteConfirm(null);
- showToast(error.message, 'error');
- },
- });
- const handleSave = (data: ProjectCreate | ProjectUpdate) => {
- if (editingProject) {
- updateMutation.mutate({ id: editingProject.id, data });
- } else {
- createMutation.mutate(data as ProjectCreate);
- }
- };
- const handleEdit = (project: ProjectListItem) => {
- setEditingProject(project);
- setShowModal(true);
- };
- const handleClick = (project: ProjectListItem) => {
- // Navigate to project detail page
- navigate(`/projects/${project.id}`);
- };
- const handleDeleteClick = (id: number) => {
- setDeleteConfirm(id);
- };
- const handleDeleteConfirm = () => {
- if (deleteConfirm !== null) {
- deleteMutation.mutate(deleteConfirm);
- }
- };
- // Count projects by status for filter badges
- const projectCounts = projects?.reduce((acc, p) => {
- acc[p.status] = (acc[p.status] || 0) + 1;
- acc.all = (acc.all || 0) + 1;
- return acc;
- }, {} as Record<string, number>) || {};
- return (
- <div className="p-4 md:p-8 space-y-8">
- {/* Header */}
- <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
- <div>
- <h1 className="text-2xl font-bold text-white flex items-center gap-3">
- <div className="p-2.5 bg-bambu-green/10 rounded-xl">
- <FolderKanban className="w-6 h-6 text-bambu-green" />
- </div>
- Projects
- </h1>
- <p className="text-sm text-bambu-gray mt-2 ml-14">
- Organize and track your 3D printing projects
- </p>
- </div>
- <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
- <Plus className="w-4 h-4 mr-2" />
- New Project
- </Button>
- </div>
- {/* Filter tabs */}
- <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
- {[
- { key: 'active', label: 'Active', icon: Clock },
- { key: 'completed', label: 'Completed', icon: CheckCircle2 },
- { key: 'archived', label: 'Archived', icon: Archive },
- { key: 'all', label: 'All', icon: FolderKanban },
- ].map(({ key, label, icon: Icon }) => (
- <button
- key={key}
- onClick={() => setStatusFilter(key)}
- className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
- statusFilter === key
- ? 'bg-bambu-card text-white shadow-sm'
- : 'text-bambu-gray hover:text-white'
- }`}
- >
- <Icon className="w-4 h-4" />
- <span>{label}</span>
- {projectCounts[key] > 0 && (
- <span className={`text-xs px-1.5 py-0.5 rounded-full ${
- statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
- }`}>
- {projectCounts[key]}
- </span>
- )}
- </button>
- ))}
- </div>
- {/* Content */}
- {isLoading ? (
- <div className="flex items-center justify-center py-20">
- <div className="flex flex-col items-center gap-3">
- <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
- <p className="text-sm text-bambu-gray">Loading projects...</p>
- </div>
- </div>
- ) : projects?.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-20 px-4">
- <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
- <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
- </div>
- <h3 className="text-lg font-medium text-white mb-2">
- {statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
- </h3>
- <p className="text-bambu-gray text-center max-w-md mb-6">
- {statusFilter === 'all'
- ? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
- : `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
- }
- </p>
- {statusFilter === 'all' && (
- <Button onClick={() => setShowModal(true)}>
- <Plus className="w-4 h-4 mr-2" />
- Create Your First Project
- </Button>
- )}
- </div>
- ) : (
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
- {projects?.map((project) => (
- <ProjectCard
- key={project.id}
- project={project}
- onClick={() => handleClick(project)}
- onEdit={() => handleEdit(project)}
- onDelete={() => handleDeleteClick(project.id)}
- />
- ))}
- </div>
- )}
- {/* Delete Confirmation Modal */}
- {deleteConfirm !== null && (
- <ConfirmModal
- title="Delete Project"
- message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
- confirmText="Delete Project"
- variant="danger"
- onConfirm={handleDeleteConfirm}
- onCancel={() => setDeleteConfirm(null)}
- />
- )}
- {/* Modal */}
- {showModal && (
- <ProjectModal
- project={editingProject}
- onClose={() => {
- setShowModal(false);
- setEditingProject(undefined);
- }}
- onSave={handleSave}
- isLoading={createMutation.isPending || updateMutation.isPending}
- />
- )}
- </div>
- );
- }
|