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 ? (
+
+ ) : (
+
+
+
+
+ | Client |
+ Email |
+ |
+
+
+
+ {members.map((m) => (
+
+ |
+
+ {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 (
+
+ );
+}
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'}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
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 },