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 {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Users,
|
Users,
|
||||||
|
UsersRound,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Anchor,
|
Anchor,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -113,6 +114,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
items: [
|
items: [
|
||||||
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ href: `${base}/clients`, label: 'Clients', icon: Users },
|
{ 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}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
|
|||||||
Reference in New Issue
Block a user