420 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|