Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { format } from 'date-fns';
|
|
|
|
|
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
|
|
|
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
|
import { TagBadge } from '@/components/shared/tag-badge';
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
import { getCountryName } from '@/lib/i18n/countries';
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
export interface ClientRow {
|
|
|
|
|
id: string;
|
|
|
|
|
fullName: string;
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
nationalityIso: string | null;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
source: string | null;
|
|
|
|
|
archivedAt: string | null;
|
|
|
|
|
createdAt: string;
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
yachtCount?: number;
|
|
|
|
|
companyCount?: number;
|
feat(client): interests tab + pipeline summary panel + list-row counts
Promotes interests from a stub tab to a first-class surface on the client
detail page, and surfaces pipeline activity in two more places:
UI:
- New ClientInterestsTab (475 lines) — table of every active interest
for the client with stage-stepper visualization, lead category, source,
last-activity timestamp, and a drawer-on-tap row preview.
- New OverviewTab pipeline-summary panel above the existing 2-column
layout, rendering ClientPipelineSummary (already on this branch) in
its panel variant. Reps see the live pipeline at a glance without
leaving Overview.
- Removes "Preferred Language" inline field from the Overview tab and
the create form — unused, and the field added noise without driving
any downstream behavior.
- Tab order: Overview / Interests / Yachts / Companies / ... (Interests
moves up from the back of the list, where it was a stub anyway).
Data:
- listClients now returns interestCount + latestInterest{stage, mooring}
per row, joined from interests + berths in two parallel queries.
ClientRow type updated to surface them; Client list views can now
render "3 interests · last on D-02 (EOI Signed)" without a per-row
fetch.
- Contact rows in client detail now expose valueE164 + valueCountry to
the UI (already returned by the API; just wasn't typed through the
detail-page contract).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:37 +02:00
|
|
|
interestCount?: number;
|
|
|
|
|
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
|
|
|
|
tags?: Array<{ id: string; name: string; color: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
|
|
|
website: 'Website',
|
|
|
|
|
manual: 'Manual',
|
|
|
|
|
referral: 'Referral',
|
|
|
|
|
broker: 'Broker',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface GetColumnsOptions {
|
|
|
|
|
portSlug: string;
|
|
|
|
|
onEdit: (client: ClientRow) => void;
|
|
|
|
|
onArchive: (client: ClientRow) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getClientColumns({
|
|
|
|
|
portSlug,
|
|
|
|
|
onEdit,
|
|
|
|
|
onArchive,
|
|
|
|
|
}: GetColumnsOptions): ColumnDef<ClientRow, unknown>[] {
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
id: 'fullName',
|
|
|
|
|
accessorKey: 'fullName',
|
|
|
|
|
header: 'Name',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<Link
|
|
|
|
|
href={`/${portSlug}/clients/${row.original.id}`}
|
|
|
|
|
className="font-medium text-primary hover:underline"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
{row.original.fullName}
|
|
|
|
|
</Link>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'primaryContact',
|
|
|
|
|
header: 'Primary Contact',
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
|
|
|
|
if (!primary) return <span className="text-muted-foreground">—</span>;
|
|
|
|
|
return (
|
|
|
|
|
<span className="text-sm">
|
|
|
|
|
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
|
|
|
|
{primary.value}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-04-24 14:36:34 +02:00
|
|
|
{
|
|
|
|
|
id: 'nationality',
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
accessorKey: 'nationalityIso',
|
2026-04-24 14:36:34 +02:00
|
|
|
header: 'Nationality',
|
chore(i18n): drop legacy free-text country/nationality columns
Test-data only — no production migration needed (per earlier decision).
Schema is now ISO-only; readers convert ISO codes to localized names where
human-readable output is required (EOI documents, invoices, portal).
Migration 0016 drops:
- clients.nationality
- companies.incorporation_country
- client_addresses.{state_province, country}
- company_addresses.{state_province, country}
Code paths that previously read free-text values now read the ISO column
and pass through `getCountryName()` / `getSubdivisionName()` for rendering.
Document templates ({{client.nationality}}), portal client view, EOI/
reservation-agreement contexts, and invoice billing addresses all updated.
Public yacht-interest endpoint (/api/public/interests) drops the legacy
fields from its insert path and writes ISO codes only. The Zod validators
no longer accept the legacy fields — older website builds posting raw
'incorporationCountry' / 'country' / 'stateProvince' will get 400s.
Server-side phone normalization is unchanged.
Seed data updated to use ISO codes (GB/FR/ES/GR/SE/IT/GH/MC/PA), spread
across continents to keep test fixtures realistic.
Test assertions updated to match the new render shape (e.g.
'United States' not 'US', 'California' not 'CA').
Vitest: 741 -> 741 (unchanged count; assertions updated, no new tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:57 +02:00
|
|
|
cell: ({ getValue }) => {
|
|
|
|
|
const iso = getValue() as string | null;
|
|
|
|
|
return (
|
|
|
|
|
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '—'}</span>
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-24 14:36:34 +02:00
|
|
|
},
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
{
|
|
|
|
|
id: 'source',
|
|
|
|
|
accessorKey: 'source',
|
|
|
|
|
header: 'Source',
|
|
|
|
|
cell: ({ getValue }) => {
|
|
|
|
|
const source = getValue() as string | null;
|
|
|
|
|
if (!source) return <span className="text-muted-foreground">—</span>;
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant="outline" className="capitalize text-xs">
|
|
|
|
|
{SOURCE_LABELS[source] ?? source}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
feat(platform): residential module + admin UI + reliability fixes
Residential platform
- New schema: residentialClients, residentialInterests (separate from
marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint
Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)
Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
handlers.ts files (Next.js 15 route.ts only allows specific exports)
Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
(apiFetch already JSON.stringifies its body; passing a stringified
body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md
Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
|
|
|
{
|
|
|
|
|
id: 'yachtCount',
|
|
|
|
|
header: 'Yachts',
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
const c = row.original.yachtCount ?? 0;
|
|
|
|
|
return c === 0 ? (
|
|
|
|
|
<span className="text-muted-foreground">—</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{c}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'companyCount',
|
|
|
|
|
header: 'Companies',
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
const c = row.original.companyCount ?? 0;
|
|
|
|
|
return c === 0 ? (
|
|
|
|
|
<span className="text-muted-foreground">—</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
{c}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
{
|
|
|
|
|
id: 'tags',
|
|
|
|
|
header: 'Tags',
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
cell: ({ row }) => {
|
|
|
|
|
const clientTags = row.original.tags ?? [];
|
|
|
|
|
if (clientTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{clientTags.slice(0, 3).map((tag) => (
|
|
|
|
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
|
|
|
|
))}
|
|
|
|
|
{clientTags.length > 3 && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">
|
|
|
|
|
+{clientTags.length - 3}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'createdAt',
|
|
|
|
|
accessorKey: 'createdAt',
|
|
|
|
|
header: 'Created',
|
|
|
|
|
cell: ({ getValue }) => (
|
|
|
|
|
<span className="text-muted-foreground text-sm">
|
|
|
|
|
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'actions',
|
|
|
|
|
header: '',
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
size: 48,
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-7 w-7"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
|
|
|
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
|
|
|
|
Edit
|
|
|
|
|
</DropdownMenuItem>
|
2026-04-24 14:36:34 +02:00
|
|
|
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
<Archive className="mr-2 h-3.5 w-3.5" />
|
|
|
|
|
Archive
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|