diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 31f0674..98993f6 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -242,8 +242,8 @@ "Verify in browser using dev-browser skill" ], "priority": 13, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: Project Detail view with Breadcrumb (Client > Sub-Client path from clients list), H1 project name, 3 stat cards (Notes count, Tasks Complete done/total, Checkpoints approved/total), AI Project Summary card with sparkles icon showing aiSummary or placeholder text. All data fetched via tRPC queries scoped to projectId. shadcn/ui breadcrumb installed." }, { "id": "US-014", diff --git a/src/main/router/index.ts b/src/main/router/index.ts index 100e497..cb6c6eb 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -230,13 +230,37 @@ const tasksRouter = router({ .all(); }), + listAssignees: publicProcedure.query(() => { + const rows = getDb() + .select({ assignee: tasks.assignee }) + .from(tasks) + .all(); + const names = new Set(); + for (const row of rows) { + if (!row.assignee) continue; + try { + const parsed = JSON.parse(row.assignee) as unknown; + if (Array.isArray(parsed)) { + for (const n of parsed) { + if (typeof n === 'string' && n) names.add(n); + } + } else { + names.add(row.assignee); + } + } catch { + names.add(row.assignee); + } + } + return [...names].sort(); + }), + create: publicProcedure .input(z.object({ title: z.string(), description: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), - assignee: z.string().optional(), + assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), })) @@ -249,7 +273,7 @@ const tasksRouter = router({ description: input.description ?? null, status: input.status ?? 'todo', priority: input.priority ?? 'medium', - assignee: input.assignee ?? null, + assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null, dueDate: input.dueDate ?? null, projectId: input.projectId ?? null, createdAt: now, @@ -264,7 +288,7 @@ const tasksRouter = router({ description: z.string().optional(), status: z.string().optional(), priority: z.string().optional(), - assignee: z.string().optional(), + assignees: z.array(z.string()).optional(), dueDate: z.number().optional(), projectId: z.string().optional(), })) @@ -282,7 +306,7 @@ const tasksRouter = router({ if (input.description !== undefined) set.description = input.description; if (input.status !== undefined) set.status = input.status; if (input.priority !== undefined) set.priority = input.priority; - if (input.assignee !== undefined) set.assignee = input.assignee; + if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null; if (input.dueDate !== undefined) set.dueDate = input.dueDate; if (input.projectId !== undefined) set.projectId = input.projectId; if (Object.keys(set).length > 0) { diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index 3629d7f..267bac4 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -1,4 +1,13 @@ +import { useMemo } from 'react'; +import { Sparkles, FileText, CheckCircle2, Milestone } from 'lucide-react'; import { trpc } from '@/lib/trpc'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; type ProjectDetailProps = { projectId: string; @@ -6,6 +15,41 @@ type ProjectDetailProps = { export function ProjectDetail({ projectId }: ProjectDetailProps) { 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]); if (isLoading) { return ( @@ -24,11 +68,81 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) { } return ( -
+
+ {/* Breadcrumb */} + {breadcrumbPath.length > 0 && ( + + + {breadcrumbPath.map((segment, i) => ( + + {i > 0 && } + {segment} + + ))} + + + )} + + {/* Project Name */}

{project.name}

-

- Project detail view will be implemented in US-013. -

+ + {/* Stat Cards */} +
+ + +
+ + Notes +
+
+ +
{notesCount}
+
+
+ + + +
+ + Tasks Complete +
+
+ +
+ {taskStats.done}/{taskStats.total} +
+
+
+ + + +
+ + Checkpoints +
+
+ +
+ {checkpointStats.approved}/{checkpointStats.total} +
+
+
+
+ + {/* AI Project Summary */} + + +
+ + AI Project Summary +
+
+ +

+ {project.aiSummary || 'AI summary will appear here'} +

+
+
); } diff --git a/src/renderer/components/tasks/EditTaskDialog.tsx b/src/renderer/components/tasks/EditTaskDialog.tsx new file mode 100644 index 0000000..a643928 --- /dev/null +++ b/src/renderer/components/tasks/EditTaskDialog.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect } from 'react'; +import { format } from 'date-fns'; +import { Calendar as CalendarIcon, X, UserPlus, Check } 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 { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; +import type { TaskItem } from './TaskRow'; + +function parseAssigneesLocal(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string'); + } catch { /* plain string fallback */ } + return [raw]; +} + +interface EditTaskDialogProps { + task: TaskItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [priority, setPriority] = useState('medium'); + const [status, setStatus] = useState('todo'); + const [dueDate, setDueDate] = useState(); + const [dueTime, setDueTime] = useState(''); + const [projectId, setProjectId] = useState(''); + const [assignees, setAssignees] = useState([]); + const [assigneeInput, setAssigneeInput] = useState(''); + const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false); + + // Pre-fill fields whenever the task changes + useEffect(() => { + if (!task) return; + setTitle(task.title); + setDescription(task.description ?? ''); + setPriority(task.priority ?? 'medium'); + setStatus(task.status ?? 'todo'); + if (task.dueDate) { + const d = new Date(task.dueDate); + setDueDate(d); + setDueTime( + `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, + ); + } else { + setDueDate(undefined); + setDueTime(''); + } + setProjectId(task.projectId ?? ''); + setAssignees(parseAssigneesLocal(task.assignee)); + setAssigneeInput(''); + setAssigneePopoverOpen(false); + }, [task]); + + const { data: projectsList } = trpc.projects.listAll.useQuery(); + const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery(); + const utils = trpc.useUtils(); + + const updateTask = trpc.tasks.update.useMutation({ + onSuccess: () => { + void utils.tasks.list.invalidate(); + onOpenChange(false); + }, + }); + + function addNewAssignee() { + const name = assigneeInput.trim(); + if (!name || assignees.includes(name)) return; + setAssignees((prev) => [...prev, name]); + setAssigneeInput(''); + } + + function toggleAssignee(name: string) { + setAssignees((prev) => + prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name], + ); + } + + function removeAssignee(name: string) { + setAssignees((prev) => prev.filter((a) => a !== name)); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!task || !title.trim()) return; + + let resolvedDueDate: number | undefined; + if (dueDate) { + const d = new Date(dueDate); + if (dueTime) { + const parts = dueTime.split(':'); + const h = parseInt(parts[0] ?? '0', 10); + const m = parseInt(parts[1] ?? '0', 10); + d.setHours(h, m, 0, 0); + } + resolvedDueDate = d.getTime(); + } + + updateTask.mutate({ + id: task.id, + title: title.trim(), + description: description.trim() || undefined, + priority, + status, + dueDate: resolvedDueDate, + projectId: projectId || undefined, + assignees: assignees.length ? assignees : undefined, + }); + } + + return ( + + + + Edit Task + +
+ {/* Title */} + setTitle(e.target.value)} + required + autoFocus + /> + + {/* Description */} +