feat: add context menu component and update dropdown, select styles

- Implemented a new ContextMenu component with various subcomponents for better UI interaction.
- Updated DropdownMenu and Select components to include hover effects for improved user experience.
- Enhanced global styles with new CSS variables for better theming and consistency across components.
This commit is contained in:
Roberto Musso
2026-02-20 12:17:50 +01:00
parent 99140c2c48
commit 8c1fb54afd
9 changed files with 1037 additions and 175 deletions

View File

@@ -5,7 +5,7 @@ import {
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Pencil,
Edit2,
Archive,
ArchiveRestore,
@@ -18,12 +18,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
AlertDialog,
AlertDialogAction,
@@ -77,8 +77,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState<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);
@@ -141,6 +153,25 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
},
});
const updateClientMutation = trpc.clients.update.useMutation({
onSuccess: () => {
setRenameClient(null);
void utils.clients.list.invalidate();
},
});
const archiveByClientMutation = trpc.projects.archiveByClient.useMutation({
onSuccess: () => { void utils.projects.list.invalidate(); },
});
const deleteClientMutation = trpc.clients.deleteWithCascade.useMutation({
onSuccess: () => {
setDeleteClient(null);
void utils.clients.list.invalidate();
void utils.projects.list.invalidate();
},
});
// Build a client lookup map
const clientMap = useMemo(() => {
const m = new Map<string, string>();
@@ -156,6 +187,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
);
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);
@@ -163,7 +200,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
else groups.set(key, [p]);
}
return groups;
}, [projectList, searchQuery]);
}, [projectList, clientList, searchQuery]);
// Count projects for a top-level client including its sub-clients
const totalProjectCount = useMemo(() => {
const counts = new Map<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(() => {
@@ -242,22 +292,76 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
function handleEditOpen(project: { id: string; name: string; clientId: string | null }) {
setEditDialog(project);
setEditClientValue(project.clientId ?? NO_CLIENT_KEY);
setEditCreatingClient(false);
setEditNewClientName('');
setEditCreatingSubClient(false);
setEditNewSubClientName('');
if (!project.clientId) {
setEditClientValue(NO_CLIENT_KEY);
setEditSubClientValue(NO_CLIENT_KEY);
} else {
// Check if clientId is a sub-client
const client = clientList.find((c) => c.id === project.clientId);
if (client?.parentId) {
// It's a sub-client — set parent as client, this as sub-client
setEditClientValue(client.parentId);
setEditSubClientValue(client.id);
} else {
setEditClientValue(project.clientId);
setEditSubClientValue(NO_CLIENT_KEY);
}
}
}
function handleEditSave() {
async function handleEditSave() {
if (!editDialog) return;
const newClientId = editClientValue === NO_CLIENT_KEY ? null : editClientValue;
let resolvedClientId: string | null;
if (editCreatingClient && editNewClientName.trim()) {
// Create a new client
const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
let parentId = result.id;
if (editCreatingSubClient && editNewSubClientName.trim()) {
// Also create a sub-client under the new client
const subResult = await createClientMutation.mutateAsync({
name: editNewSubClientName.trim(),
parentId,
});
resolvedClientId = subResult.id;
} else {
resolvedClientId = parentId;
}
} else if (editCreatingSubClient && editNewSubClientName.trim() && editClientValue !== NO_CLIENT_KEY) {
// Create a new sub-client under the selected parent
const result = await createClientMutation.mutateAsync({
name: editNewSubClientName.trim(),
parentId: editClientValue,
});
resolvedClientId = result.id;
} else if (editSubClientValue !== NO_CLIENT_KEY) {
resolvedClientId = editSubClientValue;
} else if (editClientValue !== NO_CLIENT_KEY) {
resolvedClientId = editClientValue;
} else {
resolvedClientId = null;
}
updateMutation.mutate(
{ id: editDialog.id, clientId: newClientId },
{ id: editDialog.id, clientId: resolvedClientId },
{ onSuccess: () => setEditDialog(null) },
);
}
// Sort groups: client groups first (alphabetically), then "Internal / No Client" last
// Sort groups: only top-level clients + NO_CLIENT_KEY, sub-clients rendered nested
const sortedGroupKeys = useMemo(() => {
const keys = [...grouped.keys()];
// Only include top-level clients and the NO_CLIENT_KEY group
const topLevelIds = new Set(topLevelClients.map((c) => c.id));
const keys = [...grouped.keys()].filter(
(k) => k === NO_CLIENT_KEY || topLevelIds.has(k),
);
return keys.sort((a, b) => {
if (a === NO_CLIENT_KEY) return 1;
if (b === NO_CLIENT_KEY) return -1;
@@ -265,7 +369,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const nameB = clientMap.get(b) ?? '';
return nameA.localeCompare(nameB);
});
}, [grouped, clientMap]);
}, [grouped, clientMap, topLevelClients]);
const totalProjects = projectList.length;
@@ -336,16 +440,16 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
</Button>
</EmptyContent>
</Empty>
) : sortedGroupKeys.length === 0 ? (
) : 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>
) : (
sortedGroupKeys.map((groupKey) => {
<>
{/* Client groups */}
{sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => {
const groupProjects = grouped.get(groupKey) ?? [];
const groupName = groupKey === NO_CLIENT_KEY
? 'Internal / No Client'
: clientMap.get(groupKey) ?? 'Unknown Client';
const groupName = clientMap.get(groupKey) ?? 'Unknown Client';
const isOpen = effectiveExpanded.has(groupKey);
return (
@@ -354,110 +458,415 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
open={isOpen}
onOpenChange={() => toggleExpanded(groupKey)}
>
{/* Client group header */}
<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">
{groupProjects.length}
</span>
</div>
</CollapsibleTrigger>
<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>
{groupProjects.map((project) => (
<div
key={project.id}
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>
{/* Sub-clients nested under this parent */}
{(subClientsByParent.get(groupKey) ?? []).map((subClient) => {
const subProjects = grouped.get(subClient.id) ?? [];
const subIsOpen = effectiveExpanded.has(subClient.id);
const subName = subClient.name;
{/* Context menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
'relative z-10 h-5 w-5 min-h-0 min-w-0 p-0 ml-1 text-muted-foreground hover:bg-muted shrink-0',
'opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100',
)}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[152px]">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEditOpen({
id: project.id,
name: project.name,
clientId: project.clientId,
});
}}
>
<Edit2 />
Edit Client
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleArchiveToggle(project.id, project.status);
}}
>
{project.status === 'archived' ? (
<>
<ArchiveRestore />
Unarchive
</>
) : (
<>
<Archive />
Archive
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteProjectId({ id: project.id, name: project.name });
}}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
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>
))}
</>
)}
</div>
{/* Rename project dialog */}
<Dialog
open={!!renameProject}
onOpenChange={(open) => {
if (!open) setRenameProject(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for &ldquo;{renameProject?.name}&rdquo;.
</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}
@@ -642,6 +1051,75 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
</DialogContent>
</Dialog>
{/* Delete client confirmation */}
<AlertDialog
open={!!deleteClient}
onOpenChange={(open) => {
if (!open && !deleteClientMutation.isPending) setDeleteClient(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete &ldquo;{deleteClient?.name}&rdquo;?
</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 &ldquo;{renameClient?.name}&rdquo;.
</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}
@@ -656,25 +1134,131 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
Assign &ldquo;{editDialog?.name}&rdquo; to a client or leave as internal.
</DialogDescription>
</DialogHeader>
<Select value={editClientValue} onValueChange={setEditClientValue}>
<SelectTrigger>
<SelectValue placeholder="Select a client" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
{clientList.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<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}>
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
<Button onClick={handleEditSave} disabled={updateMutation.isPending || createClientMutation.isPending}>
{updateMutation.isPending || createClientMutation.isPending ? 'Saving\u2026' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>