feat: add inline project timeline and notes section to Project Detail

- Implemented a Gantt timeline using GanttChart component scoped to project checkpoints.
- Added functionality to create and manage checkpoints with AddCheckpointDialog.
- Introduced EditCheckpointDialog for editing existing checkpoints.
- Created a notes section displaying a list of notes with the ability to add new notes.
- Updated routing to include notes detail page.
- Enhanced GanttChart with context menu for editing and deleting checkpoints.
- Improved UI components for better user experience.
This commit is contained in:
Roberto Musso
2026-02-22 15:15:30 +01:00
parent 40ac075633
commit 7860ca6ad1
11 changed files with 595 additions and 129 deletions

View File

@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { Fragment, useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus, SquareDashed } 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';
@@ -10,6 +12,9 @@ import {
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
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;
@@ -17,6 +22,10 @@ type ProjectDetailProps = {
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 });
@@ -54,6 +63,52 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
return { approved, total: all.length };
}, [checkpointsList]);
// 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 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">
@@ -78,10 +133,12 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}>
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
<BreadcrumbItem>
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
@@ -135,11 +192,41 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</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>
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</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 })
}
/>
<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>
<Button variant="ghost" size="sm" onClick={() => setNewTaskOpen(true)}>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
@@ -150,6 +237,50 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
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">
<SquareDashed />
</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>
);
}