feat: US-010 — Projects sidebar tree view and project detail routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-19 22:54:02 +01:00
parent 6bf465c983
commit 4180c3d215
11 changed files with 846 additions and 283 deletions

View File

@@ -0,0 +1,34 @@
import { trpc } from '@/lib/trpc';
type ProjectDetailProps = {
projectId: string;
};
export function ProjectDetail({ projectId }: ProjectDetailProps) {
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
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 max-w-4xl mx-auto">
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
<p className="text-sm text-muted-foreground mt-1">
Project detail view will be implemented in US-013.
</p>
</div>
);
}

View File

@@ -1,19 +1,22 @@
import { useState, useCallback } from 'react';
import { useState, useMemo } from 'react';
import {
Folder,
Circle,
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Edit2,
FolderPlus,
Archive,
ArchiveRestore,
Trash2,
AlertTriangle,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
DropdownMenu,
DropdownMenuContent,
@@ -31,6 +34,21 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
@@ -45,251 +63,126 @@ import {
EmptyTitle,
} from '@/components/ui/empty';
type ProjectFlat = {
id: string;
name: string;
parentId: string | null;
industry: string | null;
createdAt: number;
const NO_CLIENT_KEY = '__no_client__';
type ProjectSidebarProps = {
selectedProjectId: string | undefined;
onSelectProject: (id: string) => void;
};
type ProjectNode = ProjectFlat & { children: ProjectNode[] };
function buildTree(projects: ProjectFlat[]): ProjectNode[] {
const map = new Map<string, ProjectNode>();
for (const c of projects) map.set(c.id, { ...c, children: [] });
const roots: ProjectNode[] = [];
for (const c of projects) {
const node = map.get(c.id)!;
if (c.parentId) {
const parent = map.get(c.parentId);
if (parent) parent.children.push(node);
else roots.push(node);
} else {
roots.push(node);
}
}
return roots;
}
type DeleteDialog = {
id: string;
name: string;
stage: 'confirm' | 'cascade-warn';
errorMessage?: string;
};
export function ProjectSidebar() {
export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) {
const utils = trpc.useUtils();
const { data: projects = [] } = trpc.clients.list.useQuery();
const [showArchived, setShowArchived] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialog | null>(null);
const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null);
const [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null);
const [editClientValue, setEditClientValue] = useState<string>(NO_CLIENT_KEY);
// Callback ref: auto-focus + select when rename input mounts
const renameInputCallback = useCallback((el: HTMLInputElement | null) => {
if (el) {
el.focus();
el.select();
}
}, []);
const { data: projectList = [] } = trpc.projects.list.useQuery(
{ includeArchived: showArchived },
);
const { data: clientList = [] } = trpc.clients.list.useQuery();
const createMutation = trpc.clients.create.useMutation({
onSuccess: () => { void utils.clients.list.invalidate(); },
const createMutation = trpc.projects.create.useMutation({
onSuccess: () => { void utils.projects.list.invalidate(); },
});
const updateMutation = trpc.clients.update.useMutation({
onSuccess: () => { void utils.clients.list.invalidate(); },
const updateMutation = trpc.projects.update.useMutation({
onSuccess: () => { void utils.projects.list.invalidate(); },
});
const deleteMutation = trpc.clients.delete.useMutation({
onSuccess: (result) => {
if ('error' in result) {
setDeleteDialog((prev) =>
prev ? { ...prev, stage: 'cascade-warn', errorMessage: result.error } : null,
);
} else {
setDeleteDialog(null);
void utils.clients.list.invalidate();
}
},
});
const cascadeDeleteMutation = trpc.clients.deleteWithCascade.useMutation({
const deleteMutation = trpc.projects.delete.useMutation({
onSuccess: () => {
setDeleteDialog(null);
void utils.clients.list.invalidate();
setDeleteProjectId(null);
void utils.projects.list.invalidate();
},
});
const tree = buildTree(projects as ProjectFlat[]);
// Build a client lookup map
const clientMap = useMemo(() => {
const m = new Map<string, string>();
for (const c of clientList) m.set(c.id, c.name);
return m;
}, [clientList]);
function toggleExpanded(id: string) {
// Group projects by clientId, filter by search
const grouped = useMemo(() => {
const lowerSearch = searchQuery.toLowerCase();
const filtered = projectList.filter((p) =>
!searchQuery || p.name.toLowerCase().includes(lowerSearch),
);
const groups = new Map<string, typeof filtered>();
for (const p of filtered) {
const key = p.clientId ?? NO_CLIENT_KEY;
const arr = groups.get(key);
if (arr) arr.push(p);
else groups.set(key, [p]);
}
return groups;
}, [projectList, searchQuery]);
// Auto-expand all groups when searching
const effectiveExpanded = useMemo(() => {
if (searchQuery) {
return new Set([...grouped.keys()]);
}
return expanded;
}, [searchQuery, grouped, expanded]);
function toggleExpanded(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
}
function handleNewProject() {
createMutation.mutate(
{ name: 'New Project' },
{
onSuccess: (result) => {
setRenaming({ id: result.id, value: 'New Project' });
},
},
createMutation.mutate({ name: 'New Project' });
}
function handleArchiveToggle(id: string, currentStatus: string | null) {
const newStatus = currentStatus === 'archived' ? 'active' : 'archived';
updateMutation.mutate({ id, status: newStatus as 'active' | 'archived' });
}
function handleEditOpen(project: { id: string; name: string; clientId: string | null }) {
setEditDialog(project);
setEditClientValue(project.clientId ?? NO_CLIENT_KEY);
}
function handleEditSave() {
if (!editDialog) return;
const newClientId = editClientValue === NO_CLIENT_KEY ? null : editClientValue;
updateMutation.mutate(
{ id: editDialog.id, clientId: newClientId },
{ onSuccess: () => setEditDialog(null) },
);
}
function handleNewSubProject(parentId: string) {
createMutation.mutate(
{ name: 'New Sub-Project', parentId },
{
onSuccess: (result) => {
setExpanded((prev) => new Set([...prev, parentId]));
setRenaming({ id: result.id, value: 'New Sub-Project' });
},
},
);
}
function handleRenameStart(id: string, name: string) {
setRenaming({ id, value: name });
}
// Sort groups: client groups first (alphabetically), then "Internal / No Client" last
const sortedGroupKeys = useMemo(() => {
const keys = [...grouped.keys()];
return keys.sort((a, b) => {
if (a === NO_CLIENT_KEY) return 1;
if (b === NO_CLIENT_KEY) return -1;
const nameA = clientMap.get(a) ?? '';
const nameB = clientMap.get(b) ?? '';
return nameA.localeCompare(nameB);
});
}, [grouped, clientMap]);
function handleRenameSave() {
if (!renaming) return;
const trimmed = renaming.value.trim();
if (trimmed) {
updateMutation.mutate({ id: renaming.id, name: trimmed });
}
setRenaming(null);
}
function handleRenameKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleRenameSave();
if (e.key === 'Escape') setRenaming(null);
}
function handleDeleteClick(id: string, name: string) {
setDeleteDialog({ id, name, stage: 'confirm' });
}
function handleDeleteConfirm() {
if (!deleteDialog) return;
deleteMutation.mutate({ id: deleteDialog.id });
}
function handleCascadeDelete() {
if (!deleteDialog) return;
cascadeDeleteMutation.mutate({ id: deleteDialog.id });
}
function renderNode(node: ProjectNode, depth = 0) {
const isExpanded = expanded.has(node.id);
const isRenaming = renaming?.id === node.id;
const hasChildren = node.children.length > 0;
return (
<Collapsible
key={node.id}
open={isExpanded}
onOpenChange={() => hasChildren && toggleExpanded(node.id)}
>
<div
className="group relative flex items-center h-7 rounded-md text-sm cursor-default hover:bg-accent transition-colors"
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '4px' }}
>
{/* Expand/collapse chevron */}
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-4 p-0 hover:bg-transparent text-muted-foreground"
tabIndex={-1}
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />
) : (
<span className="size-4 inline-block" />
)}
</Button>
</CollapsibleTrigger>
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
{isRenaming ? (
<Input
ref={renameInputCallback}
className="flex-1 min-w-0 h-5 text-sm px-1 py-0"
value={renaming.value}
onChange={(e) => setRenaming({ id: node.id, value: e.target.value })}
onBlur={handleRenameSave}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="flex-1 min-w-0 truncate text-foreground">{node.name}</span>
)}
{/* Kebab menu */}
{!isRenaming && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'size-5 p-0 ml-1 text-muted-foreground hover:bg-muted shrink-0',
'opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100',
)}
tabIndex={-1}
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[152px]">
<DropdownMenuItem onClick={() => handleRenameStart(node.id, node.name)}>
<Edit2 />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleNewSubProject(node.id)}>
<FolderPlus />
New Sub-Project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteClick(node.id, node.name)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Children */}
<CollapsibleContent>
{node.children.map((child) => renderNode(child, depth + 1))}
</CollapsibleContent>
</Collapsible>
);
}
const totalProjects = projectList.length;
return (
<div className="flex flex-col h-full border-r border-border w-60 shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
<h4 className="text-lg font-semibold text-foreground">
Projects
</h4>
<h4 className="text-lg font-semibold text-foreground">Projects</h4>
<Button
variant="outline"
size="icon"
@@ -301,15 +194,41 @@ export function ProjectSidebar() {
</Button>
</div>
{/* Search */}
<div className="px-3 pb-2 shrink-0">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder="Search projects..."
className="h-7 text-sm pl-7"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Show archived toggle */}
<div className="flex items-center justify-between px-3 pb-2 shrink-0">
<label htmlFor="show-archived" className="text-xs text-muted-foreground cursor-pointer">
Show archived
</label>
<Switch
id="show-archived"
checked={showArchived}
onCheckedChange={setShowArchived}
className="scale-75"
/>
</div>
{/* Project tree */}
<div className="flex-1 overflow-y-auto py-1 px-1">
{tree.length === 0 ? (
{totalProjects === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Folder />
</EmptyMedia>
<EmptyTitle>No project yet</EmptyTitle>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>
Get started by adding your first project.
</EmptyDescription>
@@ -326,77 +245,197 @@ export function ProjectSidebar() {
</Button>
</EmptyContent>
</Empty>
) : sortedGroupKeys.length === 0 ? (
<div className="text-xs text-muted-foreground px-3 py-4 text-center">
No projects match your search.
</div>
) : (
tree.map((node) => renderNode(node))
sortedGroupKeys.map((groupKey) => {
const groupProjects = grouped.get(groupKey) ?? [];
const groupName = groupKey === NO_CLIENT_KEY
? 'Internal / No Client'
: clientMap.get(groupKey) ?? 'Unknown Client';
const isOpen = effectiveExpanded.has(groupKey);
return (
<Collapsible
key={groupKey}
open={isOpen}
onOpenChange={() => toggleExpanded(groupKey)}
>
{/* Client group header */}
<CollapsibleTrigger asChild>
<div className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2">
{isOpen ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
)}
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
{groupName}
</span>
<span className="text-xs text-muted-foreground ml-1">
{groupProjects.length}
</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
{groupProjects.map((project) => (
<div
key={project.id}
className={cn(
'group relative flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
selectedProjectId === project.id && 'bg-sidebar-accent',
project.status === 'archived' && 'opacity-60',
)}
style={{ paddingLeft: '28px', paddingRight: '4px' }}
onClick={() => onSelectProject(project.id)}
>
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
<span className="flex-1 min-w-0 truncate text-foreground">
{project.name}
</span>
{/* Context menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'size-5 p-0 ml-1 text-muted-foreground hover:bg-muted shrink-0',
'opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100',
)}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[152px]">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditOpen({
id: project.id,
name: project.name,
clientId: project.clientId,
});
}}
>
<Edit2 />
Edit Client
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleArchiveToggle(project.id, project.status);
}}
>
{project.status === 'archived' ? (
<>
<ArchiveRestore />
Unarchive
</>
) : (
<>
<Archive />
Archive
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteProjectId({ id: project.id, name: project.name });
}}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</CollapsibleContent>
</Collapsible>
);
})
)}
</div>
{/* Delete confirmation — AlertDialog */}
{/* Delete confirmation */}
<AlertDialog
open={!!deleteDialog}
open={!!deleteProjectId}
onOpenChange={(open) => {
if (!open && !deleteMutation.isPending && !cascadeDeleteMutation.isPending) {
setDeleteDialog(null);
}
if (!open && !deleteMutation.isPending) setDeleteProjectId(null);
}}
>
<AlertDialogContent>
{deleteDialog?.stage === 'cascade-warn' ? (
<>
<AlertDialogHeader>
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<AlertDialogTitle>Cannot delete safely</AlertDialogTitle>
<AlertDialogDescription className="mt-1">
{deleteDialog.errorMessage}
</AlertDialogDescription>
</div>
</div>
<AlertDialogDescription>
Force-delete &ldquo;{deleteDialog?.name}&rdquo; along with all its sub-projects?
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cascadeDeleteMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleCascadeDelete}
disabled={cascadeDeleteMutation.isPending}
>
{cascadeDeleteMutation.isPending ? 'Deleting\u2026' : 'Force Delete All'}
</AlertDialogAction>
</AlertDialogFooter>
</>
) : (
<>
<AlertDialogHeader>
<AlertDialogTitle>
Delete &ldquo;{deleteDialog?.name}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</>
)}
<AlertDialogHeader>
<AlertDialogTitle>
Delete &ldquo;{deleteProjectId?.name}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete the project. Tasks assigned to this project will become unassigned. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
if (deleteProjectId) deleteMutation.mutate({ id: deleteProjectId.id });
}}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Edit client dialog */}
<Dialog
open={!!editDialog}
onOpenChange={(open) => {
if (!open) setEditDialog(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Project Client</DialogTitle>
<DialogDescription>
Assign &ldquo;{editDialog?.name}&rdquo; to a client or leave as internal.
</DialogDescription>
</DialogHeader>
<Select value={editClientValue} onValueChange={setEditClientValue}>
<SelectTrigger>
<SelectValue placeholder="Select a client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
{clientList.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="outline" onClick={() => setEditDialog(null)}>
Cancel
</Button>
<Button onClick={handleEditSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}