246 lines
12 KiB
TypeScript
246 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
|
import { Route as appRoute } from "../_app";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
|
import { useRealtime } from "@/hooks/use-realtime";
|
|
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
|
import { cn } from "@/lib/utils";
|
|
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
|
|
|
function ProjectForm({ project, onClose }: { project?: Project; onClose: () => void }) {
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState(project?.name || "");
|
|
const [description, setDescription] = useState(project?.description || "");
|
|
const [status, setStatus] = useState(project?.status || "active");
|
|
const [color, setColor] = useState(project?.color || "#3b82f6");
|
|
const [targetDate, setTargetDate] = useState(project?.targetDate ? project.targetDate.slice(0, 10) : "");
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: any) => api.post<Project>("/projects", data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (data: any) => api.patch<Project>("/projects/" + project!.id, data),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); onClose(); },
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) return;
|
|
const data: any = { name: name.trim(), description: description || null, status, color };
|
|
if (targetDate) data.targetDate = new Date(targetDate).toISOString();
|
|
if (project) updateMutation.mutate(data);
|
|
else createMutation.mutate(data);
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Project name" required />
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="desc">Description</Label>
|
|
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description (optional)" rows={2} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="status">Status</Label>
|
|
<Select value={status} onValueChange={setStatus}>
|
|
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="paused">Paused</SelectItem>
|
|
<SelectItem value="completed">Completed</SelectItem>
|
|
<SelectItem value="archived">Archived</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="color">Color</Label>
|
|
<Input id="color" type="color" value={color} onChange={(e) => setColor(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="targetDate">Target Date</Label>
|
|
<Input id="targetDate" type="date" value={targetDate} onChange={(e) => setTargetDate(e.target.value)} />
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
|
{project ? "Update" : "Create"} Project
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function ProjectsPage() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
const [detailTab, setDetailTab] = useState("overview");
|
|
|
|
useRealtime({ enabled: true });
|
|
|
|
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>(
|
|
["projects"],
|
|
"/projects?limit=200"
|
|
);
|
|
|
|
const projects = projectsData?.items || [];
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => api.delete("/projects/" + id),
|
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
|
|
});
|
|
|
|
const openProjectDetail = async (project: Project) => {
|
|
try {
|
|
const detail = await api.get<Project>("/projects/" + project.id);
|
|
setSelectedProject(detail);
|
|
} catch {
|
|
setSelectedProject(project);
|
|
}
|
|
setPanelOpen(true);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold">Projects</h1>
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button aria-label="New project"><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>New Project</DialogTitle></DialogHeader>
|
|
<ProjectForm onClose={() => setCreateOpen(false)} />
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div>
|
|
) : projects.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">No projects yet.</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{projects.map((project) => (
|
|
<Card key={project.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openProjectDetail(project)}>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
|
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
|
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{project.description && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{project.description}</p>
|
|
)}
|
|
<Progress value={project.progress} className="h-1.5 mb-2" />
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>{project.completedCount}/{project.taskCount} tasks</span>
|
|
{project.targetDate && (
|
|
<span><Calendar className="h-3 w-3 inline mr-1" />{new Date(project.targetDate).toLocaleDateString()}</span>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<EntityDetailPanel open={panelOpen} onOpenChange={setPanelOpen} title={selectedProject?.name || "Project Details"}>
|
|
{selectedProject && (
|
|
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="overview" className="flex-1">Overview</TabsTrigger>
|
|
<TabsTrigger value="tasks" className="flex-1">Tasks</TabsTrigger>
|
|
<TabsTrigger value="sections" className="flex-1">Sections</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="overview" className="space-y-4 pt-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Progress value={selectedProject.progress} className="h-2 flex-1" />
|
|
<span className="text-sm font-medium">{selectedProject.progress}%</span>
|
|
</div>
|
|
<ProjectForm project={selectedProject} onClose={() => setPanelOpen(false)} />
|
|
<div className="pt-4 border-t">
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive" size="sm"><Trash2 className="h-4 w-4 mr-2" />Delete Project</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Project</AlertDialogTitle>
|
|
<AlertDialogDescription>Are you sure you want to delete "{selectedProject.name}"?</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => deleteMutation.mutate(selectedProject.id)} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="tasks" className="pt-4">
|
|
<h3 className="font-semibold text-sm mb-2">Tasks ({selectedProject.tasks?.length || 0})</h3>
|
|
{selectedProject.tasks?.length ? (
|
|
<div className="space-y-1">
|
|
{selectedProject.tasks.map((task: any) => (
|
|
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
|
<span className="truncate">{task.title}</span>
|
|
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No tasks in this project.</p>
|
|
)}
|
|
</TabsContent>
|
|
<TabsContent value="sections" className="pt-4">
|
|
<h3 className="font-semibold text-sm mb-2">Sections ({selectedProject.sections?.length || 0})</h3>
|
|
{selectedProject.sections?.length ? (
|
|
<div className="space-y-1">
|
|
{selectedProject.sections.map((section) => (
|
|
<div key={section.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
|
<span className="truncate">{section.name}</span>
|
|
<Badge variant="outline" className="text-[10px]">{section.status}</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No sections yet.</p>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
</EntityDetailPanel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const Route = createRoute({
|
|
getParentRoute: () => appRoute,
|
|
path: "/projects",
|
|
component: ProjectsPage,
|
|
});
|