diff --git a/apps/web/app/(dashboard)/projects/page.tsx b/apps/web/app/(dashboard)/projects/page.tsx index 1de2a21..0bb42d5 100644 --- a/apps/web/app/(dashboard)/projects/page.tsx +++ b/apps/web/app/(dashboard)/projects/page.tsx @@ -1,12 +1,29 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Plus, FolderKanban, ExternalLink } from "lucide-react"; +import { Plus, FolderKanban, ExternalLink, MoreHorizontal, Pencil, Archive } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { ProjectCreateDialog } from "@/components/projects/project-create-dialog"; +import { ProjectEditDialog } from "@/components/projects/project-edit-dialog"; import Link from "next/link"; import { toast } from "sonner"; @@ -37,6 +54,9 @@ export default function ProjectsPage() { const [domainId, setDomainId] = useState(null); const [domains, setDomains] = useState<{ id: string; name: string }[]>([]); const [createOpen, setCreateOpen] = useState(false); + const [editProject, setEditProject] = useState(null); + const [archiveProject, setArchiveProject] = useState(null); + const [archiving, setArchiving] = useState(false); const [loading, setLoading] = useState(true); // Fetch domains @@ -116,22 +136,23 @@ export default function ProjectsPage() { ) : (
{projects.map((project) => ( - - - -
-
- {project.color && ( -
- )} - {project.name} +
+ + + +
+
+ {project.color && ( +
+ )} + {project.name} +
+
-
-
+ {project.description && (

{project.description}

@@ -168,7 +189,32 @@ export default function ProjectsPage() { )}
- + + +
+ + + + + + { e.stopPropagation(); setEditProject(project); }}> + + Edit + + { e.stopPropagation(); setArchiveProject(project); }}> + + Archive + + + +
+
))}
)} @@ -179,6 +225,54 @@ export default function ProjectsPage() { domainId={domainId || ''} onCreated={fetchProjects} /> + + {editProject && ( + { if (!open) setEditProject(null); }} + project={editProject} + domainId={domainId || ''} + onUpdated={fetchProjects} + /> + )} + + { if (!open) setArchiveProject(null); }}> + + + Archive Project + + Are you sure you want to archive "{archiveProject?.name}"? It will be hidden from the active list. + + + + Cancel + { + if (!archiveProject || !domainId) return; + setArchiving(true); + try { + const res = await fetch(`/api/domains/${domainId}/projects/${archiveProject.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'archived' }), + }); + if (!res.ok) throw new Error('Failed to archive'); + toast.success('Project archived'); + setArchiveProject(null); + fetchProjects(); + } catch { + toast.error('Failed to archive project'); + } finally { + setArchiving(false); + } + }} + > + {archiving ? 'Archiving...' : 'Archive'} + + + +
); } diff --git a/apps/web/components/projects/project-edit-dialog.tsx b/apps/web/components/projects/project-edit-dialog.tsx new file mode 100644 index 0000000..75d9fd7 --- /dev/null +++ b/apps/web/components/projects/project-edit-dialog.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { toast } from 'sonner'; + +interface Project { + id: string; + name: string; + description: string | null; + status: 'active' | 'paused' | 'completed' | 'archived'; + domainId: string; + color: string | null; + icon: string | null; + targetDate: string | null; +} + +interface ProjectEditDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + project: Project; + domainId: string; + onUpdated: () => void; +} + +export function ProjectEditDialog({ + open, + onOpenChange, + project, + domainId, + onUpdated, +}: ProjectEditDialogProps) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [status, setStatus] = useState<'active' | 'paused' | 'completed' | 'archived'>('active'); + const [color, setColor] = useState(''); + const [targetDate, setTargetDate] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [archiveOpen, setArchiveOpen] = useState(false); + const [archiving, setArchiving] = useState(false); + + useEffect(() => { + if (open && project) { + setName(project.name); + setDescription(project.description || ''); + setStatus(project.status); + setColor(project.color || ''); + setTargetDate(project.targetDate ? project.targetDate.split('T')[0] : ''); + setError(''); + } + }, [open, project]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + if (!domainId) { + setError('No domain selected'); + return; + } + setSubmitting(true); + setError(''); + + const body: Record = { name, status }; + if (description) body.description = description; + if (color) body.color = color; + if (targetDate) body.targetDate = new Date(targetDate).toISOString(); + + try { + const response = await fetch(`/api/domains/${domainId}/projects/${project.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error?.message || 'Unable to update project'); + } + + toast.success('Project updated'); + onOpenChange(false); + onUpdated(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to update project'); + } finally { + setSubmitting(false); + } + } + + async function handleArchive() { + setArchiving(true); + try { + const response = await fetch(`/api/domains/${domainId}/projects/${project.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'archived' }), + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error?.message || 'Unable to archive project'); + } + + toast.success('Project archived'); + setArchiveOpen(false); + onOpenChange(false); + onUpdated(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Unable to archive project'); + } finally { + setArchiving(false); + } + } + + return ( + <> + + + + Edit Project + Update your project details. + +
+
+ + setName(e.target.value)} + placeholder="Project name" + autoFocus + required + /> +
+ +
+ +