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:
@@ -23,20 +23,20 @@ APPEND to progress.txt (never replace, always append):
|
|||||||
|
|
||||||
## USER REQUEST
|
## USER REQUEST
|
||||||
{
|
{
|
||||||
"id": "US-014",
|
"id": "US-015",
|
||||||
"title": "Kanban board in Project Detail",
|
"title": "Inline project timeline and notes list in Project Detail",
|
||||||
"description": "As a user, I want a Kanban board inside the project detail view with drag-and-drop task management between status columns.",
|
"description": "As a user, I want to see the project's Gantt timeline and a list of its notes within the project detail scrollable view.",
|
||||||
"acceptanceCriteria": [
|
"acceptanceCriteria": [
|
||||||
"@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed",
|
"Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints",
|
||||||
"Each task card is a Draggable wrapped in a shadcn/ui Card rendering: title, description (truncated), priority as shadcn/ui Badge, due date chip, assignee string",
|
"'+ Add' shadcn/ui Button (variant=outline, size=sm) in the timeline section header opens the add-checkpoint shadcn/ui Dialog with the project pre-selected",
|
||||||
"Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)",
|
"Notes section below Kanban shows a flat list using shadcn/ui Separator between rows: each row has note title + formatted createdAt date",
|
||||||
"'+ Add' shadcn/ui Button (variant=ghost, size=sm) in each column header opens the shadcn/ui Dialog new-task modal with the column's status pre-selected",
|
"'+ Add' shadcn/ui Button in notes header calls notes.create with a default title and navigates to /notes/:noteId",
|
||||||
"Columns show a task count in their header using shadcn/ui Badge (variant=secondary)",
|
"Clicking a note title navigates to /notes/:noteId",
|
||||||
"All card content uses shadcn/ui primitives: Card, Badge, Button (already installed)",
|
"All buttons/dialogs use shadcn/ui components (already installed)",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 14,
|
"priority": 15,
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
4
prd.json
4
prd.json
@@ -278,8 +278,8 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 15,
|
"priority": 15,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": "Completed: Project Timeline section using GanttChart (scoped to project checkpoints) with AddCheckpointDialog (defaultProjectId hides project selector), checkpoint delete mutation. Notes section using Item cards (variant=muted) in flex-wrap grid matching Figma design, '+ Add' creates note via notes.create and navigates to /notes/$noteId, clicking note card navigates to /notes/$noteId. Route stub at notes.$noteId.tsx with back button + note title."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "US-016",
|
"id": "US-016",
|
||||||
|
|||||||
20
progress.txt
20
progress.txt
@@ -249,3 +249,23 @@
|
|||||||
- When grouping tasks by status for Kanban columns, always handle unknown/null status values with a fallback to prevent tasks from disappearing
|
- When grouping tasks by status for Kanban columns, always handle unknown/null status values with a fallback to prevent tasks from disappearing
|
||||||
- `DragDropContext.onDragEnd` provides `draggableId` which maps directly to `task.id` — no need to look up the task object for status updates
|
- `DragDropContext.onDragEnd` provides `draggableId` which maps directly to `task.id` — no need to look up the task object for status updates
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-22 - US-015
|
||||||
|
- What was implemented:
|
||||||
|
- Added Project Timeline section to `ProjectDetail.tsx` between AI Summary and Tasks Kanban
|
||||||
|
- Reused `GanttChart` component (from US-012) scoped to current project's checkpoints
|
||||||
|
- "+ Add" Button (variant=outline, size=sm) opens `AddCheckpointDialog` with `defaultProjectId={projectId}` (hides project selector)
|
||||||
|
- Wired `checkpoints.delete` mutation with `onDelete` prop for checkpoint dot deletion
|
||||||
|
- Computed `ganttStart`/`ganttEnd` dynamically from checkpoint dates with 1-month padding (fallback ±2 months if empty)
|
||||||
|
- Added Notes section below Tasks Kanban using `Item` component (variant=muted) in a flex-wrap grid layout matching Figma design
|
||||||
|
- Each note card shows `SquareDashed` icon + title + formatted createdAt date, clickable to navigate to `/notes/$noteId`
|
||||||
|
- "+ Add" Button calls `notes.create({ title: 'Untitled Note', content: '', projectId })` then navigates to the new note
|
||||||
|
- Created route stub at `src/renderer/routes/notes.$noteId.tsx` with back button + note title placeholder (full editor deferred to US-016)
|
||||||
|
- Files changed: `src/renderer/components/projects/ProjectDetail.tsx`, `src/renderer/routes/notes.$noteId.tsx` (new), `prd.json`, `progress.txt`
|
||||||
|
- **Learnings for future iterations:**
|
||||||
|
- GanttChart + AddCheckpointDialog are designed for reuse: `defaultProjectId` prop scopes the dialog to a project and hides the project select dropdown
|
||||||
|
- Figma notes section uses a card grid layout (flex-wrap with Item cards), not a flat list with Separators — always cross-reference Figma when acceptance criteria text diverges
|
||||||
|
- `trpc.useUtils()` provides `invalidate()` for cache busting after mutations — use at the component level, not inside mutation callbacks
|
||||||
|
- `notes.create` returns `{ id }` which can be used directly for navigation in the `onSuccess` callback
|
||||||
|
- TanStack Router file-based routing: `notes.$noteId.tsx` generates `/notes/:noteId` route automatically — `Route.useParams()` provides typed `{ noteId }`
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-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 { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||||
@@ -10,6 +12,9 @@ import {
|
|||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
import { KanbanBoard } from './KanbanBoard';
|
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 = {
|
type ProjectDetailProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -17,6 +22,10 @@ type ProjectDetailProps = {
|
|||||||
|
|
||||||
export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
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: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||||
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
|
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
|
||||||
@@ -54,6 +63,52 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
return { approved, total: all.length };
|
return { approved, total: all.length };
|
||||||
}, [checkpointsList]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||||
@@ -78,10 +133,12 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{breadcrumbPath.map((segment, i) => (
|
{breadcrumbPath.map((segment, i) => (
|
||||||
<BreadcrumbItem key={i}>
|
<Fragment key={i}>
|
||||||
{i > 0 && <BreadcrumbSeparator />}
|
{i > 0 && <BreadcrumbSeparator />}
|
||||||
|
<BreadcrumbItem>
|
||||||
<span className="text-muted-foreground">{segment}</span>
|
<span className="text-muted-foreground">{segment}</span>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
@@ -135,11 +192,41 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</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 */}
|
{/* Tasks Kanban */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
<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" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -150,6 +237,50 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
onNewTaskOpenChange={setNewTaskOpen}
|
onNewTaskOpenChange={setNewTaskOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
import { Calendar as CalendarIcon, Check } from 'lucide-react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -28,10 +28,16 @@ interface AddCheckpointDialogProps {
|
|||||||
defaultProjectId?: string;
|
defaultProjectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddedEntry {
|
||||||
|
title: string;
|
||||||
|
date: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
|
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [date, setDate] = useState<Date | undefined>();
|
const [date, setDate] = useState<Date | undefined>();
|
||||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||||
|
const [added, setAdded] = useState<AddedEntry[]>([]);
|
||||||
|
|
||||||
const showProjectSelect = !defaultProjectId;
|
const showProjectSelect = !defaultProjectId;
|
||||||
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
||||||
@@ -40,16 +46,19 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const createCheckpoint = trpc.checkpoints.create.useMutation({
|
const createCheckpoint = trpc.checkpoints.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: (_data, variables) => {
|
||||||
void utils.checkpoints.list.invalidate();
|
void utils.checkpoints.list.invalidate();
|
||||||
resetAndClose();
|
setAdded((prev) => [...prev, { title: variables.title, date: new Date(variables.date) }]);
|
||||||
|
setTitle('');
|
||||||
|
setDate(undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function resetAndClose() {
|
function handleClose() {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setDate(undefined);
|
setDate(undefined);
|
||||||
setProjectId(defaultProjectId ?? '');
|
setProjectId(defaultProjectId ?? '');
|
||||||
|
setAdded([]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +77,25 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
|
|||||||
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
|
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Checkpoint</DialogTitle>
|
<DialogTitle>Add Checkpoints</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Just-added list */}
|
||||||
|
{added.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
|
||||||
|
{added.map((entry, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||||
|
<span className="truncate">{entry.title}</span>
|
||||||
|
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Checkpoint title"
|
placeholder="Checkpoint title"
|
||||||
@@ -100,6 +123,7 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
|
|||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date}
|
||||||
|
numberOfMonths={2}
|
||||||
onSelect={setDate}
|
onSelect={setDate}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -121,11 +145,11 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={resetAndClose}>
|
<Button type="button" variant="outline" onClick={handleClose}>
|
||||||
Cancel
|
{added.length > 0 ? 'Done' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
|
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
|
||||||
Add Checkpoint
|
{added.length > 0 ? 'Add Another' : 'Add'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
107
src/renderer/components/timeline/EditCheckpointDialog.tsx
Normal file
107
src/renderer/components/timeline/EditCheckpointDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { GanttCheckpoint } from './GanttChart';
|
||||||
|
|
||||||
|
interface EditCheckpointDialogProps {
|
||||||
|
checkpoint: GanttCheckpoint | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditCheckpointDialog({ checkpoint, onOpenChange }: EditCheckpointDialogProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [date, setDate] = useState<Date | undefined>();
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkpoint) {
|
||||||
|
setTitle(checkpoint.title);
|
||||||
|
setDate(new Date(checkpoint.date));
|
||||||
|
}
|
||||||
|
}, [checkpoint]);
|
||||||
|
|
||||||
|
const updateCheckpoint = trpc.checkpoints.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.checkpoints.list.invalidate();
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!checkpoint || !title.trim() || !date) return;
|
||||||
|
|
||||||
|
updateCheckpoint.mutate({
|
||||||
|
id: checkpoint.id,
|
||||||
|
title: title.trim(),
|
||||||
|
date: date.getTime(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = title.trim() && date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!checkpoint} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Checkpoint</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Checkpoint title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'justify-start text-left font-normal',
|
||||||
|
!date && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{date ? format(date, 'PPP') : 'Pick a date'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canSubmit || updateCheckpoint.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from '@/components/ui/context-menu';
|
||||||
|
|
||||||
export interface GanttCheckpoint {
|
export interface GanttCheckpoint {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,12 +25,15 @@ interface GanttChartProps {
|
|||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onEdit?: (checkpoint: GanttCheckpoint) => void;
|
||||||
|
onToggleApproval?: (id: string, currentApproved: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADER_HEIGHT = 30;
|
const HEADER_HEIGHT = 30;
|
||||||
const BASELINE_Y = 70;
|
const BASELINE_Y = 70;
|
||||||
const SVG_HEIGHT = 100;
|
const BASELINE_HEIGHT = 8;
|
||||||
const DOT_RADIUS = 7;
|
const SVG_HEIGHT = 110;
|
||||||
|
const DOT_RADIUS = 10;
|
||||||
const PADDING_X = 40;
|
const PADDING_X = 40;
|
||||||
|
|
||||||
function getMonthsBetween(start: Date, end: Date): Date[] {
|
function getMonthsBetween(start: Date, end: Date): Date[] {
|
||||||
@@ -43,7 +53,14 @@ function dateToX(date: Date, start: Date, end: Date, width: number): number {
|
|||||||
return PADDING_X + ratio * (width - PADDING_X * 2);
|
return PADDING_X + ratio * (width - PADDING_X * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttChartProps) {
|
export function GanttChart({
|
||||||
|
checkpoints,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onToggleApproval,
|
||||||
|
}: GanttChartProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(600);
|
const [width, setWidth] = useState(600);
|
||||||
|
|
||||||
@@ -63,10 +80,11 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
|
|||||||
|
|
||||||
const months = getMonthsBetween(startDate, endDate);
|
const months = getMonthsBetween(startDate, endDate);
|
||||||
const todayX = dateToX(new Date(), startDate, endDate, width);
|
const todayX = dateToX(new Date(), startDate, endDate, width);
|
||||||
|
const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full overflow-hidden">
|
<div ref={containerRef} className="w-full overflow-hidden">
|
||||||
<svg width={width} height={SVG_HEIGHT} className="select-none">
|
<svg width={width} height={SVG_HEIGHT} className="select-none overflow-visible">
|
||||||
{/* Month labels */}
|
{/* Month labels */}
|
||||||
{months.map((month) => {
|
{months.map((month) => {
|
||||||
const x = dateToX(month, startDate, endDate, width);
|
const x = dateToX(month, startDate, endDate, width);
|
||||||
@@ -86,8 +104,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
|
|||||||
x1={x}
|
x1={x}
|
||||||
y1={HEADER_HEIGHT}
|
y1={HEADER_HEIGHT}
|
||||||
x2={x}
|
x2={x}
|
||||||
y2={BASELINE_Y + 10}
|
y2={BASELINE_Y + 14}
|
||||||
stroke="#e5e5e5"
|
stroke="var(--border)"
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="4 4"
|
strokeDasharray="4 4"
|
||||||
/>
|
/>
|
||||||
@@ -95,41 +113,47 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Baseline */}
|
{/* Baseline — thick rounded bar */}
|
||||||
<line
|
<rect
|
||||||
x1={PADDING_X}
|
x={PADDING_X}
|
||||||
y1={BASELINE_Y}
|
y={BASELINE_Y - BASELINE_HEIGHT / 2}
|
||||||
x2={width - PADDING_X}
|
width={Math.max(0, width - PADDING_X * 2)}
|
||||||
y2={BASELINE_Y}
|
height={BASELINE_HEIGHT}
|
||||||
stroke="#d4d4d4"
|
rx={BASELINE_HEIGHT / 2}
|
||||||
strokeWidth={2}
|
fill="var(--border)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Today marker */}
|
{/* Today marker — dashed line */}
|
||||||
{todayX >= PADDING_X && todayX <= width - PADDING_X && (
|
{todayVisible && (
|
||||||
<g>
|
|
||||||
<line
|
<line
|
||||||
x1={todayX}
|
x1={todayX}
|
||||||
y1={HEADER_HEIGHT}
|
y1={HEADER_HEIGHT}
|
||||||
x2={todayX}
|
x2={todayX}
|
||||||
y2={BASELINE_Y + 10}
|
y2={BASELINE_Y + 14}
|
||||||
stroke="#ef4444"
|
stroke="var(--destructive)"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 2"
|
||||||
/>
|
/>
|
||||||
<text
|
|
||||||
x={todayX}
|
|
||||||
y={BASELINE_Y + 22}
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="#ef4444"
|
|
||||||
fontSize={10}
|
|
||||||
fontFamily="Geist, sans-serif"
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Checkpoint dots rendered as foreignObject for Popover */}
|
{/* Today marker — Badge label via foreignObject */}
|
||||||
|
{todayVisible && (
|
||||||
|
<foreignObject
|
||||||
|
x={todayX - 30}
|
||||||
|
y={BASELINE_Y + 12}
|
||||||
|
width={60}
|
||||||
|
height={28}
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-destructive text-destructive">
|
||||||
|
Today
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Checkpoint dots */}
|
||||||
{checkpoints.map((cp) => {
|
{checkpoints.map((cp) => {
|
||||||
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
|
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
|
||||||
return (
|
return (
|
||||||
@@ -138,6 +162,8 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
|
|||||||
checkpoint={cp}
|
checkpoint={cp}
|
||||||
cx={cx}
|
cx={cx}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onToggleApproval={onToggleApproval}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -146,83 +172,131 @@ export function GanttChart({ checkpoints, startDate, endDate, onDelete }: GanttC
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(checkpoint: GanttCheckpoint): { text: string; className: string } {
|
||||||
|
if (checkpoint.isApproved === 0) {
|
||||||
|
return { text: 'Pending', className: 'bg-muted text-muted-foreground' };
|
||||||
|
}
|
||||||
|
if (checkpoint.date < Date.now()) {
|
||||||
|
return { text: 'Completed', className: 'bg-chart-2/15 text-chart-2' };
|
||||||
|
}
|
||||||
|
return { text: 'To Do', className: 'bg-primary/10 text-primary' };
|
||||||
|
}
|
||||||
|
|
||||||
function CheckpointDot({
|
function CheckpointDot({
|
||||||
checkpoint,
|
checkpoint,
|
||||||
cx,
|
cx,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onToggleApproval,
|
||||||
}: {
|
}: {
|
||||||
checkpoint: GanttCheckpoint;
|
checkpoint: GanttCheckpoint;
|
||||||
cx: number;
|
cx: number;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
onEdit?: (checkpoint: GanttCheckpoint) => void;
|
||||||
|
onToggleApproval?: (id: string, currentApproved: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
onDelete?.(checkpoint.id);
|
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
||||||
setOpen(false);
|
hoverTimeout.current = setTimeout(() => setHovered(true), 200);
|
||||||
}, [onDelete, checkpoint.id]);
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
||||||
|
hoverTimeout.current = setTimeout(() => setHovered(false), 150);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Determine dot style
|
|
||||||
// isApproved=0 (pending AI suggestion) → dashed outline
|
|
||||||
// isApproved=1 + date in past → green (#16a34a) = completed
|
|
||||||
// isApproved=1 + date in future → dark (#171717) = todo
|
|
||||||
const isPending = checkpoint.isApproved === 0;
|
const isPending = checkpoint.isApproved === 0;
|
||||||
const isPast = checkpoint.date < Date.now();
|
const isPast = checkpoint.date < Date.now();
|
||||||
const fill = isPending ? 'none' : (isPast ? '#16a34a' : '#171717');
|
const fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)');
|
||||||
const stroke = isPending ? '#737373' : 'none';
|
const stroke = isPending ? 'var(--muted-foreground)' : 'none';
|
||||||
const strokeDasharray = isPending ? '3 2' : undefined;
|
const strokeDasharray = isPending ? '3 2' : undefined;
|
||||||
|
const status = getStatusLabel(checkpoint);
|
||||||
|
|
||||||
return (
|
const dotSize = DOT_RADIUS * 2 + 2;
|
||||||
<foreignObject
|
const hitArea = dotSize + 8;
|
||||||
x={cx - 16}
|
|
||||||
y={BASELINE_Y - 16}
|
const dotSvg = (
|
||||||
width={32}
|
<svg width={dotSize} height={dotSize} className="shrink-0">
|
||||||
height={32}
|
|
||||||
className="overflow-visible"
|
|
||||||
>
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg width={DOT_RADIUS * 2 + 2} height={DOT_RADIUS * 2 + 2}>
|
|
||||||
<circle
|
<circle
|
||||||
cx={DOT_RADIUS + 1}
|
cx={DOT_RADIUS + 1}
|
||||||
cy={DOT_RADIUS + 1}
|
cy={DOT_RADIUS + 1}
|
||||||
r={DOT_RADIUS}
|
r={DOT_RADIUS}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
stroke={stroke || '#171717'}
|
stroke={stroke || 'var(--primary)'}
|
||||||
strokeWidth={isPending ? 1.5 : 0}
|
strokeWidth={isPending ? 1.5 : 0}
|
||||||
strokeDasharray={strokeDasharray}
|
strokeDasharray={strokeDasharray}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<foreignObject
|
||||||
|
x={cx - hitArea / 2}
|
||||||
|
y={BASELINE_Y - hitArea / 2}
|
||||||
|
width={hitArea}
|
||||||
|
height={hitArea}
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
<ContextMenu>
|
||||||
|
<Popover open={hovered}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleApproval?.(checkpoint.id, checkpoint.isApproved)}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{dotSvg}
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-60 p-3" side="top">
|
</ContextMenuTrigger>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="font-semibold text-sm">{checkpoint.title}</div>
|
<PopoverContent
|
||||||
<div className="text-xs text-muted-foreground">
|
className="w-52 p-3 pointer-events-none"
|
||||||
{format(new Date(checkpoint.date), 'PPP')}
|
side="top"
|
||||||
</div>
|
sideOffset={8}
|
||||||
{checkpoint.projectName && (
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Project: {checkpoint.projectName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{onDelete && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="mt-1"
|
|
||||||
>
|
>
|
||||||
Delete
|
<div className="flex flex-col gap-1.5">
|
||||||
</Button>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-sm leading-snug truncate">
|
||||||
|
{checkpoint.title}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 shrink-0 ${status.className}`}>
|
||||||
|
{status.text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{format(new Date(checkpoint.date), 'PPP')}
|
||||||
|
</span>
|
||||||
|
{checkpoint.projectName && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{checkpoint.projectName}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onSelect={() => onEdit?.(checkpoint)}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit Checkpoint
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() => onDelete?.(checkpoint.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete Checkpoint
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/renderer/components/ui/hover-card.tsx
Normal file
42
src/renderer/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -13,6 +13,7 @@ import { Route as TimelineRouteImport } from './routes/timeline'
|
|||||||
import { Route as TasksRouteImport } from './routes/tasks'
|
import { Route as TasksRouteImport } from './routes/tasks'
|
||||||
import { Route as ProjectsRouteImport } from './routes/projects'
|
import { Route as ProjectsRouteImport } from './routes/projects'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as NotesNoteIdRouteImport } from './routes/notes.$noteId'
|
||||||
|
|
||||||
const TimelineRoute = TimelineRouteImport.update({
|
const TimelineRoute = TimelineRouteImport.update({
|
||||||
id: '/timeline',
|
id: '/timeline',
|
||||||
@@ -34,18 +35,25 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const NotesNoteIdRoute = NotesNoteIdRouteImport.update({
|
||||||
|
id: '/notes/$noteId',
|
||||||
|
path: '/notes/$noteId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/projects': typeof ProjectsRoute
|
'/projects': typeof ProjectsRoute
|
||||||
'/tasks': typeof TasksRoute
|
'/tasks': typeof TasksRoute
|
||||||
'/timeline': typeof TimelineRoute
|
'/timeline': typeof TimelineRoute
|
||||||
|
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/projects': typeof ProjectsRoute
|
'/projects': typeof ProjectsRoute
|
||||||
'/tasks': typeof TasksRoute
|
'/tasks': typeof TasksRoute
|
||||||
'/timeline': typeof TimelineRoute
|
'/timeline': typeof TimelineRoute
|
||||||
|
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -53,13 +61,14 @@ export interface FileRoutesById {
|
|||||||
'/projects': typeof ProjectsRoute
|
'/projects': typeof ProjectsRoute
|
||||||
'/tasks': typeof TasksRoute
|
'/tasks': typeof TasksRoute
|
||||||
'/timeline': typeof TimelineRoute
|
'/timeline': typeof TimelineRoute
|
||||||
|
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/projects' | '/tasks' | '/timeline'
|
fullPaths: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/projects' | '/tasks' | '/timeline'
|
to: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||||
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline'
|
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -67,6 +76,7 @@ export interface RootRouteChildren {
|
|||||||
ProjectsRoute: typeof ProjectsRoute
|
ProjectsRoute: typeof ProjectsRoute
|
||||||
TasksRoute: typeof TasksRoute
|
TasksRoute: typeof TasksRoute
|
||||||
TimelineRoute: typeof TimelineRoute
|
TimelineRoute: typeof TimelineRoute
|
||||||
|
NotesNoteIdRoute: typeof NotesNoteIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -99,6 +109,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/notes/$noteId': {
|
||||||
|
id: '/notes/$noteId'
|
||||||
|
path: '/notes/$noteId'
|
||||||
|
fullPath: '/notes/$noteId'
|
||||||
|
preLoaderRoute: typeof NotesNoteIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +124,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ProjectsRoute: ProjectsRoute,
|
ProjectsRoute: ProjectsRoute,
|
||||||
TasksRoute: TasksRoute,
|
TasksRoute: TasksRoute,
|
||||||
TimelineRoute: TimelineRoute,
|
TimelineRoute: TimelineRoute,
|
||||||
|
NotesNoteIdRoute: NotesNoteIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
34
src/renderer/routes/notes.$noteId.tsx
Normal file
34
src/renderer/routes/notes.$noteId.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/notes/$noteId')({
|
||||||
|
component: NoteDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function NoteDetailPage() {
|
||||||
|
const { noteId } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: note } = trpc.notes.get.useQuery({ id: noteId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => void navigate({ to: '/projects' })}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{note?.title ?? 'Loading...'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Note editor will be implemented in US-016 (Milkdown).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { trpc } from '@/lib/trpc';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||||
|
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||||
|
|
||||||
export const Route = createFileRoute('/timeline')({
|
export const Route = createFileRoute('/timeline')({
|
||||||
component: TimelinePage,
|
component: TimelinePage,
|
||||||
@@ -12,6 +13,7 @@ export const Route = createFileRoute('/timeline')({
|
|||||||
|
|
||||||
function TimelinePage() {
|
function TimelinePage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||||
|
|
||||||
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
||||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||||
@@ -23,6 +25,12 @@ function TimelinePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateCheckpoint = trpc.checkpoints.update.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.checkpoints.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Build project name lookup
|
// Build project name lookup
|
||||||
const projectMap = useMemo(() => {
|
const projectMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -75,15 +83,15 @@ function TimelinePage() {
|
|||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#171717" /></svg>
|
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--primary)" /></svg>
|
||||||
To Do
|
To Do
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="#16a34a" /></svg>
|
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--chart-2)" /></svg>
|
||||||
Completed
|
Completed
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="#737373" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
|
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="var(--muted-foreground)" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
|
||||||
AI Suggestion (Pending)
|
AI Suggestion (Pending)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,11 +108,19 @@ function TimelinePage() {
|
|||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
onDelete={(id) => deleteCheckpoint.mutate({ id })}
|
onDelete={(id) => deleteCheckpoint.mutate({ id })}
|
||||||
|
onEdit={(cp) => setEditingCheckpoint(cp)}
|
||||||
|
onToggleApproval={(id, current) =>
|
||||||
|
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
<EditCheckpointDialog
|
||||||
|
checkpoint={editingCheckpoint}
|
||||||
|
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user