import { useState, useMemo } from 'react'; import { Folder, Circle, ChevronRight, ChevronDown, Plus, Pencil, Edit2, Archive, ArchiveRestore, Trash2, 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 { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, 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, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '@/components/ui/empty'; const NO_CLIENT_KEY = '__no_client__'; type ProjectSidebarProps = { selectedProjectId: string | undefined; onSelectProject: (id: string) => void; }; export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSidebarProps) { const utils = trpc.useUtils(); const [showArchived, setShowArchived] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [expanded, setExpanded] = useState>(new Set()); const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null); const [renameProject, setRenameProject] = useState<{ id: string; name: string } | null>(null); const [renameProjectValue, setRenameProjectValue] = useState(''); const [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null); // Client action state const [renameClient, setRenameClient] = useState<{ id: string; name: string } | null>(null); const [renameClientValue, setRenameClientValue] = useState(''); const [deleteClient, setDeleteClient] = useState<{ id: string; name: string } | null>(null); const [editClientValue, setEditClientValue] = useState(NO_CLIENT_KEY); const [editSubClientValue, setEditSubClientValue] = useState(NO_CLIENT_KEY); const [editCreatingClient, setEditCreatingClient] = useState(false); const [editNewClientName, setEditNewClientName] = useState(''); const [editCreatingSubClient, setEditCreatingSubClient] = useState(false); const [editNewSubClientName, setEditNewSubClientName] = useState(''); // New-project dialog state const [newProjectOpen, setNewProjectOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT_KEY); const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT_KEY); // Inline client creation const [creatingClient, setCreatingClient] = useState(false); const [newClientName, setNewClientName] = useState(''); // Inline sub-client creation const [creatingSubClient, setCreatingSubClient] = useState(false); const [newSubClientName, setNewSubClientName] = useState(''); const { data: projectList = [] } = trpc.projects.list.useQuery( { includeArchived: showArchived }, ); const { data: clientList = [] } = trpc.clients.list.useQuery(); // Derived: top-level clients and sub-clients grouped by parentId const topLevelClients = useMemo( () => clientList.filter((c) => !c.parentId), [clientList], ); const subClientsByParent = useMemo(() => { const m = new Map(); for (const c of clientList) { if (c.parentId) { const arr = m.get(c.parentId); if (arr) arr.push(c); else m.set(c.parentId, [c]); } } return m; }, [clientList]); const createClientMutation = trpc.clients.create.useMutation({ onSuccess: () => { void utils.clients.list.invalidate(); }, }); const createMutation = trpc.projects.create.useMutation({ onSuccess: (data, variables) => { // Auto-expand the matching client group const groupKey = variables.clientId ?? NO_CLIENT_KEY; setExpanded((prev) => new Set([...prev, groupKey])); onSelectProject(data.id); void utils.projects.list.invalidate(); }, }); const updateMutation = trpc.projects.update.useMutation({ onSuccess: () => { void utils.projects.list.invalidate(); }, }); const deleteMutation = trpc.projects.delete.useMutation({ onSuccess: () => { setDeleteProjectId(null); void utils.projects.list.invalidate(); }, }); const updateClientMutation = trpc.clients.update.useMutation({ onSuccess: () => { setRenameClient(null); void utils.clients.list.invalidate(); }, }); const archiveByClientMutation = trpc.projects.archiveByClient.useMutation({ onSuccess: () => { void utils.projects.list.invalidate(); }, }); const deleteClientMutation = trpc.clients.deleteWithCascade.useMutation({ onSuccess: () => { setDeleteClient(null); void utils.clients.list.invalidate(); void utils.projects.list.invalidate(); }, }); // Build a client lookup map const clientMap = useMemo(() => { const m = new Map(); for (const c of clientList) m.set(c.id, c.name); return m; }, [clientList]); // 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(); // Ensure every client appears even with 0 projects for (const c of clientList) { groups.set(c.id, []); } 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, clientList, searchQuery]); // Count projects for a top-level client including its sub-clients const totalProjectCount = useMemo(() => { const counts = new Map(); for (const c of topLevelClients) { let count = (grouped.get(c.id) ?? []).length; for (const sc of subClientsByParent.get(c.id) ?? []) { count += (grouped.get(sc.id) ?? []).length; } counts.set(c.id, count); } return counts; }, [topLevelClients, subClientsByParent, grouped]); // 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(key) ? next.delete(key) : next.add(key); return next; }); } function handleOpenNewProject() { setNewProjectName(''); setNewProjectClientId(NO_CLIENT_KEY); setNewProjectSubClientId(NO_CLIENT_KEY); setCreatingClient(false); setNewClientName(''); setCreatingSubClient(false); setNewSubClientName(''); setNewProjectOpen(true); } async function handleCreateProject() { const name = newProjectName.trim(); if (!name) return; let resolvedClientId: string | undefined; // If creating a brand-new client first if (creatingClient && newClientName.trim()) { const result = await createClientMutation.mutateAsync({ name: newClientName.trim() }); resolvedClientId = result.id; // If also creating a sub-client under the new client if (creatingSubClient && newSubClientName.trim()) { const subResult = await createClientMutation.mutateAsync({ name: newSubClientName.trim(), parentId: resolvedClientId, }); resolvedClientId = subResult.id; } } else if (newProjectClientId !== NO_CLIENT_KEY) { // User picked an existing client if (creatingSubClient && newSubClientName.trim()) { // Create a new sub-client under the selected client const subResult = await createClientMutation.mutateAsync({ name: newSubClientName.trim(), parentId: newProjectClientId, }); resolvedClientId = subResult.id; } else if (newProjectSubClientId !== NO_CLIENT_KEY) { // User picked an existing sub-client resolvedClientId = newProjectSubClientId; } else { // Just the parent client, no sub-client resolvedClientId = newProjectClientId; } } createMutation.mutate( { name, clientId: resolvedClientId }, { onSuccess: () => setNewProjectOpen(false) }, ); } 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); setEditCreatingClient(false); setEditNewClientName(''); setEditCreatingSubClient(false); setEditNewSubClientName(''); if (!project.clientId) { setEditClientValue(NO_CLIENT_KEY); setEditSubClientValue(NO_CLIENT_KEY); } else { // Check if clientId is a sub-client const client = clientList.find((c) => c.id === project.clientId); if (client?.parentId) { // It's a sub-client — set parent as client, this as sub-client setEditClientValue(client.parentId); setEditSubClientValue(client.id); } else { setEditClientValue(project.clientId); setEditSubClientValue(NO_CLIENT_KEY); } } } async function handleEditSave() { if (!editDialog) return; let resolvedClientId: string | null; if (editCreatingClient && editNewClientName.trim()) { // Create a new client const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() }); const parentId = result.id; if (editCreatingSubClient && editNewSubClientName.trim()) { // Also create a sub-client under the new client const subResult = await createClientMutation.mutateAsync({ name: editNewSubClientName.trim(), parentId, }); resolvedClientId = subResult.id; } else { resolvedClientId = parentId; } } else if (editCreatingSubClient && editNewSubClientName.trim() && editClientValue !== NO_CLIENT_KEY) { // Create a new sub-client under the selected parent const result = await createClientMutation.mutateAsync({ name: editNewSubClientName.trim(), parentId: editClientValue, }); resolvedClientId = result.id; } else if (editSubClientValue !== NO_CLIENT_KEY) { resolvedClientId = editSubClientValue; } else if (editClientValue !== NO_CLIENT_KEY) { resolvedClientId = editClientValue; } else { resolvedClientId = null; } updateMutation.mutate( { id: editDialog.id, clientId: resolvedClientId }, { onSuccess: () => setEditDialog(null) }, ); } // Sort groups: only top-level clients + NO_CLIENT_KEY, sub-clients rendered nested const sortedGroupKeys = useMemo(() => { // Only include top-level clients and the NO_CLIENT_KEY group const topLevelIds = new Set(topLevelClients.map((c) => c.id)); const keys = [...grouped.keys()].filter( (k) => k === NO_CLIENT_KEY || topLevelIds.has(k), ); 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, topLevelClients]); const totalProjects = projectList.length; return (
{/* Header */}

Projects

{/* Search */}
setSearchQuery(e.target.value)} />
{/* Show archived toggle */}
{/* Project tree */} {totalProjects === 0 ? ( No projects yet Get started by adding your first project. ) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
No projects match your search.
) : ( <> {/* Client groups */} {sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => { const groupProjects = grouped.get(groupKey) ?? []; const groupName = clientMap.get(groupKey) ?? 'Unknown Client'; const isOpen = effectiveExpanded.has(groupKey); return ( toggleExpanded(groupKey)} >
{isOpen ? ( ) : ( )} {groupName} {totalProjectCount.get(groupKey) ?? groupProjects.length}
{ setRenameClient({ id: groupKey, name: groupName }); setRenameClientValue(groupName); }} > Rename { const allArchived = groupProjects.every((p) => p.status === 'archived'); archiveByClientMutation.mutate({ clientId: groupKey, status: allArchived ? 'active' : 'archived', }); }} > {groupProjects.every((p) => p.status === 'archived') ? ( <> Unarchive All ) : ( <> Archive All )} { setDeleteClient({ id: groupKey, name: groupName }); }} > Delete
{/* Sub-clients nested under this parent */} {(subClientsByParent.get(groupKey) ?? []).map((subClient) => { const subProjects = grouped.get(subClient.id) ?? []; const subIsOpen = effectiveExpanded.has(subClient.id); const subName = subClient.name; return ( toggleExpanded(subClient.id)} >
{subIsOpen ? ( ) : ( )} {subName} {subProjects.length}
{ setRenameClient({ id: subClient.id, name: subName }); setRenameClientValue(subName); }} > Rename { const allArchived = subProjects.every((p) => p.status === 'archived'); archiveByClientMutation.mutate({ clientId: subClient.id, status: allArchived ? 'active' : 'archived', }); }} > {subProjects.every((p) => p.status === 'archived') ? ( <> Unarchive All ) : ( <> Archive All )} { setDeleteClient({ id: subClient.id, name: subName }); }} > Delete
{subProjects.map((project) => (
onSelectProject(project.id)} > {project.name}
{ setRenameProject({ id: project.id, name: project.name }); setRenameProjectValue(project.name); }} > Rename { handleEditOpen({ id: project.id, name: project.name, clientId: project.clientId, }); }} > Edit Client { handleArchiveToggle(project.id, project.status); }} > {project.status === 'archived' ? ( <> Unarchive ) : ( <> Archive )} { setDeleteProjectId({ id: project.id, name: project.name }); }} > Delete
))}
); })} {/* Direct projects under this client */} {groupProjects.map((project) => (
onSelectProject(project.id)} > {project.name}
{ setRenameProject({ id: project.id, name: project.name }); setRenameProjectValue(project.name); }} > Rename { handleEditOpen({ id: project.id, name: project.name, clientId: project.clientId, }); }} > Edit Client { handleArchiveToggle(project.id, project.status); }} > {project.status === 'archived' ? ( <> Unarchive ) : ( <> Archive )} { setDeleteProjectId({ id: project.id, name: project.name }); }} > Delete
))}
); })} {/* Unassigned projects (no client) — rendered flat at root level */} {(grouped.get(NO_CLIENT_KEY) ?? []).map((project) => (
onSelectProject(project.id)} > {project.name}
{ setRenameProject({ id: project.id, name: project.name }); setRenameProjectValue(project.name); }} > Rename { handleEditOpen({ id: project.id, name: project.name, clientId: project.clientId, }); }} > Edit Client { handleArchiveToggle(project.id, project.status); }} > {project.status === 'archived' ? ( <> Unarchive ) : ( <> Archive )} { setDeleteProjectId({ id: project.id, name: project.name }); }} > Delete
))} )}
{/* Rename project dialog */} { if (!open) setRenameProject(null); }} > Rename Project Enter a new name for “{renameProject?.name}”. setRenameProjectValue(e.target.value)} placeholder="Project name" autoFocus /> {/* Delete confirmation */} { if (!open && !deleteMutation.isPending) setDeleteProjectId(null); }} > Delete “{deleteProjectId?.name}”? This will delete the project. Tasks assigned to this project will become unassigned. This action cannot be undone. Cancel { if (deleteProjectId) deleteMutation.mutate({ id: deleteProjectId.id }); }} disabled={deleteMutation.isPending} > {deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'} {/* New Project dialog */} New Project Give your project a name and optionally assign it to a client.
{/* Project name */}
setNewProjectName(e.target.value)} autoFocus />
{/* Client selection */}
{creatingClient ? (
setNewClientName(e.target.value)} className="flex-1" />
) : (
)}
{/* Sub-client selection — only when a client is selected or being created */} {(newProjectClientId !== NO_CLIENT_KEY || (creatingClient && newClientName.trim())) && (
{creatingSubClient ? (
setNewSubClientName(e.target.value)} className="flex-1" />
) : creatingClient ? ( /* When creating a new client there are no existing sub-clients to pick */ ) : (
)}
)}
{/* Delete client confirmation */} { if (!open && !deleteClientMutation.isPending) setDeleteClient(null); }} > Delete “{deleteClient?.name}”? This will delete the client and all its projects. Tasks assigned to those projects will become unassigned. This action cannot be undone. Cancel { if (deleteClient) deleteClientMutation.mutate({ id: deleteClient.id }); }} disabled={deleteClientMutation.isPending} > {deleteClientMutation.isPending ? 'Deleting\u2026' : 'Delete'} {/* Rename client dialog */} { if (!open) setRenameClient(null); }} > Rename Client Enter a new name for “{renameClient?.name}”. setRenameClientValue(e.target.value)} placeholder="Client name" autoFocus /> {/* Edit client dialog */} { if (!open) setEditDialog(null); }} > Edit Project Client Assign “{editDialog?.name}” to a client or leave as internal.
{editCreatingClient ? (
setEditNewClientName(e.target.value)} className="flex-1" />
) : (
)}
{(editClientValue !== NO_CLIENT_KEY || (editCreatingClient && editNewClientName.trim())) && (
{editCreatingSubClient ? (
setEditNewSubClientName(e.target.value)} className="flex-1" />
) : editCreatingClient ? ( ) : (
)}
)}
); }