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 */}
+
);
}
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
+
+ )}
);