- Added AIChatPanel component with context header, user and AI message handling. - Integrated streaming responses via IPC and error handling for chat mutations. - Enhanced user experience with input handling and auto-scrolling features. - Updated AppShell to derive AI chat context from the current route. - Introduced ScrollArea component for better scrolling behavior in various dialogs. - Added support for Tailwind typography and improved global styles. - Updated project and task dialogs to utilize ScrollArea for better UX.
1270 lines
49 KiB
TypeScript
1270 lines
49 KiB
TypeScript
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<Set<string>>(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<string>(NO_CLIENT_KEY);
|
|
const [editSubClientValue, setEditSubClientValue] = useState<string>(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<string>(NO_CLIENT_KEY);
|
|
const [newProjectSubClientId, setNewProjectSubClientId] = useState<string>(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<string, typeof clientList>();
|
|
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<string, string>();
|
|
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<string, typeof filtered>();
|
|
|
|
// 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<string, number>();
|
|
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() });
|
|
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: 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 (
|
|
<div className="flex flex-col h-full border-r border-border w-60 shrink-0">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
|
|
<h4 className="text-lg font-semibold text-foreground">Projects</h4>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleOpenNewProject}
|
|
disabled={createMutation.isPending}
|
|
aria-label="New Project"
|
|
>
|
|
<Plus />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="px-3 pb-2 shrink-0">
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search projects..."
|
|
className="h-7 text-sm pl-7"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show archived toggle */}
|
|
<div className="flex items-center justify-between px-3 pb-2 shrink-0">
|
|
<label htmlFor="show-archived" className="text-xs text-muted-foreground cursor-pointer">
|
|
Show archived
|
|
</label>
|
|
<Switch
|
|
id="show-archived"
|
|
checked={showArchived}
|
|
onCheckedChange={setShowArchived}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
|
|
{/* Project tree */}
|
|
<ScrollArea className="flex-1 py-1 px-1">
|
|
{totalProjects === 0 ? (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<Folder />
|
|
</EmptyMedia>
|
|
<EmptyTitle>No projects yet</EmptyTitle>
|
|
<EmptyDescription>
|
|
Get started by adding your first project.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
<EmptyContent>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleOpenNewProject}
|
|
disabled={createMutation.isPending}
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
Add Project
|
|
</Button>
|
|
</EmptyContent>
|
|
</Empty>
|
|
) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground px-3 py-4 text-center">
|
|
No projects match your search.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 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 (
|
|
<Collapsible
|
|
key={groupKey}
|
|
open={isOpen}
|
|
onOpenChange={() => toggleExpanded(groupKey)}
|
|
>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<CollapsibleTrigger asChild>
|
|
<div className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2">
|
|
{isOpen ? (
|
|
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
|
|
) : (
|
|
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
|
|
)}
|
|
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
|
|
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
|
|
{groupName}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
{totalProjectCount.get(groupKey) ?? groupProjects.length}
|
|
</span>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="min-w-[160px]">
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setRenameClient({ id: groupKey, name: groupName });
|
|
setRenameClientValue(groupName);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
const allArchived = groupProjects.every((p) => p.status === 'archived');
|
|
archiveByClientMutation.mutate({
|
|
clientId: groupKey,
|
|
status: allArchived ? 'active' : 'archived',
|
|
});
|
|
}}
|
|
>
|
|
{groupProjects.every((p) => p.status === 'archived') ? (
|
|
<>
|
|
<ArchiveRestore />
|
|
Unarchive All
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive />
|
|
Archive All
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteClient({ id: groupKey, name: groupName });
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
<CollapsibleContent>
|
|
{/* 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 (
|
|
<Collapsible
|
|
key={subClient.id}
|
|
open={subIsOpen}
|
|
onOpenChange={() => toggleExpanded(subClient.id)}
|
|
>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<CollapsibleTrigger asChild>
|
|
<div
|
|
className="flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors"
|
|
style={{ paddingLeft: '20px', paddingRight: '8px' }}
|
|
>
|
|
{subIsOpen ? (
|
|
<ChevronDown className="size-3 shrink-0 text-muted-foreground mr-1" />
|
|
) : (
|
|
<ChevronRight className="size-3 shrink-0 text-muted-foreground mr-1" />
|
|
)}
|
|
<Folder className="size-3 shrink-0 text-muted-foreground mr-1.5" />
|
|
<span className="flex-1 min-w-0 truncate font-medium text-foreground">
|
|
{subName}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
{subProjects.length}
|
|
</span>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="min-w-[160px]">
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setRenameClient({ id: subClient.id, name: subName });
|
|
setRenameClientValue(subName);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
const allArchived = subProjects.every((p) => p.status === 'archived');
|
|
archiveByClientMutation.mutate({
|
|
clientId: subClient.id,
|
|
status: allArchived ? 'active' : 'archived',
|
|
});
|
|
}}
|
|
>
|
|
{subProjects.every((p) => p.status === 'archived') ? (
|
|
<>
|
|
<ArchiveRestore />
|
|
Unarchive All
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive />
|
|
Archive All
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteClient({ id: subClient.id, name: subName });
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
<CollapsibleContent>
|
|
{subProjects.map((project) => (
|
|
<ContextMenu key={project.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
|
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
|
project.status === 'archived' && 'opacity-60',
|
|
)}
|
|
style={{ paddingLeft: '40px', paddingRight: '4px' }}
|
|
onClick={() => onSelectProject(project.id)}
|
|
>
|
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
|
{project.name}
|
|
</span>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="min-w-[152px]">
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setRenameProject({ id: project.id, name: project.name });
|
|
setRenameProjectValue(project.name);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleEditOpen({
|
|
id: project.id,
|
|
name: project.name,
|
|
clientId: project.clientId,
|
|
});
|
|
}}
|
|
>
|
|
<Edit2 />
|
|
Edit Client
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleArchiveToggle(project.id, project.status);
|
|
}}
|
|
>
|
|
{project.status === 'archived' ? (
|
|
<>
|
|
<ArchiveRestore />
|
|
Unarchive
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive />
|
|
Archive
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteProjectId({ id: project.id, name: project.name });
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
|
|
{/* Direct projects under this client */}
|
|
{groupProjects.map((project) => (
|
|
<ContextMenu key={project.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors',
|
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
|
project.status === 'archived' && 'opacity-60',
|
|
)}
|
|
style={{ paddingLeft: '28px', paddingRight: '4px' }}
|
|
onClick={() => onSelectProject(project.id)}
|
|
>
|
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
|
{project.name}
|
|
</span>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="min-w-[152px]">
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setRenameProject({ id: project.id, name: project.name });
|
|
setRenameProjectValue(project.name);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleEditOpen({
|
|
id: project.id,
|
|
name: project.name,
|
|
clientId: project.clientId,
|
|
});
|
|
}}
|
|
>
|
|
<Edit2 />
|
|
Edit Client
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleArchiveToggle(project.id, project.status);
|
|
}}
|
|
>
|
|
{project.status === 'archived' ? (
|
|
<>
|
|
<ArchiveRestore />
|
|
Unarchive
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive />
|
|
Archive
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteProjectId({ id: project.id, name: project.name });
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
|
|
{/* Unassigned projects (no client) — rendered flat at root level */}
|
|
{(grouped.get(NO_CLIENT_KEY) ?? []).map((project) => (
|
|
<ContextMenu key={project.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
'group flex items-center h-7 rounded-md text-sm cursor-pointer hover:bg-accent transition-colors px-2',
|
|
selectedProjectId === project.id && 'bg-sidebar-accent',
|
|
project.status === 'archived' && 'opacity-60',
|
|
)}
|
|
onClick={() => onSelectProject(project.id)}
|
|
>
|
|
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
|
<span className="flex-1 min-w-0 truncate text-foreground">
|
|
{project.name}
|
|
</span>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="min-w-[152px]">
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setRenameProject({ id: project.id, name: project.name });
|
|
setRenameProjectValue(project.name);
|
|
}}
|
|
>
|
|
<Pencil />
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleEditOpen({
|
|
id: project.id,
|
|
name: project.name,
|
|
clientId: project.clientId,
|
|
});
|
|
}}
|
|
>
|
|
<Edit2 />
|
|
Edit Client
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
handleArchiveToggle(project.id, project.status);
|
|
}}
|
|
>
|
|
{project.status === 'archived' ? (
|
|
<>
|
|
<ArchiveRestore />
|
|
Unarchive
|
|
</>
|
|
) : (
|
|
<>
|
|
<Archive />
|
|
Archive
|
|
</>
|
|
)}
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteProjectId({ id: project.id, name: project.name });
|
|
}}
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
</>
|
|
)}
|
|
</ScrollArea>
|
|
|
|
{/* Rename project dialog */}
|
|
<Dialog
|
|
open={!!renameProject}
|
|
onOpenChange={(open) => {
|
|
if (!open) setRenameProject(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Rename Project</DialogTitle>
|
|
<DialogDescription>
|
|
Enter a new name for “{renameProject?.name}”.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
value={renameProjectValue}
|
|
onChange={(e) => setRenameProjectValue(e.target.value)}
|
|
placeholder="Project name"
|
|
autoFocus
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRenameProject(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (renameProject && renameProjectValue.trim()) {
|
|
updateMutation.mutate(
|
|
{ id: renameProject.id, name: renameProjectValue.trim() },
|
|
{ onSuccess: () => setRenameProject(null) },
|
|
);
|
|
}
|
|
}}
|
|
disabled={!renameProjectValue.trim() || updateMutation.isPending}
|
|
>
|
|
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete confirmation */}
|
|
<AlertDialog
|
|
open={!!deleteProjectId}
|
|
onOpenChange={(open) => {
|
|
if (!open && !deleteMutation.isPending) setDeleteProjectId(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Delete “{deleteProjectId?.name}”?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will delete the project. Tasks assigned to this project will become unassigned. This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={() => {
|
|
if (deleteProjectId) deleteMutation.mutate({ id: deleteProjectId.id });
|
|
}}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* New Project dialog */}
|
|
<Dialog open={newProjectOpen} onOpenChange={setNewProjectOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>New Project</DialogTitle>
|
|
<DialogDescription>
|
|
Give your project a name and optionally assign it to a client.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-4 py-2">
|
|
{/* Project name */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="new-project-name" className="text-sm font-medium">Project Name</label>
|
|
<Input
|
|
id="new-project-name"
|
|
placeholder="e.g. Website Redesign"
|
|
value={newProjectName}
|
|
onChange={(e) => setNewProjectName(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
{/* Client selection */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-sm font-medium">Client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
|
{creatingClient ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="New client name"
|
|
value={newClientName}
|
|
onChange={(e) => setNewClientName(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setCreatingClient(false);
|
|
setNewClientName('');
|
|
setCreatingSubClient(false);
|
|
setNewSubClientName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={newProjectClientId}
|
|
onValueChange={(v) => {
|
|
setNewProjectClientId(v);
|
|
setNewProjectSubClientId(NO_CLIENT_KEY);
|
|
setCreatingSubClient(false);
|
|
setNewSubClientName('');
|
|
}}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a client" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_CLIENT_KEY}>None (Internal)</SelectItem>
|
|
{topLevelClients.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCreatingClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Sub-client selection — only when a client is selected or being created */}
|
|
{(newProjectClientId !== NO_CLIENT_KEY || (creatingClient && newClientName.trim())) && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-sm font-medium">Sub-client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
|
{creatingSubClient ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="New sub-client name"
|
|
value={newSubClientName}
|
|
onChange={(e) => setNewSubClientName(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setCreatingSubClient(false);
|
|
setNewSubClientName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : creatingClient ? (
|
|
/* When creating a new client there are no existing sub-clients to pick */
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={() => setCreatingSubClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New Sub-client
|
|
</Button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={newProjectSubClientId}
|
|
onValueChange={setNewProjectSubClientId}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a sub-client" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_CLIENT_KEY}>None</SelectItem>
|
|
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
|
|
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCreatingSubClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setNewProjectOpen(false)}>Cancel</Button>
|
|
<Button
|
|
onClick={handleCreateProject}
|
|
disabled={!newProjectName.trim() || createMutation.isPending || createClientMutation.isPending}
|
|
>
|
|
{createMutation.isPending || createClientMutation.isPending ? 'Creating…' : 'Create Project'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete client confirmation */}
|
|
<AlertDialog
|
|
open={!!deleteClient}
|
|
onOpenChange={(open) => {
|
|
if (!open && !deleteClientMutation.isPending) setDeleteClient(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
Delete “{deleteClient?.name}”?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will delete the client and all its projects. Tasks assigned to those projects will become unassigned. This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleteClientMutation.isPending}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={() => {
|
|
if (deleteClient) deleteClientMutation.mutate({ id: deleteClient.id });
|
|
}}
|
|
disabled={deleteClientMutation.isPending}
|
|
>
|
|
{deleteClientMutation.isPending ? 'Deleting\u2026' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Rename client dialog */}
|
|
<Dialog
|
|
open={!!renameClient}
|
|
onOpenChange={(open) => {
|
|
if (!open) setRenameClient(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Rename Client</DialogTitle>
|
|
<DialogDescription>
|
|
Enter a new name for “{renameClient?.name}”.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Input
|
|
value={renameClientValue}
|
|
onChange={(e) => setRenameClientValue(e.target.value)}
|
|
placeholder="Client name"
|
|
autoFocus
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRenameClient(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (renameClient && renameClientValue.trim()) {
|
|
updateClientMutation.mutate({ id: renameClient.id, name: renameClientValue.trim() });
|
|
}
|
|
}}
|
|
disabled={!renameClientValue.trim() || updateClientMutation.isPending}
|
|
>
|
|
{updateClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit client dialog */}
|
|
<Dialog
|
|
open={!!editDialog}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditDialog(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Project Client</DialogTitle>
|
|
<DialogDescription>
|
|
Assign “{editDialog?.name}” to a client or leave as internal.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-4 py-2">
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-sm font-medium">Client</label>
|
|
{editCreatingClient ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="New client name"
|
|
value={editNewClientName}
|
|
onChange={(e) => setEditNewClientName(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setEditCreatingClient(false);
|
|
setEditNewClientName('');
|
|
setEditCreatingSubClient(false);
|
|
setEditNewSubClientName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={editClientValue}
|
|
onValueChange={(v) => {
|
|
setEditClientValue(v);
|
|
setEditSubClientValue(NO_CLIENT_KEY);
|
|
setEditCreatingSubClient(false);
|
|
setEditNewSubClientName('');
|
|
}}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a client" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
|
|
{topLevelClients.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditCreatingClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(editClientValue !== NO_CLIENT_KEY || (editCreatingClient && editNewClientName.trim())) && (
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-sm font-medium">Sub-client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
|
{editCreatingSubClient ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="New sub-client name"
|
|
value={editNewSubClientName}
|
|
onChange={(e) => setEditNewSubClientName(e.target.value)}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setEditCreatingSubClient(false);
|
|
setEditNewSubClientName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : editCreatingClient ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-fit"
|
|
onClick={() => setEditCreatingSubClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New Sub-client
|
|
</Button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={editSubClientValue}
|
|
onValueChange={setEditSubClientValue}
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a sub-client" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_CLIENT_KEY}>None</SelectItem>
|
|
{(subClientsByParent.get(editClientValue) ?? []).map((sc) => (
|
|
<SelectItem key={sc.id} value={sc.id}>
|
|
{sc.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditCreatingSubClient(true)}
|
|
>
|
|
<Plus className="size-3.5 mr-1" />New
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEditDialog(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleEditSave} disabled={updateMutation.isPending || createClientMutation.isPending}>
|
|
{updateMutation.isPending || createClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|