import { useState, useMemo } from 'react'; import { Folder, Circle, ChevronRight, ChevronDown, Plus, MoreHorizontal, 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-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 { 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 [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null); const [editClientValue, setEditClientValue] = useState(NO_CLIENT_KEY); // 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(); }, }); // 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(); 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(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); 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) }, ); } // 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]); 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 ? (
No projects match your search.
) : ( 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 ( toggleExpanded(groupKey)} > {/* Client group header */}
{isOpen ? ( ) : ( )} {groupName} {groupProjects.length}
{groupProjects.map((project) => (
onSelectProject(project.id)} > {project.name} {/* Context menu */} { e.stopPropagation(); handleEditOpen({ id: project.id, name: project.name, clientId: project.clientId, }); }} > Edit Client { e.stopPropagation(); handleArchiveToggle(project.id, project.status); }} > {project.status === 'archived' ? ( <> Unarchive ) : ( <> Archive )} { e.stopPropagation(); setDeleteProjectId({ id: project.id, name: project.name }); }} > Delete
))}
); }) )}
{/* 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 */ ) : (
)}
)}
{/* Edit client dialog */} { if (!open) setEditDialog(null); }} > Edit Project Client Assign “{editDialog?.name}” to a client or leave as internal.
); }