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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user