From 3165ec651f01088d4ae8c34a93d36f5b5b1518c4 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 18 Jun 2026 22:49:29 +0200 Subject: [PATCH] feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- .../client-groups/[groupId]/page.tsx | 10 + .../[portSlug]/client-groups/page.tsx | 5 + src/app/api/v1/client-groups/[id]/handlers.ts | 49 +++ .../v1/client-groups/[id]/members/handlers.ts | 31 ++ .../v1/client-groups/[id]/members/route.ts | 6 + src/app/api/v1/client-groups/[id]/route.ts | 7 + src/app/api/v1/client-groups/handlers.ts | 31 ++ src/app/api/v1/client-groups/route.ts | 6 + .../client-groups/client-group-detail.tsx | 304 ++++++++++++++++++ .../client-groups/client-groups-list.tsx | 170 ++++++++++ src/components/layout/sidebar.tsx | 2 + 11 files changed, 621 insertions(+) create mode 100644 src/app/(dashboard)/[portSlug]/client-groups/[groupId]/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/client-groups/page.tsx create mode 100644 src/app/api/v1/client-groups/[id]/handlers.ts create mode 100644 src/app/api/v1/client-groups/[id]/members/handlers.ts create mode 100644 src/app/api/v1/client-groups/[id]/members/route.ts create mode 100644 src/app/api/v1/client-groups/[id]/route.ts create mode 100644 src/app/api/v1/client-groups/handlers.ts create mode 100644 src/app/api/v1/client-groups/route.ts create mode 100644 src/components/client-groups/client-group-detail.tsx create mode 100644 src/components/client-groups/client-groups-list.tsx diff --git a/src/app/(dashboard)/[portSlug]/client-groups/[groupId]/page.tsx b/src/app/(dashboard)/[portSlug]/client-groups/[groupId]/page.tsx new file mode 100644 index 00000000..39b53364 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/client-groups/[groupId]/page.tsx @@ -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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/client-groups/page.tsx b/src/app/(dashboard)/[portSlug]/client-groups/page.tsx new file mode 100644 index 00000000..6911d2db --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/client-groups/page.tsx @@ -0,0 +1,5 @@ +import { ClientGroupsList } from '@/components/client-groups/client-groups-list'; + +export default function ClientGroupsPage() { + return ; +} diff --git a/src/app/api/v1/client-groups/[id]/handlers.ts b/src/app/api/v1/client-groups/[id]/handlers.ts new file mode 100644 index 00000000..3f166001 --- /dev/null +++ b/src/app/api/v1/client-groups/[id]/handlers.ts @@ -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); + } +}; diff --git a/src/app/api/v1/client-groups/[id]/members/handlers.ts b/src/app/api/v1/client-groups/[id]/members/handlers.ts new file mode 100644 index 00000000..2451525c --- /dev/null +++ b/src/app/api/v1/client-groups/[id]/members/handlers.ts @@ -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); + } +}; diff --git a/src/app/api/v1/client-groups/[id]/members/route.ts b/src/app/api/v1/client-groups/[id]/members/route.ts new file mode 100644 index 00000000..c923de3a --- /dev/null +++ b/src/app/api/v1/client-groups/[id]/members/route.ts @@ -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)); diff --git a/src/app/api/v1/client-groups/[id]/route.ts b/src/app/api/v1/client-groups/[id]/route.ts new file mode 100644 index 00000000..dacd0878 --- /dev/null +++ b/src/app/api/v1/client-groups/[id]/route.ts @@ -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)); diff --git a/src/app/api/v1/client-groups/handlers.ts b/src/app/api/v1/client-groups/handlers.ts new file mode 100644 index 00000000..2ee47026 --- /dev/null +++ b/src/app/api/v1/client-groups/handlers.ts @@ -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); + } +}; diff --git a/src/app/api/v1/client-groups/route.ts b/src/app/api/v1/client-groups/route.ts new file mode 100644 index 00000000..29fa11bd --- /dev/null +++ b/src/app/api/v1/client-groups/route.ts @@ -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)); diff --git a/src/components/client-groups/client-group-detail.tsx b/src/components/client-groups/client-group-detail.tsx new file mode 100644 index 00000000..4d5bd6c5 --- /dev/null +++ b/src/components/client-groups/client-group-detail.tsx @@ -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 ( +
+ + + All groups + + + + + {members.length} {members.length === 1 ? 'member' : 'members'} + {emails.length < members.length ? ( + + · {members.length - emails.length} without email + + ) : null} + + } + variant="gradient" + actions={ + <> + + + + + + + + + } + /> + + {isLoading ? ( +

Loading members…

+ ) : members.length === 0 ? ( + + ) : ( +
+ + + + + + + + + {members.map((m) => ( + + + + + + ))} + +
ClientEmail +
+ + {m.fullName} + + {m.email ?? '—'} + {m.email ? ( + + ) : null} +
+
+ )} + + {manageOpen ? ( + m.clientId)} + onSaved={() => { + qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] }); + qc.invalidateQueries({ queryKey: ['client-groups'] }); + }} + /> + ) : null} +
+ ); +} + +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>(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 ( + + + + Manage members + + Tick the clients who belong in this group. {selected.size} selected. + + + setSearch(e.target.value)} + /> +
+ {isLoading ? ( +

Loading clients…

+ ) : filtered.length === 0 ? ( +

No matching clients.

+ ) : ( + filtered.map((c) => ( + + )) + )} +
+ + + + +
+
+ ); +} diff --git a/src/components/client-groups/client-groups-list.tsx b/src/components/client-groups/client-groups-list.tsx new file mode 100644 index 00000000..ebbf4cb3 --- /dev/null +++ b/src/components/client-groups/client-groups-list.tsx @@ -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 ( +
+ + + + } + /> + + {isLoading ? ( +

Loading…

+ ) : groups.length === 0 ? ( + + ) : ( +
+ {groups.map((g) => ( + +
+ +

{g.name}

+
+ {g.description ? ( +

{g.description}

+ ) : null} +

+ + {g.memberCount} {g.memberCount === 1 ? 'member' : 'members'} +

+ + ))} +
+ )} + + + + + New client group + A named mailing/segment group for this port. + +
+
+ + setName(e.target.value)} + placeholder="e.g. Newsletter subscribers" + autoFocus + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + setColor(e.target.value)} + className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background" + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 0cee1a11..37fbd12a 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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 },