feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete), /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms - Client Groups list page (grid + create dialog) and detail page (member viewer, per-row copy email, "Copy all emails" → To:-bar format, manage-members picker over /api/v1/clients) - Sidebar nav entry (UsersRound icon) tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push still deferred until client creds/account). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
|
||||
|
||||
export default async function ClientGroupDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string; groupId: string }>;
|
||||
}) {
|
||||
const { groupId } = await params;
|
||||
return <ClientGroupDetail groupId={groupId} />;
|
||||
}
|
||||
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/client-groups/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
|
||||
|
||||
export default function ClientGroupsPage() {
|
||||
return <ClientGroupsList />;
|
||||
}
|
||||
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
49
src/app/api/v1/client-groups/[id]/handlers.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import {
|
||||
archiveClientGroup,
|
||||
getClientGroupById,
|
||||
updateClientGroup,
|
||||
} from '@/lib/services/client-groups.service';
|
||||
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const group = await getClientGroupById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: group });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateClientGroupSchema);
|
||||
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
await archiveClientGroup(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
31
src/app/api/v1/client-groups/[id]/members/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
|
||||
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const members = await listGroupMembers(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: members, total: members.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
|
||||
try {
|
||||
const { clientIds } = await parseBody(req, setGroupMembersSchema);
|
||||
await setGroupMembers(params.id!, ctx.portId, clientIds, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
6
src/app/api/v1/client-groups/[id]/members/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getMembersHandler, putMembersHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
|
||||
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));
|
||||
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
7
src/app/api/v1/client-groups/[id]/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { getHandler, patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
|
||||
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
|
||||
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));
|
||||
31
src/app/api/v1/client-groups/handlers.ts
Normal file
31
src/app/api/v1/client-groups/handlers.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { type RouteHandler } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
|
||||
import { createClientGroupSchema } from '@/lib/validators/client-groups';
|
||||
|
||||
export const listHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const groups = await listClientGroups(ctx.portId);
|
||||
return NextResponse.json({ data: groups, total: groups.length });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createHandler: RouteHandler = async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createClientGroupSchema);
|
||||
const group = await createClientGroup(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: group }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
};
|
||||
6
src/app/api/v1/client-groups/route.ts
Normal file
6
src/app/api/v1/client-groups/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { listHandler, createHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
|
||||
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));
|
||||
304
src/components/client-groups/client-group-detail.tsx
Normal file
304
src/components/client-groups/client-group-detail.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface GroupMember {
|
||||
clientId: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
}
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
fullName: string;
|
||||
primaryEmail: string | null;
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string, successMsg: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMsg);
|
||||
} catch {
|
||||
toast.error('Copy failed — clipboard unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
export function ClientGroupDetail({ groupId }: { groupId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
|
||||
queryKey: ['client-group', groupId],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
|
||||
});
|
||||
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
|
||||
queryKey: ['client-group', groupId, 'members'],
|
||||
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
|
||||
});
|
||||
|
||||
const group = groupResp?.data;
|
||||
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
|
||||
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Group archived');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
router.push(`/${portSlug}/client-groups` as Route);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href={`/${portSlug}/client-groups` as Route}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
All groups
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={group?.name ?? 'Group'}
|
||||
eyebrow="Mailing group"
|
||||
kpiLine={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
{emails.length < members.length ? (
|
||||
<span className="text-amber-700">
|
||||
· {members.length - emails.length} without email
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={emails.length === 0}
|
||||
onClick={() =>
|
||||
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
|
||||
}
|
||||
>
|
||||
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Copy all emails
|
||||
</Button>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button variant="outline" onClick={() => setManageOpen(true)}>
|
||||
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Manage members
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
|
||||
archive.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
|
||||
Archive
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No members yet"
|
||||
description="Use “Manage members” to add clients to this group."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Client</th>
|
||||
<th className="px-4 py-2 font-medium">Email</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{members.map((m) => (
|
||||
<tr key={m.clientId} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${m.clientId}` as Route}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{m.fullName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-end">
|
||||
{m.email ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(m.email!, 'Email copied')}
|
||||
aria-label={`Copy ${m.email}`}
|
||||
title="Copy email"
|
||||
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manageOpen ? (
|
||||
<ManageMembersDialog
|
||||
groupId={groupId}
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
currentIds={members.map((m) => m.clientId)}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageMembersDialog({
|
||||
groupId,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentIds,
|
||||
onSaved,
|
||||
}: {
|
||||
groupId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
currentIds: string[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
|
||||
queryKey: ['clients', 'group-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const clients = data?.data ?? [];
|
||||
const filtered = clients.filter((c) =>
|
||||
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
|
||||
method: 'PUT',
|
||||
body: { clientIds: Array.from(selected) },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Members updated');
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage members</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tick the clients who belong in this group. {selected.size} selected.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder="Search clients…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading clients…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
|
||||
{c.primaryEmail ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{c.primaryEmail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Save members
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
170
src/components/client-groups/client-groups-list.tsx
Normal file
170
src/components/client-groups/client-groups-list.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Users } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface ClientGroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
export function ClientGroupsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [color, setColor] = useState('#6B7280');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
|
||||
queryKey: ['client-groups'],
|
||||
queryFn: () => apiFetch('/api/v1/client-groups'),
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/client-groups', {
|
||||
method: 'POST',
|
||||
body: { name: name.trim(), description: description.trim() || null, color },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Group created');
|
||||
qc.invalidateQueries({ queryKey: ['client-groups'] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setColor('#6B7280');
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const groups = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Client Groups"
|
||||
eyebrow="Mailing"
|
||||
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
|
||||
variant="gradient"
|
||||
actions={
|
||||
<PermissionGate resource="client_groups" action="manage">
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="me-1.5 h-4 w-4" aria-hidden />
|
||||
New group
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No groups yet"
|
||||
description="Create a group to start organising clients into mailing lists."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/${portSlug}/client-groups/${g.id}` as Route}
|
||||
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: g.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
|
||||
</div>
|
||||
{g.description ? (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
|
||||
) : null}
|
||||
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" aria-hidden />
|
||||
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New client group</DialogTitle>
|
||||
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-name">Name</Label>
|
||||
<Input
|
||||
id="cg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Newsletter subscribers"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="cg-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cg-color">Color</Label>
|
||||
<input
|
||||
id="cg-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
|
||||
Create group
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
UsersRound,
|
||||
Bookmark,
|
||||
Anchor,
|
||||
KeyRound,
|
||||
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
items: [
|
||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
||||
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
|
||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||
|
||||
Reference in New Issue
Block a user