diff --git a/package-lock.json b/package-lock.json index 3e552cb..85bf338 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "zod": "^4.3.6" }, "devDependencies": { @@ -18950,6 +18951,15 @@ "node": "*" } }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index d0f612c..3701c25 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "zod": "^4.3.6" } } diff --git a/src/main/router/index.ts b/src/main/router/index.ts index 2be5908..100e497 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -166,6 +166,13 @@ const projectsRouter = router({ db.delete(projects).where(eq(projects.id, input.id)).run(); return { success: true as const }; }), + + archiveByClient: publicProcedure + .input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) })) + .mutation(({ input }) => { + getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run(); + return { success: true as const }; + }), }); const tasksRouter = router({ diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx index e28671a..80dae71 100644 --- a/src/renderer/components/layout/AppShell.tsx +++ b/src/renderer/components/layout/AppShell.tsx @@ -6,7 +6,7 @@ import { ClipboardCheck, FolderKanban, PanelLeft, - ChevronDown, + ChevronUp, } from 'lucide-react'; import { trpc } from '@/lib/trpc'; import { @@ -61,15 +61,15 @@ export function AppShell({ children }: AppShellProps) { {children} {/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */} -
+
+ - keep scrolling for AI + keep scrolling up for AI -
diff --git a/src/renderer/components/projects/ProjectSidebar.tsx b/src/renderer/components/projects/ProjectSidebar.tsx index 7167a37..bfb8e7b 100644 --- a/src/renderer/components/projects/ProjectSidebar.tsx +++ b/src/renderer/components/projects/ProjectSidebar.tsx @@ -5,7 +5,7 @@ import { ChevronRight, ChevronDown, Plus, - MoreHorizontal, + Pencil, Edit2, Archive, ArchiveRestore, @@ -18,12 +18,12 @@ 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'; + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; import { AlertDialog, AlertDialogAction, @@ -77,8 +77,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi 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); @@ -141,6 +153,25 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi }, }); + 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(); @@ -156,6 +187,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi ); 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); @@ -163,7 +200,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi else groups.set(key, [p]); } return groups; - }, [projectList, searchQuery]); + }, [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(() => { @@ -242,22 +292,76 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi function handleEditOpen(project: { id: string; name: string; clientId: string | null }) { setEditDialog(project); - setEditClientValue(project.clientId ?? NO_CLIENT_KEY); + 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); + } + } } - function handleEditSave() { + async function handleEditSave() { if (!editDialog) return; - const newClientId = editClientValue === NO_CLIENT_KEY ? null : editClientValue; + + let resolvedClientId: string | null; + + if (editCreatingClient && editNewClientName.trim()) { + // Create a new client + const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() }); + let 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: newClientId }, + { id: editDialog.id, clientId: resolvedClientId }, { onSuccess: () => setEditDialog(null) }, ); } - // Sort groups: client groups first (alphabetically), then "Internal / No Client" last + // Sort groups: only top-level clients + NO_CLIENT_KEY, sub-clients rendered nested const sortedGroupKeys = useMemo(() => { - const keys = [...grouped.keys()]; + // 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; @@ -265,7 +369,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi const nameB = clientMap.get(b) ?? ''; return nameA.localeCompare(nameB); }); - }, [grouped, clientMap]); + }, [grouped, clientMap, topLevelClients]); const totalProjects = projectList.length; @@ -336,16 +440,16 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi - ) : sortedGroupKeys.length === 0 ? ( + ) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
No projects match your search.
) : ( - sortedGroupKeys.map((groupKey) => { + <> + {/* Client groups */} + {sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => { const groupProjects = grouped.get(groupKey) ?? []; - const groupName = groupKey === NO_CLIENT_KEY - ? 'Internal / No Client' - : clientMap.get(groupKey) ?? 'Unknown Client'; + const groupName = clientMap.get(groupKey) ?? 'Unknown Client'; const isOpen = effectiveExpanded.has(groupKey); return ( @@ -354,110 +458,415 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi open={isOpen} onOpenChange={() => toggleExpanded(groupKey)} > - {/* Client group header */} - -
- {isOpen ? ( - - ) : ( - - )} - - - {groupName} - - - {groupProjects.length} - -
-
+ + + +
+ {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 + + +
- {groupProjects.map((project) => ( -
onSelectProject(project.id)} - > - - - {project.name} - + {/* 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; - {/* 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 - - - -
+ 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 */} + {/* 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 */} - +
+
+ + {editCreatingClient ? ( +
+ setEditNewClientName(e.target.value)} + className="flex-1" + /> + +
+ ) : ( +
+ + +
+ )} +
+ + {(editClientValue !== NO_CLIENT_KEY || (editCreatingClient && editNewClientName.trim())) && ( +
+ + {editCreatingSubClient ? ( +
+ setEditNewSubClientName(e.target.value)} + className="flex-1" + /> + +
+ ) : editCreatingClient ? ( + + ) : ( +
+ + +
+ )} +
+ )} +
- diff --git a/src/renderer/components/ui/context-menu.tsx b/src/renderer/components/ui/context-menu.tsx new file mode 100644 index 0000000..268c701 --- /dev/null +++ b/src/renderer/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" +import { ContextMenu as ContextMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/renderer/components/ui/dropdown-menu.tsx b/src/renderer/components/ui/dropdown-menu.tsx index bffc327..094358c 100644 --- a/src/renderer/components/ui/dropdown-menu.tsx +++ b/src/renderer/components/ui/dropdown-menu.tsx @@ -74,7 +74,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({