diff --git a/package-lock.json b/package-lock.json index 4e4021d..4534ad3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,8 +14,10 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.90.21", @@ -2976,6 +2978,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3548,6 +3556,90 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -3612,6 +3704,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -3772,6 +3893,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", diff --git a/package.json b/package.json index 51b169f..3ced825 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.90.21", diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 5415058..302b815 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -181,8 +181,8 @@ "Verify in browser using dev-browser skill" ], "priority": 10, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: ProjectSidebar reworked to show projects grouped by client (Collapsible groups), 'Internal / No Client' group for orphan projects, real-time search filter, archive toggle (Switch), context menu (Edit Client via Dialog+Select, Archive/Unarchive, Delete with AlertDialog), click selects project via search params, active project highlighted. ProjectDetail placeholder component. projects.update now accepts nullable clientId. shadcn/ui: dialog, select, switch installed." }, { "id": "US-011", diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index 12c3aba..ff66729 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -14,6 +14,8 @@ - electron-store@8 (CJS) used for app settings; use lazy init pattern `getStore()` like `getDb()` to avoid calling before app ready - ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver` - App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access +- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value +- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`) --- ## 2026-02-19 - US-002 @@ -158,3 +160,24 @@ - `useCallback` ref pattern (`ref={callbackRef}`) is used for auto-focus + select on mount without useEffect - The two-stage delete flow (try simple delete first → if error, show cascade option) maps well to the backend's `clients.delete` (guards) + `clients.deleteWithCascade` (force) pattern --- + +## 2026-02-19 - US-010 +- What was implemented: + - Rewrote `ProjectSidebar` from a client-hierarchy tree to a project-centric sidebar grouped by client + - Projects grouped by `clientId` using Collapsible headers; projects without a client appear under "Internal / No Client" + - Search input filters projects by name in real-time (auto-expands all groups when searching) + - Show/hide archived projects via Switch toggle (queries `projects.list` with `includeArchived`) + - Context menu per project (DropdownMenu): Edit Client (Dialog + Select to assign/change/remove client), Archive/Unarchive, Delete (AlertDialog) + - Clicking a project sets `projectId` in search params → renders ProjectDetail placeholder in right pane + - Active project highlighted with `bg-sidebar-accent` + - Updated `projects.update` tRPC procedure to accept `clientId: z.string().nullable().optional()` (allows unlinking from client) + - Created placeholder `ProjectDetail` component (full implementation deferred to US-013) + - Installed shadcn/ui: dialog, select, switch +- Files changed: `src/renderer/components/projects/ProjectSidebar.tsx`, `src/renderer/routes/projects.tsx`, `src/renderer/components/projects/ProjectDetail.tsx` (new), `src/main/router/index.ts`, `src/renderer/components/ui/dialog.tsx` (new), `src/renderer/components/ui/select.tsx` (new), `src/renderer/components/ui/switch.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt` +- **Learnings for future iterations:** + - TanStack Router `validateSearch` with Zod schema is the cleanest way to pass selected-item IDs via URL search params without creating nested routes + - `Route.useNavigate()` returns a typed navigate fn; use `void navigate({ search: { ... } })` to avoid unhandled promise warnings + - For project grouping, query both `projects.list` and `clients.list` separately then join in-memory via a Map — avoids complex SQL joins for display-only data + - `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign) + - Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand +--- diff --git a/src/main/router/index.ts b/src/main/router/index.ts index 0a7dd94..2be5908 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -140,7 +140,7 @@ const projectsRouter = router({ .input(z.object({ id: z.string(), name: z.string().optional(), - clientId: z.string().optional(), + clientId: z.string().nullable().optional(), status: z.enum(['active', 'archived']).optional(), aiSummary: z.string().optional(), })) diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx new file mode 100644 index 0000000..3629d7f --- /dev/null +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -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 ( +
+ Loading project... +
+ ); + } + + if (!project) { + return ( +
+ Project not found +
+ ); + } + + return ( +
+

{project.name}

+

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

+
+ ); +} diff --git a/src/renderer/components/projects/ProjectSidebar.tsx b/src/renderer/components/projects/ProjectSidebar.tsx index f8a48df..beb66c5 100644 --- a/src/renderer/components/projects/ProjectSidebar.tsx +++ b/src/renderer/components/projects/ProjectSidebar.tsx @@ -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(); - 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>(new Set()); - const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null); - const [deleteDialog, setDeleteDialog] = useState(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(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(); + 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(); + 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 ( - hasChildren && toggleExpanded(node.id)} - > -
- {/* Expand/collapse chevron */} - - - - - - - {isRenaming ? ( - setRenaming({ id: node.id, value: e.target.value })} - onBlur={handleRenameSave} - onKeyDown={handleRenameKeyDown} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {node.name} - )} - - {/* Kebab menu */} - {!isRenaming && ( - - - - - - handleRenameStart(node.id, node.name)}> - - Rename - - handleNewSubProject(node.id)}> - - New Sub-Project - - - handleDeleteClick(node.id, node.name)} - > - - Delete - - - - )} -
- - {/* Children */} - - {node.children.map((child) => renderNode(child, depth + 1))} - -
- ); - } + const totalProjects = projectList.length; return (
{/* Header */}
-

- Projects -

+

Projects

+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {/* Show archived toggle */} +
+ + +
+ {/* Project tree */}
- {tree.length === 0 ? ( + {totalProjects === 0 ? ( - No project yet + No projects yet Get started by adding your first project. @@ -326,77 +245,197 @@ export function ProjectSidebar() { + ) : sortedGroupKeys.length === 0 ? ( +
+ No projects match your search. +
) : ( - 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 ( + 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 — AlertDialog */} + {/* Delete confirmation */} { - if (!open && !deleteMutation.isPending && !cascadeDeleteMutation.isPending) { - setDeleteDialog(null); - } + if (!open && !deleteMutation.isPending) setDeleteProjectId(null); }} > - {deleteDialog?.stage === 'cascade-warn' ? ( - <> - -
- -
- Cannot delete safely - - {deleteDialog.errorMessage} - -
-
- - Force-delete “{deleteDialog?.name}” along with all its sub-projects? - This cannot be undone. - -
- - - Cancel - - - {cascadeDeleteMutation.isPending ? 'Deleting\u2026' : 'Force Delete All'} - - - - ) : ( - <> - - - Delete “{deleteDialog?.name}”? - - - This action cannot be undone. - - - - - Cancel - - - {deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'} - - - - )} + + + 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'} + +
+ + {/* Edit client dialog */} + { + if (!open) setEditDialog(null); + }} + > + + + Edit Project Client + + Assign “{editDialog?.name}” to a client or leave as internal. + + + + + + + + +
); } diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx new file mode 100644 index 0000000..9dbeaa0 --- /dev/null +++ b/src/renderer/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/renderer/components/ui/select.tsx b/src/renderer/components/ui/select.tsx new file mode 100644 index 0000000..6e637f7 --- /dev/null +++ b/src/renderer/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/renderer/components/ui/switch.tsx b/src/renderer/components/ui/switch.tsx new file mode 100644 index 0000000..455c23b --- /dev/null +++ b/src/renderer/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/renderer/routes/projects.tsx b/src/renderer/routes/projects.tsx index 5a23934..cc73e4d 100644 --- a/src/renderer/routes/projects.tsx +++ b/src/renderer/routes/projects.tsx @@ -1,16 +1,39 @@ import { createFileRoute } from '@tanstack/react-router'; +import { z } from 'zod'; import { ProjectSidebar } from '@/components/projects/ProjectSidebar'; +import { ProjectDetail } from '@/components/projects/ProjectDetail'; + +const searchSchema = z.object({ + projectId: z.string().optional(), +}); export const Route = createFileRoute('/projects')({ + validateSearch: searchSchema, component: ProjectsPage, }); function ProjectsPage() { + const { projectId } = Route.useSearch(); + const navigate = Route.useNavigate(); + + function handleSelectProject(id: string) { + void navigate({ search: { projectId: id } }); + } + return (
- -
- Select a project to view details + +
+ {projectId ? ( + + ) : ( +
+ Select a project to view details +
+ )}
);