Files
adiuva/src/renderer/components/projects/ProjectDetail.tsx

420 lines
14 KiB
TypeScript

import { Fragment, useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Card, CardContent } from '@/components/ui/card';
import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
type ProjectDetailProps = {
projectId: string;
};
export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const navigate = useNavigate();
const utils = trpc.useUtils();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery();
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const { data: checkpointsList } = trpc.checkpoints.list.useQuery({ projectId });
// Build breadcrumb path: Client > Sub-Client
const breadcrumbPath = useMemo(() => {
if (!project?.clientId || !clientsList) return [];
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
const client = clientMap.get(project.clientId);
if (!client) return [];
// If client has a parent, show parent > client
if (client.parentId) {
const parent = clientMap.get(client.parentId);
if (parent) return [parent.name, client.name];
}
return [client.name];
}, [project?.clientId, clientsList]);
// Compute stats
const notesCount = notesList?.length ?? 0;
const taskStats = useMemo(() => {
const all = tasksList ?? [];
const done = all.filter((t) => t.status === 'done').length;
return { done, total: all.length };
}, [tasksList]);
const checkpointStats = useMemo(() => {
const all = checkpointsList ?? [];
const approved = all.filter((c) => c.isApproved === 1).length;
return { approved, total: all.length };
}, [checkpointsList]);
const pendingCheckpoints = useMemo(() =>
(checkpointsList ?? []).filter((c) => c.isAiSuggested === 1 && c.isApproved === 0),
[checkpointsList],
);
const pendingTasks = useMemo(() =>
(tasksList ?? []).filter((t) => t.isAiSuggested === 1 && t.isApproved === 0),
[tasksList],
);
// Map checkpoints to GanttChart format
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
return (checkpointsList ?? []).map((c) => ({
id: c.id,
title: c.title,
date: c.date,
projectId,
isAiSuggested: c.isAiSuggested,
isApproved: c.isApproved,
}));
}, [checkpointsList, projectId]);
const { ganttStart, ganttEnd } = useMemo(() => {
const now = new Date();
if (ganttCheckpoints.length === 0) {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0);
return { ganttStart: start, ganttEnd: end };
}
const dates = ganttCheckpoints.map((c) => c.date);
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const start = new Date(minDate.getFullYear(), minDate.getMonth() - 1, 1);
const end = new Date(maxDate.getFullYear(), maxDate.getMonth() + 2, 0);
return { ganttStart: start, ganttEnd: end };
}, [ganttCheckpoints]);
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const updateCheckpoint = trpc.checkpoints.update.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const suggestCheckpoints = trpc.ai.chat.useMutation({
onSuccess: () => {
void utils.checkpoints.list.invalidate({ projectId });
},
});
const suggestTasks = trpc.ai.chat.useMutation({
onSuccess: () => {
void utils.tasks.list.invalidate({ projectId });
},
});
const createNote = trpc.notes.create.useMutation({
onSuccess: (data) => {
void utils.notes.list.invalidate({ projectId });
void navigate({ to: '/notes/$noteId', params: { noteId: data.id } });
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Loading project...
</div>
);
}
if (!project) {
return (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Project not found
</div>
);
}
return (
<div className="p-6 pe-8 flex flex-col gap-6">
{/* Breadcrumb + Project Name */}
<div className="flex flex-col gap-1">
{breadcrumbPath.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-3 gap-4">
<Item variant="muted">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon">
<Milestone />
</ItemMedia>
<ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription>
</ItemContent>
</Item>
</div>
{/* AI Project Summary */}
<Item variant="outline">
<ItemMedia variant="icon">
<Sparkles />
</ItemMedia>
<ItemContent>
<ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</ItemDescription>
</ItemContent>
</Item>
{/* Project Timeline */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={suggestCheckpoints.isPending}
onClick={() =>
suggestCheckpoints.mutate({
message: 'Suggest checkpoints for this project based on the notes.',
context: { type: 'project', projectId },
})
}
>
<Sparkles className="h-4 w-4 mr-1" />
{suggestCheckpoints.isPending ? 'Suggesting…' : 'Suggest checkpoints'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
<GanttChart
checkpoints={ganttCheckpoints}
startDate={ganttStart}
endDate={ganttEnd}
onDelete={(id) => deleteCheckpoint.mutate({ id })}
onEdit={(cp) => setEditingCheckpoint(cp)}
onToggleApproval={(id, current) =>
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
}
/>
{pendingCheckpoints.length > 0 && (
<div className="flex flex-col gap-2">
{pendingCheckpoints.map((cp) => (
<Card key={cp.id} className="border-dashed py-3">
<CardContent className="flex items-center justify-between px-4 py-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{cp.title}</span>
<span className="text-xs text-muted-foreground">
{format(new Date(cp.date), 'PPP')}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => updateCheckpoint.mutate({ id: cp.id, isApproved: 1 })}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteCheckpoint.mutate({ id: cp.id })}
>
Reject
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AddCheckpointDialog
open={addCheckpointOpen}
onOpenChange={setAddCheckpointOpen}
defaultProjectId={projectId}
/>
<EditCheckpointDialog
checkpoint={editingCheckpoint}
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
/>
</div>
{/* Tasks Kanban */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={suggestTasks.isPending}
onClick={() =>
suggestTasks.mutate({
message: 'Suggest tasks for this project based on the notes.',
context: { type: 'project', projectId },
})
}
>
<Sparkles className="h-4 w-4 mr-1" />
{suggestTasks.isPending ? 'Suggesting…' : 'Suggest tasks'}
</Button>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
</div>
{pendingTasks.length > 0 && (
<div className="flex flex-col gap-2">
{pendingTasks.map((t) => (
<Card key={t.id} className="border-dashed py-3">
<CardContent className="flex items-center justify-between px-4 py-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{t.title}</span>
{t.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{t.description}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={() => updateTask.mutate({ id: t.id, isApproved: 1 })}
>
Approve
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteTask.mutate({ id: t.id })}
>
Reject
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}
onNewTaskOpenChange={setNewTaskOpen}
/>
</div>
{/* Notes */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Notes</h2>
<Button
variant="secondary"
size="sm"
disabled={createNote.isPending}
onClick={() =>
createNote.mutate({ title: 'Untitled Note', content: '', projectId })
}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{notesList && notesList.length > 0 ? (
<div className="flex flex-wrap gap-5">
{notesList.map((note) => (
<Item
key={note.id}
variant="muted"
className="min-w-[280px] flex-1 cursor-pointer"
onClick={() =>
void navigate({ to: '/notes/$noteId', params: { noteId: note.id } })
}
>
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{note.title}</ItemTitle>
<ItemDescription>
{format(new Date(note.createdAt), 'PPP')}
</ItemDescription>
</ItemContent>
</Item>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No notes yet.</p>
)}
</div>
</div>
);
}