diff --git a/src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx b/src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx index bb37dde..0e65559 100644 --- a/src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/berths/[berthId]/page.tsx @@ -1,4 +1,5 @@ import { BerthDetail } from '@/components/berths/berth-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; interface BerthPageProps { params: Promise<{ portSlug: string; berthId: string }>; @@ -6,5 +7,10 @@ interface BerthPageProps { export default async function BerthPage({ params }: BerthPageProps) { const { berthId } = await params; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx b/src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx index 58539a3..6900f6a 100644 --- a/src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/clients/[clientId]/page.tsx @@ -1,4 +1,5 @@ import { ClientDetail } from '@/components/clients/client-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; @@ -12,5 +13,10 @@ export default async function ClientDetailPage({ params }: ClientDetailPageProps const session = await auth.api.getSession({ headers: await headers() }); const currentUserId = session?.user?.id; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx b/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx index 11a65fa..7dfab29 100644 --- a/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/companies/[companyId]/page.tsx @@ -1,4 +1,5 @@ import { CompanyDetail } from '@/components/companies/company-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; @@ -12,5 +13,10 @@ export default async function CompanyDetailPage({ params }: CompanyDetailPagePro const session = await auth.api.getSession({ headers: await headers() }); const currentUserId = session?.user?.id; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx index 9380821..cf83f31 100644 --- a/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/documents/[id]/page.tsx @@ -1,4 +1,5 @@ import { DocumentDetail } from '@/components/documents/document-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; interface PageProps { params: Promise<{ portSlug: string; id: string }>; @@ -6,5 +7,10 @@ interface PageProps { export default async function DocumentDetailPage({ params }: PageProps) { const { portSlug, id } = await params; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx index db02f4a..c37c0cf 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/[id]/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation'; import { ExpenseDetail } from '@/components/expenses/expense-detail'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; +import { TrackEntityView } from '@/components/search/track-entity-view'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '@/lib/api/client'; import type { ExpenseRow } from '@/components/expenses/expense-columns'; @@ -22,6 +23,7 @@ export default function ExpenseDetailPage() { return (
+ setEditOpen(true)} @@ -29,11 +31,7 @@ export default function ExpenseDetailPage() { /> {data?.data && ( - + )}
); diff --git a/src/app/(dashboard)/[portSlug]/interests/[interestId]/page.tsx b/src/app/(dashboard)/[portSlug]/interests/[interestId]/page.tsx index 526a1c3..9a6f41d 100644 --- a/src/app/(dashboard)/[portSlug]/interests/[interestId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/interests/[interestId]/page.tsx @@ -1,4 +1,5 @@ import { InterestDetail } from '@/components/interests/interest-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; @@ -12,5 +13,10 @@ export default async function InterestDetailPage({ params }: InterestDetailPageP const session = await auth.api.getSession({ headers: await headers() }); const currentUserId = session?.user?.id; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx index 2565bf1..f29cc9f 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx @@ -1,5 +1,6 @@ import { use } from 'react'; import { InvoiceDetail } from '@/components/invoices/invoice-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; interface InvoiceDetailPageProps { params: Promise<{ portSlug: string; id: string }>; @@ -9,6 +10,7 @@ export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) { const { id } = use(params); return (
+
); diff --git a/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx index b35e2ed..e1e4640 100644 --- a/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/residential/clients/[id]/page.tsx @@ -1,4 +1,5 @@ import { ResidentialClientDetail } from '@/components/residential/residential-client-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; interface Props { params: Promise<{ id: string }>; @@ -6,5 +7,10 @@ interface Props { export default async function ResidentialClientDetailPage({ params }: Props) { const { id } = await params; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx index 070ce25..ea977dd 100644 --- a/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/residential/interests/[id]/page.tsx @@ -1,4 +1,5 @@ import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; interface Props { params: Promise<{ id: string }>; @@ -6,5 +7,10 @@ interface Props { export default async function ResidentialInterestDetailPage({ params }: Props) { const { id } = await params; - return ; + return ( + <> + + + + ); } diff --git a/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx b/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx index e1ea071..102f623 100644 --- a/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx @@ -1,4 +1,5 @@ import { YachtDetail } from '@/components/yachts/yacht-detail'; +import { TrackEntityView } from '@/components/search/track-entity-view'; import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; @@ -12,5 +13,10 @@ export default async function YachtDetailPage({ params }: YachtDetailPageProps) const session = await auth.api.getSession({ headers: await headers() }); const currentUserId = session?.user?.id; - return ; + return ( + <> + + + + ); } diff --git a/src/app/api/public/interests/route.ts b/src/app/api/public/interests/route.ts index 213abe2..db9bc46 100644 --- a/src/app/api/public/interests/route.ts +++ b/src/app/api/public/interests/route.ts @@ -225,7 +225,6 @@ export async function POST(req: NextRequest) { yachtId, source: 'website', pipelineStage: 'open', - notes: data.notes, }) .returning(); diff --git a/src/app/api/v1/clients/[id]/activity/route.ts b/src/app/api/v1/clients/[id]/activity/route.ts index fe38276..61c4e8a 100644 --- a/src/app/api/v1/clients/[id]/activity/route.ts +++ b/src/app/api/v1/clients/[id]/activity/route.ts @@ -4,11 +4,14 @@ import { eq, and } from 'drizzle-orm'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; -import { loadEntityActivity } from '@/lib/services/entity-activity.service'; +import { + loadClientActivityAggregated, + loadEntityActivity, +} from '@/lib/services/entity-activity.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; export const GET = withAuth( - withPermission('clients', 'view', async (_req, ctx, params) => { + withPermission('clients', 'view', async (req, ctx, params) => { try { const id = params.id; if (!id) throw new NotFoundError('client'); @@ -18,11 +21,19 @@ export const GET = withAuth( .where(and(eq(clients.id, id), eq(clients.portId, ctx.portId))) .limit(1); if (exists.length === 0) throw new NotFoundError('client'); - const data = await loadEntityActivity({ - portId: ctx.portId, - entityType: 'client', - entityId: id, - }); + // ?aggregate=false opts out for callers that need just the + // client-level audit log (no interest events). Default is on + // since the Client overview Activity tab benefits from the + // full timeline. + const url = new URL(req.url); + const aggregate = url.searchParams.get('aggregate') !== 'false'; + const data = aggregate + ? await loadClientActivityAggregated({ portId: ctx.portId, clientId: id }) + : await loadEntityActivity({ + portId: ctx.portId, + entityType: 'client', + entityId: id, + }); return NextResponse.json({ data }); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/clients/[id]/notes/route.ts b/src/app/api/v1/clients/[id]/notes/route.ts index e183c3f..fd85a17 100644 --- a/src/app/api/v1/clients/[id]/notes/route.ts +++ b/src/app/api/v1/clients/[id]/notes/route.ts @@ -9,11 +9,18 @@ import { createNoteSchema } from '@/lib/validators/notes'; import * as notesService from '@/lib/services/notes.service'; export const GET = withAuth( - withPermission('clients', 'view', async (_req, ctx, params) => { + withPermission('clients', 'view', async (req, ctx, params) => { try { const clientId = params.id; if (!clientId) throw new NotFoundError('Client'); - const notes = await notesService.listForEntity(ctx.portId, 'clients', clientId); + // ?aggregate=true unions client-level notes with notes from + // every interest + directly-owned yacht so the Notes tab on + // the client overview shows the full timeline. + const url = new URL(req.url); + const aggregate = url.searchParams.get('aggregate') === 'true'; + const notes = aggregate + ? await notesService.listForClientAggregated(ctx.portId, clientId) + : await notesService.listForEntity(ctx.portId, 'clients', clientId); return NextResponse.json({ data: notes }); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/contact-log/[id]/route.ts b/src/app/api/v1/contact-log/[id]/route.ts new file mode 100644 index 0000000..37345b5 --- /dev/null +++ b/src/app/api/v1/contact-log/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { remove, update } from '@/lib/services/interest-contact-log.service'; +import { updateContactLogSchema } from '@/lib/validators/interest-contact-log'; + +export const PATCH = withAuth( + withPermission('interests', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, updateContactLogSchema); + const entry = await update(params.id!, ctx.portId, ctx.userId, { + occurredAt: body.occurredAt, + channel: body.channel, + direction: body.direction, + summary: body.summary, + followUpAt: body.followUpAt, + }); + return NextResponse.json({ data: entry }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('interests', 'edit', async (_req, ctx, params) => { + try { + await remove(params.id!, ctx.portId); + return NextResponse.json({ ok: true }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/contact-log/route.ts b/src/app/api/v1/interests/[id]/contact-log/route.ts new file mode 100644 index 0000000..d95ab10 --- /dev/null +++ b/src/app/api/v1/interests/[id]/contact-log/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { create, listForInterest } from '@/lib/services/interest-contact-log.service'; +import { createContactLogSchema } from '@/lib/validators/interest-contact-log'; + +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const entries = await listForInterest(params.id!, ctx.portId); + return NextResponse.json({ data: entries }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const POST = withAuth( + withPermission('interests', 'edit', async (req, ctx, params) => { + try { + const body = await parseBody(req, createContactLogSchema); + const entry = await create(ctx.userId, { + interestId: params.id!, + occurredAt: body.occurredAt, + channel: body.channel, + direction: body.direction, + summary: body.summary, + followUpAt: body.followUpAt ?? null, + }); + return NextResponse.json({ data: entry }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/[id]/eoi-context/route.ts b/src/app/api/v1/interests/[id]/eoi-context/route.ts new file mode 100644 index 0000000..2274614 --- /dev/null +++ b/src/app/api/v1/interests/[id]/eoi-context/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { buildEoiContext } from '@/lib/services/eoi-context'; + +/** + * Returns the resolved `EoiContext` — the actual data that would be + * auto-filled into the EOI document — for the given interest. Drives + * the EOI generate dialog's pre-flight preview so a sales rep can see + * (and correct) every value before sending the document for signing. + * + * No mutation; pure read of denormalized data the EOI builder already + * computes server-side. Returns 404 if the interest is missing or in + * another port (the buildEoiContext function throws NotFoundError). + */ +export const GET = withAuth( + withPermission('interests', 'view', async (_req, ctx, params) => { + try { + const context = await buildEoiContext(params.id!, ctx.portId); + return NextResponse.json({ data: context }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/interests/board/route.ts b/src/app/api/v1/interests/board/route.ts new file mode 100644 index 0000000..f0d78f3 --- /dev/null +++ b/src/app/api/v1/interests/board/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseQuery } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { listInterestsForBoard } from '@/lib/services/interests.service'; +import { boardFiltersSchema } from '@/lib/validators/interests'; + +/** + * Board (kanban) endpoint — returns every active interest for the port + * with a minimal projection (id, clientName, mooring, leadCategory, + * stage, updatedAt). No pagination: the kanban renders the whole + * pipeline at once. The service hard-caps at 5000 rows to keep payload + * size bounded; if `truncated: true` the UI surfaces a banner. + * + * Filter params are a strict subset of the list endpoint — see + * boardFiltersSchema. `pipelineStage` and `includeArchived` are + * intentionally rejected at validation time. + */ +export const GET = withAuth( + withPermission('interests', 'view', async (req, ctx) => { + try { + const filters = parseQuery(req, boardFiltersSchema); + const result = await listInterestsForBoard(ctx.portId, filters); + return NextResponse.json(result); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts index bf5406a..65cf246 100644 --- a/src/app/api/v1/me/route.ts +++ b/src/app/api/v1/me/route.ts @@ -30,12 +30,34 @@ const updateProfileSchema = z.object({ dark_mode: z.boolean().optional(), locale: z.string().optional(), timezone: z.string().optional(), + // Per-table column visibility. Keyed by entity type — entries + // with an empty `hiddenColumns` mean "all visible". The validator + // caps total entries / IDs so a malicious client can't bloat the + // 8 KB preferences blob; see merge step below for the byte cap. + tablePreferences: z + .record( + z.string().min(1).max(64), + z + .object({ + hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(), + }) + .strict(), + ) + .optional(), }) .strict() .optional(), }); export const GET = withAuth(async (_req, ctx: AuthContext) => { + // Hydrate preferences from user_profiles so the client can read its + // saved table-column visibility (and other prefs) without a second + // round-trip on app boot. + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, ctx.userId), + columns: { preferences: true, avatarFileId: true, avatarUrl: true }, + }); + return NextResponse.json({ data: { userId: ctx.userId, @@ -44,6 +66,11 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => { permissions: ctx.permissions, isSuperAdmin: ctx.isSuperAdmin, user: ctx.user, + preferences: profile?.preferences ?? {}, + profile: { + avatarFileId: profile?.avatarFileId ?? null, + avatarUrl: profile?.avatarUrl ?? null, + }, }, }); }); @@ -67,7 +94,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => { // .passthrough(); the merge prunes them so legacy bloat doesn't // accumulate forever, and a future schema regression that tries // to ship arbitrary keys still gets dropped here at write time. - const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone']); + const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']); const existing = (profile.preferences as Record) ?? {}; const merged = Object.fromEntries( Object.entries({ ...existing, ...body.preferences }).filter(([k]) => diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 3360f39..5d7a9bd 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -12,20 +12,100 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { TagBadge } from '@/components/shared/tag-badge'; +import { mooringLetterDot } from './mooring-letter-tone'; export type BerthRow = { id: string; mooringNumber: string; area: string | null; status: string; + // Dimensions (both units; row falls back when one is null) + lengthFt: string | null; + widthFt: string | null; + draftFt: string | null; lengthM: string | null; widthM: string | null; + draftM: string | null; + widthIsMinimum: boolean | null; + // Capacity + nominalBoatSize: string | null; + nominalBoatSizeM: string | null; + waterDepth: string | null; + waterDepthM: string | null; + waterDepthIsMinimum: boolean | null; + // Pontoon details (NocoDB) + sidePontoon: string | null; + mooringType: string | null; + cleatType: string | null; + cleatCapacity: string | null; + bollardType: string | null; + bollardCapacity: string | null; + access: string | null; + bowFacing: string | null; + berthApproved: boolean | null; + // Power + powerCapacity: string | null; + voltage: string | null; + // Pricing price: string | null; priceCurrency: string; + weeklyRateHighUsd: string | null; + weeklyRateLowUsd: string | null; + dailyRateHighUsd: string | null; + dailyRateLowUsd: string | null; + pricingValidUntil: string | null; + // Tenure tenureType: string; + tenureYears: number | null; + tenureStartDate: string | null; + tenureEndDate: string | null; tags: Array<{ id: string; name: string; color: string }>; }; +/** + * Toggleable columns for the berth list ColumnPicker. Heavy NocoDB + * fields default to hidden; reps can switch them on per-table-view. + * `mooringNumber` is intentionally omitted from this list — it's the + * primary identifier and always visible. + */ +export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [ + { id: 'area', label: 'Area' }, + { id: 'status', label: 'Status' }, + { id: 'sidePontoon', label: 'Side / Pontoon' }, + { id: 'dimensions', label: 'Dimensions' }, + { id: 'nominalBoatSize', label: 'Nominal boat size' }, + { id: 'waterDepth', label: 'Water depth' }, + { id: 'mooringType', label: 'Mooring type' }, + { id: 'cleat', label: 'Cleat (type · capacity)' }, + { id: 'bollard', label: 'Bollard (type · capacity)' }, + { id: 'access', label: 'Access' }, + { id: 'bowFacing', label: 'Bow facing' }, + { id: 'berthApproved', label: 'Approved' }, + { id: 'power', label: 'Power (kW · V)' }, + { id: 'price', label: 'Price' }, + { id: 'rates', label: 'Daily / Weekly rates' }, + { id: 'pricingValidUntil', label: 'Pricing valid until' }, + { id: 'tenure', label: 'Tenure' }, + { id: 'tags', label: 'Tags' }, +]; + +/** Hidden by default — power-users turn them on via the picker. */ +export const BERTH_DEFAULT_HIDDEN: string[] = [ + 'tenure', + 'sidePontoon', + 'nominalBoatSize', + 'waterDepth', + 'mooringType', + 'cleat', + 'bollard', + 'access', + 'bowFacing', + 'berthApproved', + 'power', + 'rates', + 'pricingValidUntil', +]; + function StatusBadge({ status }: { status: string }) { const variants: Record = { available: 'bg-green-100 text-green-800 border-green-200', @@ -90,46 +170,167 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) { ); } +function joinNonNull(parts: Array, sep = ' · '): string { + return parts.filter((p): p is string => Boolean(p)).join(sep); +} + +function formatMoney(amount: string | null, currency: string): string | null { + if (!amount) return null; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency || 'USD', + maximumFractionDigits: 0, + }).format(Number(amount)); +} + export const berthColumns: ColumnDef[] = [ { accessorKey: 'mooringNumber', header: 'Mooring #', - cell: ({ row }) => {row.original.mooringNumber}, + cell: ({ row }) => { + const dot = mooringLetterDot(row.original.mooringNumber); + return ( + + {dot && } + {row.original.mooringNumber} + + ); + }, }, { + id: 'area', accessorKey: 'area', header: 'Area', cell: ({ row }) => row.original.area ?? '-', }, { + id: 'status', accessorKey: 'status', header: 'Status', cell: ({ row }) => , }, + { + id: 'sidePontoon', + header: 'Side / Pontoon', + enableSorting: false, + cell: ({ row }) => row.original.sidePontoon ?? '-', + }, { id: 'dimensions', header: 'Dimensions', enableSorting: false, cell: ({ row }) => { - const { lengthM, widthM } = row.original; + const { lengthM, widthM, draftM, widthIsMinimum } = row.original; if (!lengthM && !widthM) return '-'; - return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`; + const widthLabel = widthM ? `${widthIsMinimum ? '≥' : ''}${widthM}m` : '?'; + const base = `${lengthM ?? '?'}m × ${widthLabel}`; + return draftM ? `${base} (draft ${draftM}m)` : base; }, }, { + id: 'nominalBoatSize', + header: 'Boat size', + enableSorting: false, + cell: ({ row }) => { + const m = row.original.nominalBoatSizeM; + const ft = row.original.nominalBoatSize; + if (!m && !ft) return '-'; + return m ? `${m}m` : `${ft}ft`; + }, + }, + { + id: 'waterDepth', + header: 'Water depth', + enableSorting: false, + cell: ({ row }) => { + const { waterDepthM, waterDepthIsMinimum } = row.original; + if (!waterDepthM) return '-'; + return `${waterDepthIsMinimum ? '≥' : ''}${waterDepthM}m`; + }, + }, + { + id: 'mooringType', + header: 'Mooring type', + enableSorting: false, + cell: ({ row }) => row.original.mooringType ?? '-', + }, + { + id: 'cleat', + header: 'Cleat', + enableSorting: false, + cell: ({ row }) => joinNonNull([row.original.cleatType, row.original.cleatCapacity]) || '-', + }, + { + id: 'bollard', + header: 'Bollard', + enableSorting: false, + cell: ({ row }) => joinNonNull([row.original.bollardType, row.original.bollardCapacity]) || '-', + }, + { + id: 'access', + header: 'Access', + enableSorting: false, + cell: ({ row }) => row.original.access ?? '-', + }, + { + id: 'bowFacing', + header: 'Bow facing', + enableSorting: false, + cell: ({ row }) => row.original.bowFacing ?? '-', + }, + { + id: 'berthApproved', + header: 'Approved', + enableSorting: false, + cell: ({ row }) => (row.original.berthApproved ? 'Yes' : 'No'), + }, + { + id: 'power', + header: 'Power', + enableSorting: false, + cell: ({ row }) => { + const kw = row.original.powerCapacity; + const v = row.original.voltage; + if (!kw && !v) return '-'; + return joinNonNull([kw ? `${kw}kW` : null, v ? `${v}V` : null]); + }, + }, + { + id: 'price', accessorKey: 'price', header: 'Price', + cell: ({ row }) => formatMoney(row.original.price, row.original.priceCurrency) ?? '-', + }, + { + id: 'rates', + header: 'Rates (USD)', + enableSorting: false, cell: ({ row }) => { - const { price, priceCurrency } = row.original; - if (!price) return '-'; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: priceCurrency || 'USD', - maximumFractionDigits: 0, - }).format(Number(price)); + const { dailyRateLowUsd, dailyRateHighUsd, weeklyRateLowUsd, weeklyRateHighUsd } = + row.original; + const daily = + dailyRateLowUsd && dailyRateHighUsd + ? `${dailyRateLowUsd}–${dailyRateHighUsd}/d` + : dailyRateLowUsd + ? `${dailyRateLowUsd}/d` + : null; + const weekly = + weeklyRateLowUsd && weeklyRateHighUsd + ? `${weeklyRateLowUsd}–${weeklyRateHighUsd}/wk` + : weeklyRateLowUsd + ? `${weeklyRateLowUsd}/wk` + : null; + return joinNonNull([daily, weekly]) || '-'; }, }, { + id: 'pricingValidUntil', + header: 'Pricing valid', + enableSorting: false, + cell: ({ row }) => row.original.pricingValidUntil ?? '-', + }, + { + id: 'tenure', accessorKey: 'tenureType', header: 'Tenure', cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'), diff --git a/src/components/berths/berth-list.tsx b/src/components/berths/berth-list.tsx index 4f285d6..c1d2c55 100644 --- a/src/components/berths/berth-list.tsx +++ b/src/components/berths/berth-list.tsx @@ -1,35 +1,57 @@ 'use client'; import { useRouter, useParams } from 'next/navigation'; +import { Anchor } from 'lucide-react'; import { DataTable } from '@/components/shared/data-table'; import { FilterBar } from '@/components/shared/filter-bar'; import { PageHeader } from '@/components/shared/page-header'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; +import { ColumnPicker } from '@/components/shared/column-picker'; import { EmptyState } from '@/components/shared/empty-state'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useTablePreferences } from '@/hooks/use-table-preferences'; import { BerthCard } from './berth-card'; -import { berthColumns, type BerthRow } from './berth-columns'; +import { + berthColumns, + BERTH_COLUMN_OPTIONS, + BERTH_DEFAULT_HIDDEN, + type BerthRow, +} from './berth-columns'; import { berthFilterDefinitions } from './berth-filters'; -import { Anchor } from 'lucide-react'; +import { mooringLetterTone } from './mooring-letter-tone'; export function BerthList() { const router = useRouter(); const params = useParams<{ portSlug: string }>(); - const { data, pagination, isLoading, sort, setSort, filters, setFilter, clearFilters, setPage } = - usePaginatedQuery({ - queryKey: ['berths'], - endpoint: '/api/v1/berths', - filterDefinitions: berthFilterDefinitions, - }); + const { + data, + pagination, + isLoading, + sort, + setSort, + filters, + setFilter, + clearFilters, + setPage, + setPageSize, + } = usePaginatedQuery({ + queryKey: ['berths'], + endpoint: '/api/v1/berths', + filterDefinitions: berthFilterDefinitions, + }); useRealtimeInvalidation({ 'berth:updated': [['berths']], 'berth:statusChanged': [['berths']], }); + // Persisted column visibility — same pattern as ClientList / InterestList. + const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); + const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); + return (
-
+
{ clearFilters(); Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value)); }} /> +
columns={berthColumns} + columnVisibility={columnVisibility} data={data} isLoading={isLoading} pagination={{ @@ -69,11 +91,15 @@ export function BerthList() { total: pagination.total, totalPages: pagination.totalPages, }} - onPaginationChange={(page) => setPage(page)} + onPaginationChange={(page, pageSize) => { + setPage(page); + setPageSize(pageSize); + }} sort={sort} onSortChange={setSort} getRowId={(row) => row.id} onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)} + getRowClassName={(row) => mooringLetterTone(row.mooringNumber)} cardRender={(row) => } emptyState={ 'Z') return null; + return (letter.charCodeAt(0) - 'A'.charCodeAt(0)) % BORDER_CYCLE.length; +} + +export function mooringLetterTone(mooringNumber: string | null | undefined): string | undefined { + const i = indexFor(mooringNumber); + return i === null ? undefined : BORDER_CYCLE[i]; +} + +export function mooringLetterDot(mooringNumber: string | null | undefined): string | undefined { + const i = indexFor(mooringNumber); + return i === null ? undefined : DOT_CYCLE[i]; +} diff --git a/src/components/clients/client-columns.tsx b/src/components/clients/client-columns.tsx index bcd57f8..120b5a1 100644 --- a/src/components/clients/client-columns.tsx +++ b/src/components/clients/client-columns.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { format } from 'date-fns'; -import { MoreHorizontal, Pencil, Archive } from 'lucide-react'; +import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react'; import type { ColumnDef } from '@tanstack/react-table'; import { Button } from '@/components/ui/button'; @@ -13,7 +13,11 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Badge } from '@/components/ui/badge'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { getCountryName } from '@/lib/i18n/countries'; +import { stageDotClass, stageLabel } from '@/lib/constants'; +import { cn } from '@/lib/utils'; +import type { ColumnPickerOption } from '@/components/shared/column-picker'; export interface ClientRow { id: string; @@ -24,24 +28,58 @@ export interface ClientRow { createdAt: string; primaryEmail?: string | null; primaryPhone?: string | null; + /** E.164 (digits + leading +) — used to build wa.me / tel: links. */ + primaryPhoneE164?: string | null; yachtCount?: number; companyCount?: number; interestCount?: number; latestInterest?: { stage: string; mooringNumber: string | null } | null; + /** + * Berths the client has interests in (active only) with the most-active + * interest's stage attached. Sorted server-side: open deals first, most + * progressed stage first, then mooring alphabetical. Each chip in the + * list view links to the interest, not the berth — that's the action + * sales reps want. + */ + linkedBerths?: Array<{ + id: string; + mooringNumber: string; + interestId: string; + stage: string; + outcome: string | null; + }>; tags?: Array<{ id: string; name: string; color: string }>; } -const STAGE_LABELS: Record = { - open: 'Open', - qualified: 'Qualified', - eoi_sent: 'EOI sent', - eoi_signed: 'EOI signed', - deposit: 'Deposit', - contract: 'Contract', - signed: 'Signed', - closed_won: 'Won', - closed_lost: 'Lost', -}; +/** + * Picker manifest — drives the `` dropdown next to the + * filter bar. Order here is the order shown in the menu. `alwaysVisible` + * marks columns the user can't hide (otherwise the table is unusable). + * + * "Latest stage" used to be a default-on column, but each Berths chip + * now carries its own per-interest stage (color dot + label), so the + * standalone column was duplicating the same information. Kept in the + * picker for users who want a single coarse "what's their most recent + * stage" indicator regardless of berth. + */ +export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [ + { id: 'fullName', label: 'Name', alwaysVisible: true }, + { id: 'email', label: 'Email' }, + { id: 'phone', label: 'Phone' }, + { id: 'country', label: 'Country' }, + { id: 'source', label: 'Source' }, + { id: 'berths', label: 'Berths' }, + { id: 'latestStage', label: 'Latest stage (legacy)' }, + { id: 'createdAt', label: 'Created' }, +]; + +/** + * Default-hidden columns for a fresh user. The hook merges this with + * the user's saved overrides — once they explicitly toggle a column, + * their choice wins. New columns surface for existing users by default + * (they're absent from the user's stored hidden list). + */ +export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage']; const SOURCE_LABELS: Record = { website: 'Website', @@ -83,7 +121,17 @@ export function getClientColumns({ cell: ({ row }) => { const value = row.original.primaryEmail; if (!value) return -; - return {value}; + return ( + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline" + title={`Email ${value}`} + > + + {value} + + ); }, }, { @@ -92,8 +140,38 @@ export function getClientColumns({ enableSorting: false, cell: ({ row }) => { const value = row.original.primaryPhone; + const e164 = row.original.primaryPhoneE164; if (!value) return -; - return {value}; + // wa.me requires the E.164 digits without the leading +; fall + // back to a tel: link when the contact hasn't been normalized + // yet (legacy rows imported before the i18n PhoneInput shipped). + const waDigits = e164 ? e164.replace(/[^0-9]/g, '') : null; + return ( + + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline" + title={`Call ${value}`} + > + + {value} + + {waDigits && ( + e.stopPropagation()} + className="text-emerald-600 hover:text-emerald-700" + title={`WhatsApp ${value}`} + aria-label={`WhatsApp ${value}`} + > + + + )} + + ); }, }, { @@ -122,22 +200,88 @@ export function getClientColumns({ }, }, { + id: 'berths', + header: 'Berths', + enableSorting: false, + cell: ({ row }) => { + const list = row.original.linkedBerths ?? []; + if (list.length === 0) return -; + // Show the 2 most-actionable interests inline (sorted server- + // side: open before closed, most-progressed stage first). The + // remainder collapses behind a "+N" popover so the row stays + // single-line even for clients with many historical interests. + const VISIBLE = 2; + const head = list.slice(0, VISIBLE); + const overflow = list.slice(VISIBLE); + return ( +
+ {head.map((b) => ( + + ))} + {overflow.length > 0 && ( + + + + + e.stopPropagation()} + > +
+ All linked berths +
+
+ {list.map((b) => ( + e.stopPropagation()} + className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent" + > + + {b.mooringNumber} + + {b.outcome + ? `${stageLabel(b.stage)} · ${b.outcome.replace(/_/g, ' ')}` + : stageLabel(b.stage)} + + + ))} +
+
+
+ )} +
+ ); + }, + }, + { + // Hidden by default — the per-berth stage is now carried by each + // chip in the Berths column, so this standalone column is only + // useful when a user has explicitly toggled it on. id: 'latestStage', header: 'Latest stage', enableSorting: false, cell: ({ row }) => { const latest = row.original.latestInterest; if (!latest) return -; - const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage; return ( -
- - {stageLabel} - - {latest.mooringNumber && ( - {latest.mooringNumber} - )} -
+ + {stageLabel(latest.stage)} + ); }, }, @@ -183,3 +327,50 @@ export function getClientColumns({ }, ]; } + +/** + * Single berth-with-stage chip used in the inline (top-2) chip row of + * the Berths column. Shows mooring + full stage label, with a colored + * dot for stage reinforcement (decorative — the label carries the + * meaning so color-blind / no-hover users don't lose anything). + * + * Click target is the *interest*, not the berth — the user almost + * always wants to act on the deal, not look at the berth's static + * specs. Outcome-set rows (won/lost/cancelled) get a muted dot so they + * read as historical context rather than active work. + */ +function BerthInterestChip({ + berth, + portSlug, +}: { + berth: NonNullable[number]; + portSlug: string; +}) { + const isClosed = berth.outcome !== null; + const label = isClosed + ? `${stageLabel(berth.stage)} · ${berth.outcome!.replace(/_/g, ' ')}` + : stageLabel(berth.stage); + return ( + e.stopPropagation()} + title={`Open interest · ${berth.mooringNumber} · ${label}`} + className={cn( + 'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs transition-colors', + 'border-border bg-background hover:bg-accent', + isClosed && 'opacity-60', + )} + > + + {berth.mooringNumber} + · + {label} + + ); +} diff --git a/src/components/clients/client-detail.tsx b/src/components/clients/client-detail.tsx index 6c4b220..219e01b 100644 --- a/src/components/clients/client-detail.tsx +++ b/src/components/clients/client-detail.tsx @@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid import { ClientDetailHeader } from '@/components/clients/client-detail-header'; import { getClientTabs } from '@/components/clients/client-tabs'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint'; import { apiFetch } from '@/lib/api/client'; import type { Address } from '@/components/shared/addresses-editor'; @@ -91,6 +92,10 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) { return () => setChrome({ title: null, showBackButton: false }); }, [titleForChrome, setChrome]); + // Topbar breadcrumb hint: replaces "Clients › " with + // "Clients › Mary Smith". Hint clears on unmount. + useBreadcrumbHint(data ? { parents: [], current: data.fullName } : null); + useRealtimeInvalidation({ 'client:updated': [['clients', clientId]], 'client:archived': [['clients', clientId]], diff --git a/src/components/clients/client-filters.tsx b/src/components/clients/client-filters.tsx index d1d74b9..344fc25 100644 --- a/src/components/clients/client-filters.tsx +++ b/src/components/clients/client-filters.tsx @@ -16,11 +16,12 @@ export const clientFilterDefinitions: FilterDefinition[] = [ { label: 'Manual', value: 'manual' }, { label: 'Referral', value: 'referral' }, { label: 'Broker', value: 'broker' }, + { label: 'Other', value: 'other' }, ], }, { key: 'nationality', - label: 'Nationality', + label: 'Country', type: 'text', placeholder: 'Filter by nationality...', }, diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 07b4cfc..c4a2a2c 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -334,7 +334,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index c587f75..e3a655e 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/shared/data-table'; import { FilterBar } from '@/components/shared/filter-bar'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; +import { SaveViewDialog } from '@/components/shared/save-view-dialog'; import { PageHeader } from '@/components/shared/page-header'; import { EmptyState } from '@/components/shared/empty-state'; import { TableSkeleton } from '@/components/shared/loading-skeleton'; @@ -30,9 +31,16 @@ import { import { ClientForm } from '@/components/clients/client-form'; import { clientFilterDefinitions } from '@/components/clients/client-filters'; import { ClientCard } from '@/components/clients/client-card'; -import { getClientColumns, type ClientRow } from '@/components/clients/client-columns'; +import { + CLIENT_COLUMN_OPTIONS, + CLIENT_DEFAULT_HIDDEN, + getClientColumns, + type ClientRow, +} from '@/components/clients/client-columns'; +import { ColumnPicker } from '@/components/shared/column-picker'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useTablePreferences } from '@/hooks/use-table-preferences'; import { apiFetch } from '@/lib/api/client'; export function ClientList() { @@ -49,6 +57,7 @@ export function ClientList() { const [tagChoice, setTagChoice] = useState([]); const [bulkDeleteIds, setBulkDeleteIds] = useState([]); const [bulkArchiveIds, setBulkArchiveIds] = useState([]); + const [saveViewOpen, setSaveViewOpen] = useState(false); const { can } = usePermissions(); const canHardDelete = can('admin', 'permanently_delete_clients'); @@ -119,6 +128,13 @@ export function ClientList() { onArchive: (client) => setArchiveClient(client), }); + // Per-user column visibility, persisted into user_profiles.preferences + // via /api/v1/me. Hidden IDs are the source of truth — `actions` and + // `select` columns aren't user-toggleable so they're never in the + // hidden set. New columns surface for existing users by default. + const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN); + const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); + return (
{ clearFilters(); Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); }} /> +
+ + {isLoading ? ( ) : ( { diff --git a/src/components/clients/client-tabs.tsx b/src/components/clients/client-tabs.tsx index 719b6c2..5fa7d1c 100644 --- a/src/components/clients/client-tabs.tsx +++ b/src/components/clients/client-tabs.tsx @@ -6,6 +6,7 @@ import type { DetailTab } from '@/components/shared/detail-layout'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; +import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { NotesList } from '@/components/shared/notes-list'; import type { CountryCode } from '@/lib/i18n/countries'; @@ -35,6 +36,7 @@ const SOURCE_OPTIONS = [ { value: 'manual', label: 'Manual' }, { value: 'referral', label: 'Referral' }, { value: 'broker', label: 'Broker' }, + { value: 'other', label: 'Other' }, ]; const CONTACT_METHOD_OPTIONS = [ @@ -150,18 +152,36 @@ function OverviewTab({ - + { - await mutation.mutateAsync({ nationalityIso: iso }); + // Auto-default the timezone to the country's primary + // zone when none is set yet — saves the rep a click + // and matches what a marina actually wants for first + // contact (London for GB, NYC for US, etc.). Only + // fires when timezone is empty so we never clobber a + // value the rep deliberately picked. + const patch: { nationalityIso: string | null; timezone?: string | null } = { + nationalityIso: iso, + }; + if (iso && !client.timezone) { + const defaultTz = primaryTimezoneFor(iso as CountryCode); + if (defaultTz) patch.timezone = defaultTz; + } + await mutation.mutateAsync(patch); }} - data-testid="client-nationality-inline" + data-testid="client-country-inline" /> { await mutation.mutateAsync({ timezone: tz }); @@ -267,7 +287,14 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt id: 'notes', label: 'Notes', badge: client.noteCount, - content: , + content: ( + + ), }, { id: 'files', diff --git a/src/components/clients/gdpr-export-button.tsx b/src/components/clients/gdpr-export-button.tsx index 72a592d..973ea8d 100644 --- a/src/components/clients/gdpr-export-button.tsx +++ b/src/components/clients/gdpr-export-button.tsx @@ -110,7 +110,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) { return ( - diff --git a/src/components/clients/portal-invite-button.tsx b/src/components/clients/portal-invite-button.tsx index c765ac2..c24959e 100644 --- a/src/components/clients/portal-invite-button.tsx +++ b/src/components/clients/portal-invite-button.tsx @@ -69,6 +69,7 @@ export function PortalInviteButton({