feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul

Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -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 <BerthDetail berthId={berthId} />;
return (
<>
<TrackEntityView type="berth" id={berthId} />
<BerthDetail berthId={berthId} />
</>
);
}

View File

@@ -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 <ClientDetail clientId={clientId} currentUserId={currentUserId} />;
return (
<>
<TrackEntityView type="client" id={clientId} />
<ClientDetail clientId={clientId} currentUserId={currentUserId} />
</>
);
}

View File

@@ -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 <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
return (
<>
<TrackEntityView type="company" id={companyId} />
<CompanyDetail companyId={companyId} currentUserId={currentUserId} />
</>
);
}

View File

@@ -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 <DocumentDetail documentId={id} portSlug={portSlug} />;
return (
<>
<TrackEntityView type="document" id={id} />
<DocumentDetail documentId={id} portSlug={portSlug} />
</>
);
}

View File

@@ -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 (
<div className="max-w-3xl mx-auto">
<TrackEntityView type="expense" id={params.id} />
<ExpenseDetail
expenseId={params.id}
onEdit={() => setEditOpen(true)}
@@ -29,11 +31,7 @@ export default function ExpenseDetailPage() {
/>
{data?.data && (
<ExpenseFormDialog
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
<ExpenseFormDialog open={editOpen} onOpenChange={setEditOpen} expense={data.data} />
)}
</div>
);

View File

@@ -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 <InterestDetail interestId={interestId} currentUserId={currentUserId} />;
return (
<>
<TrackEntityView type="interest" id={interestId} />
<InterestDetail interestId={interestId} currentUserId={currentUserId} />
</>
);
}

View File

@@ -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 (
<div className="max-w-4xl mx-auto space-y-6">
<TrackEntityView type="invoice" id={id} />
<InvoiceDetail invoiceId={id} />
</div>
);

View File

@@ -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 <ResidentialClientDetail clientId={id} />;
return (
<>
<TrackEntityView type="residential-client" id={id} />
<ResidentialClientDetail clientId={id} />
</>
);
}

View File

@@ -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 <ResidentialInterestDetail interestId={id} />;
return (
<>
<TrackEntityView type="residential-interest" id={id} />
<ResidentialInterestDetail interestId={id} />
</>
);
}

View File

@@ -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 <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
return (
<>
<TrackEntityView type="yacht" id={yachtId} />
<YachtDetail yachtId={yachtId} currentUserId={currentUserId} />
</>
);
}

View File

@@ -225,7 +225,6 @@ export async function POST(req: NextRequest) {
yachtId,
source: 'website',
pipelineStage: 'open',
notes: data.notes,
})
.returning();

View File

@@ -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,7 +21,15 @@ 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({
// ?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,

View File

@@ -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);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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);
}
}),
);

View File

@@ -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<string, unknown>) ?? {};
const merged = Object.fromEntries(
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>

View File

@@ -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<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200',
@@ -90,46 +170,167 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
);
}
function joinNonNull(parts: Array<string | null | undefined>, 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<BerthRow, unknown>[] = [
{
accessorKey: 'mooringNumber',
header: 'Mooring #',
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
cell: ({ row }) => {
const dot = mooringLetterDot(row.original.mooringNumber);
return (
<span className="inline-flex items-center gap-2 font-medium">
{dot && <span className={`inline-block size-2 rounded-full ${dot}`} aria-hidden />}
{row.original.mooringNumber}
</span>
);
},
},
{
id: 'area',
accessorKey: 'area',
header: 'Area',
cell: ({ row }) => row.original.area ?? '-',
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
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'),

View File

@@ -1,25 +1,43 @@
'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<BerthRow>({
const {
data,
pagination,
isLoading,
sort,
setSort,
filters,
setFilter,
clearFilters,
setPage,
setPageSize,
} = usePaginatedQuery<BerthRow>({
queryKey: ['berths'],
endpoint: '/api/v1/berths',
filterDefinitions: berthFilterDefinitions,
@@ -30,6 +48,10 @@ export function BerthList() {
'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 (
<div className="space-y-6">
<PageHeader
@@ -46,21 +68,21 @@ export function BerthList() {
onChange={setFilter}
onClear={clearFilters}
/>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<SavedViewsDropdown
entityType="berths"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
}}
/>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
</div>
<DataTable<BerthRow>
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) => <BerthCard berth={row.original} />}
emptyState={
<EmptyState

View File

@@ -0,0 +1,50 @@
/**
* Maps a berth's mooring-letter prefix (A, B, C…) to a subtle visual
* accent. Pontoons cluster physically — A row is one dock, B another
* — so the berth grid reads at a glance when each pontoon's rows
* share a colour cue. Earlier iteration tinted the entire row
* background; that proved visually noisy. This version keeps rows
* white and surfaces the colour as a coloured left border, plus a
* matching dot the column factory uses inside the Mooring # cell.
*
* Cycle wraps at the 8th letter; ports with more pontoons get
* repeats (fine in practice — they don't sit adjacent on the page).
*/
const BORDER_CYCLE = [
'border-l-4 border-l-rose-400',
'border-l-4 border-l-amber-400',
'border-l-4 border-l-emerald-400',
'border-l-4 border-l-sky-400',
'border-l-4 border-l-violet-400',
'border-l-4 border-l-orange-400',
'border-l-4 border-l-teal-400',
'border-l-4 border-l-fuchsia-400',
] as const;
const DOT_CYCLE = [
'bg-rose-400',
'bg-amber-400',
'bg-emerald-400',
'bg-sky-400',
'bg-violet-400',
'bg-orange-400',
'bg-teal-400',
'bg-fuchsia-400',
] as const;
function indexFor(mooringNumber: string | null | undefined): number | null {
if (!mooringNumber) return null;
const letter = mooringNumber.charAt(0).toUpperCase();
if (letter < 'A' || letter > '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];
}

View File

@@ -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<string, string> = {
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 `<ColumnPicker>` 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<string, string> = {
website: 'Website',
@@ -83,7 +121,17 @@ export function getClientColumns({
cell: ({ row }) => {
const value = row.original.primaryEmail;
if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>;
return (
<a
href={`mailto:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
title={`Email ${value}`}
>
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{value}</span>
</a>
);
},
},
{
@@ -92,8 +140,38 @@ export function getClientColumns({
enableSorting: false,
cell: ({ row }) => {
const value = row.original.primaryPhone;
const e164 = row.original.primaryPhoneE164;
if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>;
// 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 (
<span className="inline-flex items-center gap-1.5 text-sm">
<a
href={e164 ? `tel:${e164}` : `tel:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
title={`Call ${value}`}
>
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" />
<span>{value}</span>
</a>
{waDigits && (
<a
href={`https://wa.me/${waDigits}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-emerald-600 hover:text-emerald-700"
title={`WhatsApp ${value}`}
aria-label={`WhatsApp ${value}`}
>
<MessageCircle className="h-3.5 w-3.5" />
</a>
)}
</span>
);
},
},
{
@@ -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 <span className="text-muted-foreground">-</span>;
// 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 (
<div className="flex flex-wrap items-center gap-1">
{head.map((b) => (
<BerthInterestChip key={b.id} berth={b} portSlug={portSlug} />
))}
{overflow.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
+{overflow.length}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 p-1"
onClick={(e) => e.stopPropagation()}
>
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
All linked berths
</div>
<div className="space-y-0.5">
{list.map((b) => (
<Link
key={b.id}
href={`/${portSlug}/interests/${b.interestId}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent"
>
<span
aria-hidden
className={cn(
'h-2 w-2 shrink-0 rounded-full',
b.outcome ? 'bg-muted-foreground/40' : stageDotClass(b.stage),
)}
/>
<span className="font-medium text-foreground">{b.mooringNumber}</span>
<span className="text-xs text-muted-foreground">
{b.outcome
? `${stageLabel(b.stage)} · ${b.outcome.replace(/_/g, ' ')}`
: stageLabel(b.stage)}
</span>
</Link>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
},
},
{
// 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 <span className="text-muted-foreground">-</span>;
const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
return (
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="text-xs capitalize">
{stageLabel}
<Badge variant="secondary" className="text-xs">
{stageLabel(latest.stage)}
</Badge>
{latest.mooringNumber && (
<span className="text-muted-foreground">{latest.mooringNumber}</span>
)}
</div>
);
},
},
@@ -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<ClientRow['linkedBerths']>[number];
portSlug: string;
}) {
const isClosed = berth.outcome !== null;
const label = isClosed
? `${stageLabel(berth.stage)} · ${berth.outcome!.replace(/_/g, ' ')}`
: stageLabel(berth.stage);
return (
<Link
href={`/${portSlug}/interests/${berth.interestId}`}
onClick={(e) => 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',
)}
>
<span
aria-hidden
className={cn(
'h-2 w-2 shrink-0 rounded-full',
isClosed ? 'bg-muted-foreground/40' : stageDotClass(berth.stage),
)}
/>
<span className="font-medium text-foreground">{berth.mooringNumber}</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{label}</span>
</Link>
);
}

View File

@@ -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 <uuid>" with
// "Clients Mary Smith". Hint clears on unmount.
useBreadcrumbHint(data ? { parents: [], current: data.fullName } : null);
useRealtimeInvalidation({
'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]],

View File

@@ -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...',
},

View File

@@ -334,7 +334,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<Select
value={watch('source') ?? ''}
onValueChange={(v) =>
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker' | 'other')
}
>
<SelectTrigger>
@@ -345,6 +345,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -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<string[]>([]);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]);
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 (
<div className="space-y-4">
<PageHeader
@@ -144,20 +160,33 @@ export function ClientList() {
/>
<SavedViewsDropdown
entityType="clients"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
<ColumnPicker
columns={CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="clients"
currentFilters={filters}
currentSort={sort}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {

View File

@@ -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({
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<EditableRow label="Country">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
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"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
@@ -267,7 +287,14 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
id: 'notes',
label: 'Notes',
badge: client.noteCount,
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
aggregate
/>
),
},
{
id: 'files',

View File

@@ -110,7 +110,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" />
GDPR export
</Button>

View File

@@ -69,6 +69,7 @@ export function PortalInviteButton({
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => {
reset();
setOpen(true);

View File

@@ -9,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
import { getCompanyTabs } from '@/components/companies/company-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';
@@ -54,6 +55,8 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
useRealtimeInvalidation({
'company:updated': [['companies', companyId]],
'company:archived': [['companies', companyId]],

View File

@@ -127,8 +127,6 @@ export function CompanyList() {
/>
<SavedViewsDropdown
entityType="companies"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));

View File

@@ -206,7 +206,7 @@ export function getCompanyTabs({
},
{
id: 'members',
label: 'Members',
label: 'Contacts',
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
},
{

View File

@@ -2,9 +2,10 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, ChevronDown, Loader2 } from 'lucide-react';
import { AlertTriangle, Check, ChevronDown, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
@@ -47,37 +48,40 @@ export function InlineStagePicker({
}: InlineStagePickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [pendingStage, setPendingStage] = useState<string | null>(null);
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
// for legal transitions — they're stored as audit-log notes on the
// interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState('');
const { can } = usePermissions();
const canOverride = can('interests', 'override_stage');
const stage = safeStage(currentStage);
const mutation = useMutation({
mutationFn: async (next: PipelineStage) => {
// Auto-set override:true when the picked stage isn't a legal
// transition AND the user has override_stage. Without this, the
// permission was unreachable from the inline picker (audit R2-M7)
// and users had to fall back to the modal InterestStagePicker.
mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
const needsOverride = !canTransitionStage(stage, next);
const useOverride = needsOverride && canOverride;
return apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: {
pipelineStage: next,
reason: reason.trim() || (useOverride ? 'Manual override (inline)' : undefined),
reason: reason ?? (useOverride ? 'Manual override (inline)' : undefined),
override: useOverride || undefined,
},
});
},
onSuccess: (_data, next) => {
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false);
setReason('');
setOverrideTarget(null);
setOverrideReason('');
setPendingStage(null);
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
toast.success(`Stage moved to ${STAGE_LABELS[vars.next]}`);
},
onError: (err) => {
setPendingStage(null);
@@ -90,15 +94,40 @@ export function InlineStagePicker({
setOpen(false);
return;
}
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
// immediately — overrides bypass the transition guard so a reason
// is genuinely useful for the audit trail.
setOverrideTarget(next);
setOverrideReason('');
return;
}
setPendingStage(next);
mutation.mutate(next);
mutation.mutate({ next, reason: null });
}
function commitOverride() {
if (!overrideTarget) return;
setPendingStage(overrideTarget);
mutation.mutate({
next: overrideTarget,
reason: overrideReason.trim() || 'Manual override (inline)',
});
}
function cancelOverride() {
setOverrideTarget(null);
setOverrideReason('');
}
return (
<Popover
open={open}
onOpenChange={(o) => {
if (!mutation.isPending) setOpen(o);
if (mutation.isPending) return;
setOpen(o);
if (!o) cancelOverride();
}}
>
<PopoverTrigger asChild>
@@ -125,38 +154,98 @@ export function InlineStagePicker({
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 p-0"
className="w-72 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
<div className="border-b px-2 py-1">
{overrideTarget ? (
// Confirm-override view: only reached when the user picked a
// stage that isn't a legal next step. Reason is optional but
// strongly nudged for the audit log.
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-foreground">Override transition</p>
<p className="text-xs text-muted-foreground">
{STAGE_LABELS[stage]} {STAGE_LABELS[overrideTarget]} isn&apos;t a standard next
step. The change will be flagged in the audit log.
</p>
</div>
</div>
<div>
<label
htmlFor="stage-override-reason"
className="text-xs font-medium text-muted-foreground"
>
Reason (optional but recommended)
</label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)…"
rows={1}
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
id="stage-override-reason"
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="e.g. Skipping EOI, client signed contract directly"
rows={2}
className="mt-1 text-sm"
disabled={mutation.isPending}
autoFocus
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelOverride}
disabled={mutation.isPending}
className="gap-1"
>
<ChevronLeft className="size-3.5" />
Back
</Button>
<Button
type="button"
size="sm"
onClick={commitOverride}
disabled={mutation.isPending}
>
{mutation.isPending && <Loader2 className="size-3.5 animate-spin mr-1" />}
Confirm override
</Button>
</div>
</div>
) : (
// Default view: just the stage list. No upfront textarea —
// earlier UX put a "Reason (optional)…" field at the top
// which read as visually noisy for the >90% of changes that
// are normal transitions and never get a reason attached.
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
{PIPELINE_STAGES.map((s) => {
const isCurrent = s === stage;
const isPending = pendingStage === s && mutation.isPending;
const isOverride = s !== stage && !canTransitionStage(stage, s);
const blockedByPermission = isOverride && !canOverride;
return (
<li key={s}>
<button
type="button"
role="option"
aria-selected={isCurrent}
disabled={mutation.isPending}
disabled={mutation.isPending || blockedByPermission}
onClick={() => pick(s)}
title={
blockedByPermission
? `Override required (you don't have permission)`
: isOverride
? 'Non-standard transition — confirm step required'
: undefined
}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'transition-colors hover:bg-muted/60 disabled:opacity-60',
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) - turns
{/* Colored chip (mirrors the inline stage badge) turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
@@ -167,12 +256,20 @@ export function InlineStagePicker({
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" />
) : isOverride && canOverride ? (
<span
className="text-[10px] uppercase tracking-wide text-amber-600"
title="Override required"
>
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</PopoverContent>
</Popover>
);

View File

@@ -72,6 +72,29 @@ const SOURCE_LABELS: Record<string, string> = {
broker: 'Broker',
};
/**
* Toggleable columns for the InterestList ColumnPicker. `actions` and
* `clientName` are intentionally omitted from this list — actions is a
* row-control column that should never be hidden, and clientName is the
* primary entity identifier (a row with no name has no useful purpose).
*/
export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'yachtName', label: 'Yacht' },
{ id: 'berthMooringNumber', label: 'Berth' },
{ id: 'desiredSize', label: 'Desired size' },
{ id: 'pipelineStage', label: 'Stage' },
{ id: 'eoiStatus', label: 'EOI status' },
{ id: 'source', label: 'Source' },
{ id: 'dateLastContact', label: 'Last contact' },
];
/**
* Columns hidden by default for users who haven't customised their view.
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default —
* power-users can turn them back on via the column picker.
*/
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
const EOI_STATUS_LABELS: Record<string, { label: string; tone: string }> = {
waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' },
signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' },
@@ -176,7 +199,10 @@ export function getInterestColumns({
const stage = row.original.pipelineStage;
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
return (
<div className="flex flex-col gap-1 items-start">
<Link
href={`/${portSlug}/interests/${row.original.id}`}
className="flex flex-col gap-1 items-start hover:opacity-80 transition-opacity"
>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
>
@@ -196,7 +222,7 @@ export function getInterestColumns({
))}
</div>
) : null}
</div>
</Link>
);
},
},

View File

@@ -0,0 +1,435 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
CalendarDays,
Mail,
MessageCircle,
MoreVertical,
Phone,
Pencil,
Plus,
Trash2,
Users,
Video,
} from 'lucide-react';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface InterestContactLogTabProps {
interestId: string;
}
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
type Direction = 'outbound' | 'inbound';
interface ContactLogEntry {
id: string;
occurredAt: string;
channel: Channel;
direction: Direction;
summary: string;
followUpAt: string | null;
reminderId: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
};
/**
* Per-interaction contact log. Sales reps log every email / call /
* WhatsApp / meeting touch with the client here so the team has a
* structured history of "what was the last conversation about" — not
* just the bare "last contact 8d ago" timestamp on the interest.
*
* Each entry can optionally schedule a follow-up that auto-creates a
* reminder pointing back at the interest. Editing the entry's
* follow-up date keeps the linked reminder in sync; deleting the
* entry removes the reminder.
*/
export function InterestContactLogTab({ interestId }: InterestContactLogTabProps) {
const [composeOpen, setComposeOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ContactLogEntry | null>(null);
const { data: res, isLoading } = useQuery<{ data: ContactLogEntry[] }>({
queryKey: ['interests', interestId, 'contact-log'],
queryFn: () =>
apiFetch<{ data: ContactLogEntry[] }>(`/api/v1/interests/${interestId}/contact-log`),
});
const entries = res?.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-foreground">Contact log</h3>
<p className="text-xs text-muted-foreground">
Record each conversation. The most recent log entry sets the &ldquo;Last contact&rdquo;
chip on the interest header.
</p>
</div>
<Button size="sm" onClick={() => setComposeOpen(true)} className="gap-1.5">
<Plus className="size-4" />
Log contact
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
) : entries.length === 0 ? (
<EmptyState onAdd={() => setComposeOpen(true)} />
) : (
<ol className="space-y-2">
{entries.map((e) => (
<ContactLogRow key={e.id} entry={e} interestId={interestId} onEdit={setEditTarget} />
))}
</ol>
)}
<ComposeDialog interestId={interestId} open={composeOpen} onOpenChange={setComposeOpen} />
{editTarget && (
<ComposeDialog
interestId={interestId}
existing={editTarget}
open={!!editTarget}
onOpenChange={(o) => !o && setEditTarget(null)}
/>
)}
</div>
);
}
// ─── Row ─────────────────────────────────────────────────────────────────────
function ContactLogRow({
entry,
interestId,
onEdit,
}: {
entry: ContactLogEntry;
interestId: string;
onEdit: (e: ContactLogEntry) => void;
}) {
const queryClient = useQueryClient();
const channelMeta = CHANNEL_META[entry.channel];
const Icon = channelMeta.icon;
const deleteMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/contact-log/${entry.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success('Contact log entry deleted.');
},
onError: (err) => toastError(err),
});
return (
<li className="rounded-lg border bg-background p-3">
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full',
channelMeta.tone,
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{channelMeta.label}</span>
<Badge variant="outline" className="text-[10px] capitalize">
{entry.direction}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(entry.occurredAt), 'MMM d, yyyy · HH:mm')}
</span>
<span className="text-xs text-muted-foreground">
({formatDistanceToNowStrict(new Date(entry.occurredAt))} ago)
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap">{entry.summary}</p>
{entry.followUpAt && (
<p className="inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs text-amber-900">
<Bell className="size-3" />
Follow up {format(new Date(entry.followUpAt), 'MMM d, yyyy')} (reminder created)
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Row actions">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(entry)}>
<Pencil className="mr-2 size-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm('Delete this contact log entry?')) {
deleteMutation.mutate();
}
}}
>
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground">
<Phone className="size-5" />
</div>
<h3 className="mt-3 text-sm font-medium text-foreground">No contact logged yet</h3>
<p className="mt-1 text-xs text-muted-foreground">
Record every call, email, and meeting so the team has full context the next time someone
picks up the deal.
</p>
<Button size="sm" onClick={onAdd} className="mt-4 gap-1.5">
<Plus className="size-3.5" />
Log first contact
</Button>
</div>
);
}
// ─── Compose / edit dialog ───────────────────────────────────────────────────
function ComposeDialog({
interestId,
existing,
open,
onOpenChange,
}: {
interestId: string;
existing?: ContactLogEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const isEdit = !!existing;
const defaultOccurredAt = useMemo(() => {
if (existing) return localIsoString(existing.occurredAt);
return localIsoString(new Date().toISOString());
}, [existing]);
const [occurredAt, setOccurredAt] = useState<string>(defaultOccurredAt);
const [channel, setChannel] = useState<Channel>(existing?.channel ?? 'phone');
const [direction, setDirection] = useState<Direction>(existing?.direction ?? 'outbound');
const [summary, setSummary] = useState<string>(existing?.summary ?? '');
const [followUpAt, setFollowUpAt] = useState<string>(
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
);
// Re-sync local state when the existing entry changes (e.g. opening
// the edit dialog for a different row).
useMemo(() => {
if (open) {
setOccurredAt(
existing ? localIsoString(existing.occurredAt) : localIsoString(new Date().toISOString()),
);
setChannel(existing?.channel ?? 'phone');
setDirection(existing?.direction ?? 'outbound');
setSummary(existing?.summary ?? '');
setFollowUpAt(existing?.followUpAt ? localIsoString(existing.followUpAt) : '');
}
}, [open, existing]);
const mutation = useMutation({
mutationFn: async () => {
const body = {
occurredAt: new Date(occurredAt).toISOString(),
channel,
direction,
summary,
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
};
if (isEdit) {
return apiFetch(`/api/v1/contact-log/${existing!.id}`, {
method: 'PATCH',
body,
});
}
return apiFetch(`/api/v1/interests/${interestId}/contact-log`, {
method: 'POST',
body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
// Bump the parent interest cache so the "Last contact" header chip
// updates without a refresh.
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success(isEdit ? 'Contact log entry updated.' : 'Contact logged.');
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</DialogTitle>
<DialogDescription>
Record the channel, the direction, and what was discussed. Optionally schedule a
follow-up a reminder will be created automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="cl-channel">Channel</Label>
<Select value={channel} onValueChange={(v) => setChannel(v as Channel)}>
<SelectTrigger id="cl-channel">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CHANNEL_META) as Channel[]).map((c) => (
<SelectItem key={c} value={c}>
{CHANNEL_META[c].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="cl-direction">Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
<SelectTrigger id="cl-direction">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outbound (you reached out)</SelectItem>
<SelectItem value="inbound">Inbound (they reached out)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="cl-occurred">When did the conversation happen?</Label>
<Input
id="cl-occurred"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cl-summary">Summary</Label>
<Textarea
id="cl-summary"
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
rows={4}
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cl-followup">Follow up by (optional creates a reminder)</Label>
<Input
id="cl-followup"
type="datetime-local"
value={followUpAt}
onChange={(e) => setFollowUpAt(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || summary.trim().length === 0}
>
{mutation.isPending ? 'Saving…' : isEdit ? 'Save changes' : 'Log contact'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Convert an ISO string into the `YYYY-MM-DDTHH:mm` format that
* `<input type="datetime-local">` expects, in the user's local
* timezone. (Browsers don't accept the trailing `Z` in this input
* type and reject anything with a timezone offset.)
*/
function localIsoString(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

View File

@@ -0,0 +1,416 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestContractTabProps {
interestId: string;
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated Contract workspace tab. Mirrors the EOI tab pattern but
* for sales contracts. Contracts differ from EOIs in that there's no
* standard Documenso template — each contract is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** — the signed contract was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** — rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
* footer-anchored signature layout is used.)
*
* The Documents tab still shows every contract document (signed or
* drafted) as a permanent history.
*/
export function InterestContractTab({ interestId, clientId: _clientId }: InterestContractTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'contract' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=contract`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveContractCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyContractState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
/>
)}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
{/* Reuses the external-EOI upload dialog. The endpoint
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
— for contract paper-uploads we'll need the equivalent
contract endpoint (deferred to a follow-up; the dialog UI
is the pattern we'll clone). For now the flow is documented
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && (
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
)}
{/* Upload-for-Documenso-signing dialog placeholder. The real
dialog (PDF picker + recipient configurator + send button)
is part of the larger custom-doc-upload service that's a
follow-up. For now show a friendly "coming soon" card. */}
{uploadForSigningOpen && (
<ComingSoonDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
title="Send contract for signing"
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the contract externally, get it signed via paper or another tool, then upload the signed copy here."
/>
)}
</div>
);
}
// ─── Active contract hero ────────────────────────────────────────────────────
function ActiveContractCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
});
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Contract cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={() => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyContractState({
onUploadSigned,
onUploadForSigning,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No contract in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled
externally, or upload the draft PDF and send for e-signing via Documenso.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}
/**
* Placeholder for the upload-for-Documenso-signing flow until the
* full upload + recipient + field-placement service is shipped.
* Intentional dead-end so reps know the path exists rather than
* misclicking and getting confusing behaviour.
*/
function ComingSoonDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
body: string;
}) {
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
<div className="mt-4 flex justify-end">
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
Got it
</Button>
</div>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import {
MessageCircle,
Phone,
AlarmClock,
Upload,
} from 'lucide-react';
import Link from 'next/link';
@@ -24,7 +23,6 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -104,7 +102,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const [externalEoiOpen, setExternalEoiOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
@@ -221,7 +219,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
className="-ml-2.5"
/>
</PermissionGate>
)}
@@ -379,20 +376,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</>
)}
</PermissionGate>
<PermissionGate resource="documents" action="upload_signed">
<button
type="button"
onClick={() => setExternalEoiOpen(true)}
aria-label="Upload externally-signed EOI"
title="Upload externally-signed EOI (paper / outside Documenso)"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Upload className="size-4" />
</button>
</PermissionGate>
{/* The "Upload paper-signed EOI" button used to live here.
It's now on the dedicated EOI tab (in both the active-EOI
hero and the empty-state CTA row), where it sits next to
the document it relates to. The header was a shotgun of
actions that didn't all belong; collecting them per-tab
is the cleaner UX. */}
<PermissionGate resource="interests" action="edit">
<button
type="button"
@@ -456,12 +445,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
<ExternalEoiUploadDialog
open={externalEoiOpen}
onOpenChange={setExternalEoiOpen}
interestId={interest.id}
/>
</>
);
}

View File

@@ -9,6 +9,7 @@ import { InterestDetailHeader } from '@/components/interests/interest-detail-hea
import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
interface InterestData {
@@ -102,7 +103,30 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
// Topbar breadcrumb: Clients Mary Smith Interest B17.
// Parent client links straight back to the client detail; the
// current crumb is the primary berth's mooring (or "Interest" if
// no berth linked yet — same trick the page H1 uses).
useBreadcrumbHint(
data
? {
parents:
data.clientId && data.clientName
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
: [],
current: data.berthMooringNumber ?? 'Interest',
}
: null,
);
const tabs = data
? getInterestTabs({
interestId,
currentUserId,
clientId: data.clientId ?? null,
interest: data,
})
: [];
return (
<DetailLayout

View File

@@ -1,12 +1,18 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { FileGrid, type FileRow } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface InterestDocumentsTabProps {
@@ -15,40 +21,78 @@ interface InterestDocumentsTabProps {
interface InterestData {
id: string;
yachtId?: string | null;
berthId?: string | null;
clientName?: string | null;
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
clientPrimaryEmail?: string | null;
clientHasAddress?: boolean;
clientId?: string | null;
}
/**
* Documents tab — legal instruments (EOI / contract / reservation) with
* full signing status, plus an Attachments section for any other file the
* rep wants on the deal. Replaces the standalone Files tab — at the
* interest level virtually everything is either a legal doc or rare
* one-off, and a separate tab was dead weight 95% of the time.
*/
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const queryClient = useQueryClient();
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
// Same query key + queryFn shape as InterestDetail's parent query, so the
// cache is consistent. (Mismatched shapes on the same key clobber each other
// and the parent header degenerates to "Unknown Client".)
const { data: interest } = useQuery<InterestData>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
});
const prerequisites = {
// Required (EOI Section 2 - top paragraph): name, address, email.
hasName: Boolean(interest?.clientName),
hasEmail: Boolean(interest?.clientPrimaryEmail),
hasAddress: Boolean(interest?.clientHasAddress),
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
hasYacht: Boolean(interest?.yachtId),
hasBerth: Boolean(interest?.berthId),
// Files attach at the client level (the schema has no interest_id
// FK on `files`). For an interest, surface every file that belongs
// to its parent client — covers the realistic case where a rep
// uploaded a passport / scan / photo while working a deal.
// Until the interest record loads we pass a sentinel clientId so the
// server returns empty rather than the unscoped port-wide file list.
const clientId = interest?.clientId ?? '__pending__';
const filesQueryKey = ['files', { clientId }] as const;
const { data: files, isLoading: filesLoading } = usePaginatedQuery<FileRow>({
queryKey: filesQueryKey,
endpoint: `/api/v1/files?clientId=${encodeURIComponent(clientId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [filesQueryKey],
'file:updated': [filesQueryKey],
'file:deleted': [filesQueryKey],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: filesQueryKey });
} catch {
// silent
}
};
const hasAttachments = files.length > 0;
return (
<div className="space-y-4">
<div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<h3 className="text-sm font-medium text-muted-foreground">Legal documents</h3>
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
@@ -73,12 +117,55 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
</div>
}
/>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
{hasAttachments ? (
<span className="text-xs text-muted-foreground">
{files.length} file{files.length === 1 ? '' : 's'}
</span>
) : null}
</div>
<PermissionGate resource="files" action="upload">
{interest?.clientId ? (
<FileUploadZone
entityType="client"
entityId={interest.clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: filesQueryKey });
}}
/>
) : null}
</PermissionGate>
{hasAttachments ? (
<FileGrid
files={files}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={filesLoading}
/>
) : null}
</section>
<EoiGenerateDialog
interestId={interestId}
clientId={interest?.clientId ?? null}
open={eoiDialogOpen}
onOpenChange={setEoiDialogOpen}
prerequisites={prerequisites}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);

View File

@@ -0,0 +1,362 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestEoiTabProps {
interestId: string;
/** Used by the generate dialog to deep-link to the client's record. */
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated EOI workspace tab. The user's "where do I generate / track
* the EOI for this deal" surface, separate from the generic Documents
* tab (which is the long-tail history of every document the interest
* has accumulated, including signed past EOIs).
*
* Layout:
* - In-flight EOI hero (signing progress + reminders) when an active
* EOI document exists for the interest
* - "Generate EOI" CTA when none is in flight
* - History strip of past completed/cancelled EOIs
*
* The actual generate flow opens `EoiGenerateDialog` which now shows
* the resolved EoiContext (real values that will be filled) rather
* than just a checklist of which fields exist.
*/
export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [generateOpen, setGenerateOpen] = useState(false);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=eoi`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveEoiCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyEoiState
onGenerate={() => setGenerateOpen(true)}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
)}
{/* History strip — completed + cancelled EOIs from earlier in the
deal's life. Quiet and skimmable; the active document above
carries the day-to-day attention. */}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
EOI history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={generateOpen}
onOpenChange={setGenerateOpen}
/>
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
</div>
);
}
// ─── In-flight EOI hero ──────────────────────────────────────────────────────
function ActiveEoiCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
});
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('EOI cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={() => {
if (window.confirm('Cancel this EOI? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyEoiState({
onGenerate,
onUploadSigned,
}: {
onGenerate: () => void;
onUploadSigned: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing Documenso handles the signing chain. You can also
upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Generate EOI
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}

View File

@@ -1,93 +0,0 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
interface InterestFilesTabProps {
interestId: string;
}
export function InterestFilesTab({ interestId }: InterestFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
endpoint: `/api/v1/files?entityType=interest&entityId=${encodeURIComponent(interestId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files', { entityType: 'interest', entityId: interestId }]],
'file:updated': [['files', { entityType: 'interest', entityId: interestId }]],
'file:deleted': [['files', { entityType: 'interest', entityId: interestId }]],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
} catch {
// silent
}
};
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
entityType="interest"
entityId={interestId}
onUploadComplete={() => {
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
}}
/>
</PermissionGate>
<FileGrid
files={data}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={isLoading}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -41,6 +41,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
{ label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' },
{ label: 'Other', value: 'other' },
],
},
{

View File

@@ -10,7 +10,6 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@@ -62,7 +61,6 @@ interface InterestFormProps {
pipelineStage: string;
leadCategory?: string | null;
source?: string | null;
notes?: string | null;
reminderEnabled?: boolean;
reminderDays?: number | null;
tags?: Array<{ id: string }>;
@@ -130,7 +128,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
source: interest.source ?? undefined,
notes: interest.notes ?? undefined,
reminderEnabled: interest.reminderEnabled ?? false,
reminderDays: interest.reminderDays ?? undefined,
tagIds: interest.tags?.map((t) => t.id) ?? [],
@@ -457,18 +454,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Add notes about this interest..."
rows={3}
/>
</div>
<Separator />
{/* Reminder */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">

View File

@@ -25,7 +25,15 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { PipelineBoard } from '@/components/interests/pipeline-board';
import { interestFilterDefinitions } from '@/components/interests/interest-filters';
import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns';
import {
getInterestColumns,
INTEREST_COLUMN_OPTIONS,
INTEREST_DEFAULT_HIDDEN,
type InterestRow,
} from '@/components/interests/interest-columns';
import { ColumnPicker } from '@/components/shared/column-picker';
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { InterestCard } from '@/components/interests/interest-card';
import { TagPicker } from '@/components/shared/tag-picker';
import {
@@ -58,6 +66,7 @@ export function InterestList() {
const [createOpen, setCreateOpen] = useState(false);
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);
// Bulk-action dialog state
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
@@ -134,6 +143,12 @@ export function InterestList() {
onArchive: (interest) => setArchiveInterest(interest),
});
// Persisted per-user column visibility — same pattern as ClientList.
// The hidden array is the source of truth; built columns stay
// declared and we drive table visibility via columnVisibility.
const { hidden, setHidden } = useTablePreferences('interests', INTEREST_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
@@ -171,8 +186,19 @@ export function InterestList() {
/>
<div className="flex flex-wrap items-center gap-2">
{/* On the kanban view we strip filters that don't make sense
* there: `pipelineStage` (the columns ARE the stages) and
* `includeArchived` (the board is for active deals — the
* list view is the place to see history). The board endpoint
* rejects these via boardFiltersSchema if they're sent. */}
<FilterBar
filters={interestFilterDefinitions}
filters={
viewMode === 'board'
? interestFilterDefinitions.filter(
(f) => f.key !== 'pipelineStage' && f.key !== 'includeArchived',
)
: interestFilterDefinitions
}
values={filters}
onChange={setFilter}
onClear={clearFilters}
@@ -188,24 +214,44 @@ export function InterestList() {
placeholder="Filter by tag / event…"
/>
</div>
{/* Columns + saved views are table-only concepts; the kanban
* always shows the same compact card across every stage so
* hiding both controls in board mode keeps the toolbar honest. */}
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
</div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="interests"
currentFilters={filters}
currentSort={sort}
/>
{viewMode === 'board' ? (
<PipelineBoard />
<PipelineBoard filters={filters} />
) : isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {

View File

@@ -0,0 +1,419 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestReservationTabProps {
interestId: string;
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated Reservation workspace tab. Mirrors the EOI tab pattern but
* for reservation agreements. Contracts differ from EOIs in that there's no
* standard Documenso template — each reservation is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** — the signed reservation was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** — rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
* footer-anchored signature layout is used.)
*
* The Documents tab still shows every reservation document (signed or
* drafted) as a permanent history.
*/
export function InterestReservationTab({
interestId,
clientId: _clientId,
}: InterestReservationTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'reservation_agreement' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=reservation_agreement`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveReservationCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyReservationState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
/>
)}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Reservation history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
{/* Reuses the external-EOI upload dialog. The endpoint
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
— for reservation paper-uploads we'll need the equivalent
reservation endpoint (deferred to a follow-up; the dialog UI
is the pattern we'll clone). For now the flow is documented
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && (
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
)}
{/* Upload-for-Documenso-signing dialog placeholder. The real
dialog (PDF picker + recipient configurator + send button)
is part of the larger custom-doc-upload service that's a
follow-up. For now show a friendly "coming soon" card. */}
{uploadForSigningOpen && (
<ComingSoonDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
title="Send reservation for signing"
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the reservation externally, get it signed via paper or another tool, then upload the signed copy here."
/>
)}
</div>
);
}
// ─── Active reservation hero ────────────────────────────────────────────────────
function ActiveReservationCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
});
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('Reservation cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={() => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyReservationState({
onUploadSigned,
onUploadForSigning,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No reservation in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you
handled externally, or upload the draft PDF and send for e-signing via Documenso.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}
/**
* Placeholder for the upload-for-Documenso-signing flow until the
* full upload + recipient + field-placement service is shipped.
* Intentional dead-end so reps know the path exists rather than
* misclicking and getting confusing behaviour.
*/
function ComingSoonDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
body: string;
}) {
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
<div className="mt-4 flex justify-end">
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
Got it
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,8 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
@@ -16,12 +17,20 @@ import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import {
LEAD_CATEGORIES,
PIPELINE_STAGES,
canTransitionStage,
type PipelineStage,
} from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
type InterestPatchField = 'leadCategory' | 'source';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
@@ -37,6 +46,9 @@ function humanizeStatus(value: string | null): string | null {
interface InterestTabsOptions {
interestId: string;
currentUserId?: string;
/** Used by the dedicated EOI tab to deep-link to the client's record
* for inline edits ("wrong details? edit on the client's page"). */
clientId?: string | null;
interest: {
pipelineStage: string;
/** Drives the recommender panel mounted on the Overview tab. */
@@ -59,6 +71,9 @@ interface InterestTabsOptions {
reminderEnabled: boolean;
reminderDays: number | null;
reminderLastFired: string | null;
/** Count of berths linked via the interest_berths junction —
* drives the "Berth Interest" milestone on the Overview tab. */
linkedBerthCount?: number;
notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note"
* teaser - saves a click into the Notes tab to peek at the latest. */
@@ -87,13 +102,23 @@ function useInterestPatch(interestId: string) {
});
}
type Phase = 'past' | 'current' | 'future';
function useStageMutation(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ stage, reason }: { stage: string; reason?: string }) =>
mutationFn: async ({
stage,
reason,
override,
}: {
stage: string;
reason?: string;
override?: boolean;
}) =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: stage, reason },
body: { pipelineStage: stage, reason, override },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] });
@@ -278,6 +303,73 @@ function MilestoneSection({
);
}
/**
* Collapsible wrapper for future-phase milestones. Hidden by default so
* the overview stays focused on the current stage; expanding lets reps
* record skipped milestones (the action click then routes through the
* advance() override-confirm).
*/
function FutureMilestones({
milestones,
stageMutation,
advance,
activeMilestone,
currentStage,
}: {
milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
steps: MilestoneSectionProps['steps'];
footer?: React.ReactNode;
}>;
stageMutation: ReturnType<typeof useStageMutation>;
advance: (stage: string) => void;
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
currentStage: string;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-dashed">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
>
<span>
{expanded ? 'Hide' : 'Show'} upcoming milestones
<span className="ml-2 text-xs">({milestones.map((m) => m.title).join(' · ')})</span>
</span>
<span className="text-xs">{expanded ? '▴' : '▾'}</span>
</button>
{expanded && (
<div
className={cn(
'grid grid-cols-1 gap-4 p-4 pt-0',
milestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{milestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={currentStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
</div>
);
}
function OverviewTab({
interestId,
interest,
@@ -292,25 +384,98 @@ function OverviewTab({
const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
const advance = (stage: string) =>
stageMutation.mutate({ stage, reason: 'Marked from overview' });
/**
* Advance the pipeline. When the requested target isn't a legal next
* step (e.g. user clicked "Mark deposit received" while still on
* EOI Sent), prompt for confirmation and pass `override:true` so the
* backend transition guard lets the change through. Mirrors the
* skip-ahead pattern from the inline stage picker so audit trails
* stay consistent regardless of which surface the rep used.
*/
const advance = (stage: string) => {
const fromStage = interest.pipelineStage as PipelineStage;
const toStage = stage as PipelineStage;
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
if (isOverride) {
const ok = window.confirm(
`This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace(
/_/g,
' ',
)}", which isn't a standard next step. Continue?\n\nThe change will be flagged in the audit log.`,
);
if (!ok) return;
}
stageMutation.mutate({
stage,
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
override: isOverride || undefined,
});
};
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
// Determine each milestone's phase relative to the current pipeline
// stage. The overview hides future-phase milestones by default — it
// was visually noisy to see Deposit + Contract cards on a deal still
// at the EOI stage, and the empty cards invited mis-clicks.
//
// Past milestones still render (collapsed history) so reps can see
// what's been completed. Future milestones are gated behind a "Show
// upcoming milestones" toggle so the rep CAN reach them when a deal
// genuinely skips stages — the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
if (stageIdx === -1 || stageIdx >= contractSignedIdx) {
activeMilestone = null;
} else if (stageIdx < eoiSignedIdx) {
activeMilestone = 'eoi';
} else if (stageIdx < depositIdx) {
activeMilestone = 'deposit';
} else {
activeMilestone = 'contract';
}
const phaseFor = (milestoneEndStageIdx: number): Phase => {
if (stageIdx === -1) return 'future';
if (stageIdx >= milestoneEndStageIdx) return 'past';
// The "current" milestone is the one whose end-stage hasn't been
// reached and whose start-stage is at-or-before the current stage.
return 'current';
};
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
// sits as the "current" milestone unless the deal has already moved
// past EOI sent (in which case the rep clearly didn't need a berth
// pinned first, so we mark it 'past' implicitly).
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiSignedIdx
? 'past'
: 'current';
const eoiPhase = phaseFor(eoiSignedIdx);
// Deposit is current once the EOI is signed but before deposit is in.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= depositIdx
? 'past'
: stageIdx >= eoiSignedIdx
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= contractSignedIdx
? 'past'
: stageIdx >= depositIdx
? 'current'
: 'future';
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
? 'berth_interest'
: eoiPhase === 'current'
? 'eoi'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
@@ -318,23 +483,49 @@ function OverviewTab({
return Number.isFinite(n) ? n : null;
};
return (
<div className="space-y-6">
{/* Sales-process milestones - the heart of the system. Each section is a
mini lifecycle that auto-completes as actions happen on the platform
(Documenso webhook, paid deposit invoice, signed contract). Until the
automation lands, salespeople nudge stages forward via the inline
buttons here, which auto-stamp the milestone date server-side. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<MilestoneSection
title="EOI"
icon={Send}
status={interest.eoiStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'eoi'}
steps={[
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
phase: Phase;
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
steps: MilestoneSectionProps['steps'];
footer?: React.ReactNode;
/** Brief one-liner shown when the milestone is in the past-strip. */
pastSummary: React.ReactNode;
}> = [
{
key: 'berth_interest',
phase: berthInterestPhase,
title: 'Berth Interest',
icon: Anchor,
// No status badge — the count IS the status. Showing "0 berths"
// would just duplicate the empty-state copy below.
status: hasLinkedBerth
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'}`
: null,
// No advanceStage step — the milestone tracks a state (berths
// linked) rather than a stage transition. Hide the row chrome by
// passing an empty steps array; the footer renders the action.
steps: [],
footer:
berthInterestPhase === 'current' ? (
<div className="text-xs text-muted-foreground">
Add a berth from the Recommendations tab or the client&apos;s active interest panel to
mark this milestone complete.
</div>
) : null,
pastSummary: hasLinkedBerth
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'} linked`
: 'Skipped',
},
{
key: 'eoi',
phase: eoiPhase,
title: 'EOI',
icon: Send,
status: interest.eoiStatus,
steps: [
{
label: 'EOI sent',
date: interest.dateEoiSent,
@@ -347,28 +538,27 @@ function OverviewTab({
advanceStage: 'eoi_signed',
actionLabel: 'Mark EOI as signed',
},
]}
/>
<MilestoneSection
title="Deposit"
icon={Wallet}
status={interest.depositStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'deposit'}
steps={[
],
pastSummary: interest.dateEoiSigned
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
},
{
key: 'deposit',
phase: depositPhase,
title: 'Deposit',
icon: Wallet,
status: interest.depositStatus,
steps: [
{
label: 'Deposit received',
date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct',
// The richer invoice-first CTA lives in `footer`. We still pass
// advanceStage so the milestone derives its done-state correctly.
hideAutoButton: true,
},
]}
footer={
!interest.dateDepositReceived ? (
],
footer:
depositPhase === 'current' && !interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
@@ -385,18 +575,18 @@ function OverviewTab({
Mark received manually
</button>
</div>
) : null
}
/>
<MilestoneSection
title="Contract"
icon={FileSignature}
status={interest.contractStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'contract'}
steps={[
) : null,
pastSummary: interest.dateDepositReceived
? `Received ${formatDate(interest.dateDepositReceived)}`
: 'Recorded',
},
{
key: 'contract',
phase: contractPhase,
title: 'Contract',
icon: FileSignature,
status: interest.contractStatus,
steps: [
{
label: 'Contract sent',
date: interest.dateContractSent,
@@ -409,9 +599,74 @@ function OverviewTab({
advanceStage: 'contract_signed',
actionLabel: 'Mark contract as signed',
},
]}
/>
],
pastSummary: interest.dateContractSigned
? `Signed ${formatDate(interest.dateContractSigned)}`
: 'Completed',
},
];
const pastMilestones = milestones.filter((m) => m.phase === 'past');
const currentMilestones = milestones.filter((m) => m.phase === 'current');
const futureMilestones = milestones.filter((m) => m.phase === 'future');
return (
<div className="space-y-6">
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */}
{pastMilestones.length > 0 && (
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
{pastMilestones.map((m) => (
<span key={m.key} className="inline-flex items-center gap-1.5">
<CheckCircle2 className="size-3 text-emerald-600" />
<span className="font-medium text-foreground">{m.title}</span>
<span>·</span>
<span>{m.pastSummary}</span>
</span>
))}
</div>
</div>
)}
{currentMilestones.length > 0 && (
<div
className={cn(
'grid grid-cols-1 gap-4',
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{currentMilestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
{futureMilestones.length > 0 && (
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
/>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */}
@@ -460,8 +715,9 @@ function OverviewTab({
{/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last."
Hidden when there's nothing to show. */}
{interest.recentNote ? (
Always rendered now that the redundant `interests.notes` blob is
gone — falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */}
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
@@ -469,10 +725,12 @@ function OverviewTab({
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
View all
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}
{interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'}
</Link>
</div>
{interest.recentNote ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content}
@@ -486,18 +744,11 @@ function OverviewTab({
: ''}
</p>
</div>
) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet.
</div>
) : null}
{/* Notes (editable, multiline) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={interest.notes}
onSave={save('notes')}
emptyText="No notes - click to add"
/>
)}
</div>
{/* Tags */}
@@ -533,14 +784,39 @@ function OverviewTab({
export function getInterestTabs({
interestId,
currentUserId,
clientId = null,
interest,
}: InterestTabsOptions): DetailTab[] {
return [
// The EOI / Contract / Reservation tabs are stage-conditional —
// each appears only at the stages where the rep is likely to act
// on it. Hides clutter from later-stage deals where earlier docs
// are ancient history. Each tab still queries for its own past
// documents; if a deal regresses the past docs remain accessible
// via the generic Documents tab.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
// EOI: from details_sent through contract_signed (the deal's whole life)
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
// Contract: appears once the deposit's been paid (deal is committed)
// and stays visible until the contract is signed
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
// Reservation: appears once the contract's signed and stays visible
// through completion (reservation is the post-contract milestone)
const showReservationTab = stageIdx >= contractSignedIdx;
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />,
},
{
id: 'contact-log',
label: 'Contact log',
content: <InterestContactLogTab interestId={interestId} />,
},
{
id: 'notes',
label: 'Notes',
@@ -548,16 +824,38 @@ export function getInterestTabs({
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
),
},
];
if (showEoiTab) {
tabs.push({
id: 'eoi',
label: 'EOI',
content: <InterestEoiTab interestId={interestId} clientId={clientId} />,
});
}
if (showContractTab) {
tabs.push({
id: 'contract',
label: 'Contract',
content: <InterestContractTab interestId={interestId} clientId={clientId} />,
});
}
if (showReservationTab) {
tabs.push({
id: 'reservation',
label: 'Reservation',
content: <InterestReservationTab interestId={interestId} clientId={clientId} />,
});
}
tabs.push(
{
id: 'documents',
label: 'Documents',
content: <InterestDocumentsTab interestId={interestId} />,
},
{
id: 'files',
label: 'Files',
content: <InterestFilesTab interestId={interestId} />,
},
{
id: 'recommendations',
label: 'Recommendations',
@@ -568,5 +866,7 @@ export function getInterestTabs({
label: 'Activity',
content: <InterestTimeline interestId={interestId} />,
},
];
);
return tabs;
}

View File

@@ -305,32 +305,39 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`specific-${row.berthId}`} className="text-sm font-medium">
Specifically pitching
</Label>
{/* Switch sits next to its label (gap-2.5) instead of being
flexed to the far right via justify-between — when the
column is wide, justify-between created a confusing visual
gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch
id={`specific-${row.berthId}`}
checked={row.isSpecificInterest}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
/>
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium">
Mark in EOI bundle
</Label>
<div className="flex items-center gap-2.5">
<Switch
id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
Mark in EOI bundle
</Label>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle

View File

@@ -7,8 +7,8 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { PipelineColumn } from '@/components/interests/pipeline-column';
import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
interface InterestRow {
id: string;
@@ -19,28 +19,77 @@ interface InterestRow {
updatedAt: string;
}
export function PipelineBoard() {
interface BoardResponse {
data: InterestRow[];
truncated: boolean;
total: number;
}
interface PipelineBoardProps {
/** Filter values from the parent's FilterBar — passed through to the
* /api/v1/interests/board endpoint. Subset of listInterests filters
* (no pipelineStage, no includeArchived). Optional; board works
* fine without filters. */
filters?: Record<string, unknown>;
}
export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { boardFilters } = usePipelineStore();
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
queryKey: ['interests-board', portSlug],
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
});
const interests = useMemo(() => {
if (!allData?.data) return [];
return allData.data.filter((i) => {
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
if (boardFilters.search) {
const q = boardFilters.search.toLowerCase();
if (!i.clientName?.toLowerCase().includes(q)) return false;
// Build the board endpoint URL with the supported filter subset.
// pipelineStage + includeArchived are intentionally not threaded
// through — see boardFiltersSchema on the backend. Stable JSON-string
// form is reused as the queryKey so React Query caches per filter combo.
const queryString = useMemo(() => {
if (!filters) return '';
const params = new URLSearchParams();
const pick = (k: string) => {
const v = filters[k];
if (v === null || v === undefined || v === '' || v === false) return;
if (Array.isArray(v)) {
if (v.length === 0) return;
params.set(k, v.join(','));
} else {
params.set(k, String(v));
}
return true;
};
pick('search');
pick('leadCategory');
pick('source');
pick('eoiStatus');
pick('tagIds');
const s = params.toString();
return s ? `?${s}` : '';
}, [filters]);
const boardQueryKey = ['interests-board', portSlug, queryString] as const;
// Dedicated board endpoint — bypasses the paginated list's max(100)
// cap, projects only the 5 fields PipelineCard renders, and hard-caps
// at 5000 server-side. If `truncated: true`, surface a banner so the
// rep knows the board isn't showing every active deal.
const {
data: allData,
isLoading,
error,
} = useQuery<BoardResponse>({
queryKey: boardQueryKey,
queryFn: () => apiFetch(`/api/v1/interests/board${queryString}`),
});
}, [allData, boardFilters]);
// Invalidate the entire ['interests-board', portSlug, *] family so
// realtime events refresh whatever filter combo is currently active.
// Using the prefix keeps stale per-filter caches from lingering after
// the underlying data changes elsewhere in the app.
useRealtimeInvalidation({
'interest:created': [['interests-board', portSlug]],
'interest:updated': [['interests-board', portSlug]],
'interest:stageChanged': [['interests-board', portSlug]],
'interest:archived': [['interests-board', portSlug]],
});
const interests = useMemo(() => allData?.data ?? [], [allData]);
const grouped = useMemo(() => {
const map: Record<string, InterestRow[]> = {};
@@ -98,8 +147,31 @@ export function PipelineBoard() {
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
}
// Surface fetch failures instead of silently rendering nine "Empty"
// columns, which is indistinguishable from "no interests yet" and was
// exactly the bug that hid this view's silent failure for so long.
if (error) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
Couldn&apos;t load the pipeline board.{' '}
<button
className="underline underline-offset-2"
onClick={() => queryClient.invalidateQueries({ queryKey: boardQueryKey })}
>
Retry
</button>
</div>
);
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{allData?.truncated ? (
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
deals aren&apos;t on the board archive completed work to keep the kanban readable.
</div>
) : null}
<div className="flex gap-3 overflow-x-auto pb-4">
{PIPELINE_STAGES.map((stage) => (
<PipelineColumn

View File

@@ -13,7 +13,8 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { usePortContext } from '@/providers/port-provider';
import { useUIStore } from '@/stores/ui-store';
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
// Human-readable labels for route segments
const SEGMENT_LABELS: Record<string, string> = {
@@ -51,7 +52,8 @@ function formatSegment(segment: string): string {
export function Breadcrumbs() {
const pathname = usePathname();
const { currentPort, currentPortSlug } = usePortContext();
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
@@ -62,22 +64,10 @@ export function Breadcrumbs() {
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg));
if (segments.length === 0) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground font-medium">
{currentPort?.name ?? 'Port Nimara CRM'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
if (segments.length === 0) return null;
// Build href for each segment
const crumbs = segments.map((segment, index) => {
// Build href for each segment from the URL.
const urlCrumbs = segments.map((segment, index) => {
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
const href = '/' + segmentsUpToHere.join('/');
const label = formatSegment(segment);
@@ -86,35 +76,37 @@ export function Breadcrumbs() {
return { label, href, isLast };
});
// When a detail page registered a hint, splice in the parent crumbs
// (e.g. the parent client name) and replace the trailing label with
// the entity's actual name (e.g. "B17"). This turns the URL-only
// "Clients Interests" into "Clients Mary Smith Interest B17"
// when the rep clicked from a client page. URL-only renders untouched
// when no hint is registered.
const crumbs = (() => {
if (!hint) return urlCrumbs;
const head = urlCrumbs.slice(0, -1).map((c) => ({ ...c, isLast: false }));
const parents = hint.parents.map((p) => ({
label: p.label,
href: p.href ?? pathname,
isLast: false,
}));
const lastUrlCrumb = urlCrumbs[urlCrumbs.length - 1];
const tail = {
label: hint.current,
href: lastUrlCrumb?.href ?? pathname,
isLast: true,
};
return [...head, ...parents, tail];
})();
return (
<Breadcrumb>
<BreadcrumbList>
{currentPort && (
<>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${currentPortSlug}/dashboard` as any}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{currentPort.name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{crumbs.length > 0 && (
<BreadcrumbSeparator>
<ChevronRight className="w-3 h-3" />
</BreadcrumbSeparator>
)}
</>
)}
<BreadcrumbList className="text-sm gap-1.5">
{crumbs.map((crumb, _index) => (
<Fragment key={crumb.href}>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage className="font-medium text-foreground">
<BreadcrumbPage className="font-medium text-foreground truncate max-w-[160px]">
{crumb.label}
</BreadcrumbPage>
) : (
@@ -122,7 +114,7 @@ export function Breadcrumbs() {
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={crumb.href as any}
className="text-muted-foreground hover:text-foreground transition-colors"
className="text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 truncate max-w-[160px]"
>
{crumb.label}
</Link>
@@ -130,7 +122,7 @@ export function Breadcrumbs() {
)}
</BreadcrumbItem>
{!crumb.isLast && (
<BreadcrumbSeparator>
<BreadcrumbSeparator className="text-muted-foreground/40">
<ChevronRight className="w-3 h-3" />
</BreadcrumbSeparator>
)}

View File

@@ -9,7 +9,6 @@ import {
BellRing,
Bookmark,
Building2,
FileText,
Globe,
Home,
Mail,
@@ -43,7 +42,6 @@ const MORE_ITEMS: MoreItem[] = [
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Inbox', icon: Mail, segment: 'email' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },

View File

@@ -123,7 +123,10 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
// Invoices nav entry removed — the expense-to-PDF flow is the
// only invoicing surface now (employee expense reports). The
// standalone /invoices route still exists for any back-compat
// links but is no longer surfaced in nav.
// Dedicated explainer page covers "Add to Home Screen" install
// + walkthrough; the mobile-only scanner UI itself lives at /scan
// and is reached via the install or the explainer page button.
@@ -224,6 +227,7 @@ function SidebarContent({
collapsed,
portSlug,
portRoles,
isSuperAdmin,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
@@ -234,6 +238,7 @@ function SidebarContent({
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
isSuperAdmin: boolean;
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
@@ -246,6 +251,14 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug);
// Small label under the user identity when the user has access to more
// than one port — disambiguates which port is currently active without
// pulling the port name back into the breadcrumbs.
const showPortLabel = !!ports && ports.length > 1;
const currentPortName = showPortLabel
? (ports.find((p) => p.slug === portSlug)?.name ?? null)
: null;
// Pre-compute every nav href the sidebar offers across all sections so
// the active-state check can do longest-prefix-match. Without this,
// /invoices/upload-receipts would highlight both "Invoices" and "How to
@@ -403,8 +416,11 @@ function SidebarContent({
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
>
{humanizeRole(portRoles[0]?.role?.name)}
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
</Badge>
{currentPortName && (
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p>
)}
</div>
</button>
}
@@ -417,8 +433,10 @@ function SidebarContent({
}
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
// Sidebar collapse removed — design preference is the always-expanded
// form. Forcibly false; the store flag stays for backwards-compat with
// any code still reading it.
const sidebarCollapsed = false;
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Super admins see every section regardless of role rows.
@@ -448,12 +466,12 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
collapsed={sidebarCollapsed}
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
isSuperAdmin={isSuperAdmin}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
user={user}
ports={ports}
onToggleCollapse={toggleSidebar}
/>
</aside>
);

View File

@@ -1,9 +1,11 @@
'use client';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ChevronLeft, Plus } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
@@ -28,16 +30,46 @@ interface TopbarProps {
export function Topbar({ ports, user }: TopbarProps) {
const router = useRouter();
const pathname = usePathname();
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const base = currentPortSlug ? `/${currentPortSlug}` : '';
// Reuse the existing per-page chrome state (originally built for the
// mobile topbar) so any detail page that already declares
// `showBackButton: true` automatically gets the back affordance on
// desktop too. Saves duplicating the wiring across N detail headers.
const { showBackButton: mobileShowBack } = useMobileChrome();
// Auto-show on entity-detail pages: `/[portSlug]/[section]/[id]` and
// deeper. Top-level lists like `/[portSlug]/clients` stay clean.
// The mobile-chrome flag still wins when a page explicitly opts in.
// Pages that already render their own "back to X" link inline
// (residential interest detail, expense scan flow, etc.) opt OUT
// by setting the chrome flag to false on mount — the flag override
// path here lets them suppress this auto-show.
const segments = pathname.split('/').filter(Boolean);
const isDeepPage = segments.length > 2;
const showBackButton = mobileShowBack || isDeepPage;
return (
// Three-column grid: breadcrumbs left, search center, actions right.
// The brand logo lives in the sidebar header (per design feedback) so the
// topbar center is dedicated to the global search bar.
<header className="grid h-14 grid-cols-[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
{/* LEFT: breadcrumbs / page title */}
<div className="min-w-0">
{/* LEFT: optional back button + breadcrumbs / page title */}
<div className="min-w-0 flex items-center gap-1.5">
{showBackButton && (
<button
type="button"
onClick={() => router.back()}
aria-label="Go back"
title="Go back"
className={cn(
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
'text-muted-foreground hover:text-foreground hover:bg-accent transition-colors',
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
<Breadcrumbs />
</div>
@@ -100,8 +132,12 @@ export function Topbar({ ports, user }: TopbarProps) {
user={user}
ports={ports}
trigger={
<Button variant="ghost" size="icon" className="rounded-full">
<Avatar className="w-7 h-7 shadow-sm ring-2 ring-background">
// Button shrunk to match the Avatar's visible footprint so
// the hover halo lands as a tight circle behind the avatar
// (was h-11 w-11 default — the rounded-full halo extended
// well past the visible avatar and read as a square glow).
<Button variant="ghost" className="rounded-full h-9 w-9 p-0">
<Avatar className="w-7 h-7">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}

View File

@@ -0,0 +1,133 @@
'use client';
import { Columns3, Check, Bookmark } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export interface ColumnPickerOption {
/** Stable ID matching the column's `id` field in the table. */
id: string;
/** Human-readable label shown in the dropdown menu. */
label: string;
/**
* When true, the column can't be toggled off (e.g. Name + actions).
* It still appears in the menu but with a disabled checkmark.
*/
alwaysVisible?: boolean;
}
/**
* Dropdown menu for toggling table column visibility. Lives next to the
* filter bar — single source of truth for which columns the current
* user wants to see in this table. Persistence is handled by the
* parent (typically via `useTablePreferences`).
*/
export function ColumnPicker({
columns,
hidden,
onChange,
onSaveView,
}: {
columns: ColumnPickerOption[];
hidden: string[];
onChange: (hidden: string[]) => void;
/**
* Optional callback. When provided, a "Save current view" item is
* appended to the menu — folds the save-view affordance into the
* column picker instead of a separate top-level button.
*/
onSaveView?: () => void;
}) {
const hiddenSet = new Set(hidden);
function toggle(id: string) {
const next = new Set(hiddenSet);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange(Array.from(next));
}
function showAll() {
onChange([]);
}
// The "All visible" affordance is only useful when something is
// hidden — a no-op button is noise.
const canShowAll = hidden.some((id) =>
columns.some((col) => col.id === id && !col.alwaysVisible),
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 h-8">
<Columns3 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Columns</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Show / hide columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns.map((col) => {
const isVisible = !hiddenSet.has(col.id);
return (
<DropdownMenuItem
key={col.id}
disabled={col.alwaysVisible}
onSelect={(e) => {
// Keep the menu open while toggling so the user can
// flip multiple columns in one pass.
e.preventDefault();
if (col.alwaysVisible) return;
toggle(col.id);
}}
className="flex items-center gap-2"
>
<span
aria-hidden
className={`flex h-4 w-4 items-center justify-center rounded-sm border ${
isVisible ? 'bg-primary border-primary text-primary-foreground' : 'border-border'
}`}
>
{isVisible && <Check className="h-3 w-3" />}
</span>
<span className="flex-1">{col.label}</span>
{col.alwaysVisible && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
always
</span>
)}
</DropdownMenuItem>
);
})}
{canShowAll && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={showAll} className="text-xs text-muted-foreground">
Show all columns
</DropdownMenuItem>
</>
)}
{onSaveView && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSaveView}>
<Bookmark className="mr-2 h-3.5 w-3.5" />
Save current view
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -8,6 +8,7 @@ import {
type ColumnDef,
type Row,
type RowSelectionState,
type VisibilityState,
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
@@ -51,6 +52,12 @@ interface DataTableProps<TData> {
isLoading?: boolean;
getRowId?: (row: TData) => string;
onRowClick?: (row: TData) => void;
/**
* Optional row class hook — return a string of Tailwind utilities
* applied to the `<TableRow>`. Use for visual grouping (e.g. tinting
* berths by mooring letter so the kanban-like grid reads at a glance).
*/
getRowClassName?: (row: TData) => string | undefined;
/**
* Mobile card renderer. When provided, the table is hidden below `lg:`
* and replaced with a vertical list of cards built from this callback.
@@ -58,6 +65,13 @@ interface DataTableProps<TData> {
* sort, and selection stay in sync across the breakpoint.
*/
cardRender?: (row: Row<TData>) => React.ReactNode;
/**
* Per-column visibility map. Keys are column IDs, values mean
* "currently visible". Columns absent from the map are visible by
* default — newly-added columns surface for existing users without
* needing a preferences migration.
*/
columnVisibility?: VisibilityState;
}
export function DataTable<TData>({
@@ -74,7 +88,9 @@ export function DataTable<TData>({
isLoading,
getRowId,
onRowClick,
getRowClassName,
cardRender,
columnVisibility,
}: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
const rowSelectionState = externalSelection ?? internalSelection;
@@ -122,6 +138,7 @@ export function DataTable<TData>({
pagination: pagination
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
: undefined,
columnVisibility,
},
onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater;
@@ -215,7 +232,7 @@ export function DataTable<TData>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(onRowClick && 'cursor-pointer')}
className={cn(onRowClick && 'cursor-pointer', getRowClassName?.(row.original))}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
@@ -247,14 +264,39 @@ export function DataTable<TData>({
</ul>
)}
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
{/* Pagination — render whenever pagination is defined so the
page-size selector is reachable even on single-page tables.
Prev/Next group only renders when there's actually more than
one page. */}
{pagination && (
<div className="flex items-center justify-between px-2 gap-3 flex-wrap">
<div className="text-sm text-muted-foreground">
{selectedIds.length > 0
? `${selectedIds.length} of ${pagination.total} row(s) selected`
: `${pagination.total} row(s) total`}
</div>
<div className="flex items-center gap-3">
{/* Page-size selector — "All" maps to the validator's
max(1000) cap. If a port has more than 1000 active rows
the user paginates; we don't quietly drop rows. */}
<label className="text-sm text-muted-foreground inline-flex items-center gap-1.5">
Show
<select
value={String(pagination.pageSize)}
onChange={(e) => {
const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
onPaginationChange?.(1, next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
<option value="all">All</option>
</select>
</label>
{pagination.totalPages > 1 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -276,6 +318,8 @@ export function DataTable<TData>({
Next
</Button>
</div>
)}
</div>
</div>
)}

View File

@@ -184,16 +184,22 @@ function FilterField({
</div>
);
case 'select':
case 'select': {
// Radix Select forbids empty-string item values (throws at render
// time, crashes the page). Use a sentinel and translate.
const ANY = '__any__';
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<Select value={(value as string) ?? ''} onValueChange={(v) => onChange(v || undefined)}>
<Select
value={(value as string) || ANY}
onValueChange={(v) => onChange(v === ANY ? undefined : v)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={definition.placeholder ?? 'Any'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
<SelectItem value={ANY}>Any</SelectItem>
{definition.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
@@ -203,6 +209,7 @@ function FilterField({
</Select>
</div>
);
}
case 'multi-select':
return (

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { ChevronDown, Loader2, Pencil } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Input } from '@/components/ui/input';
@@ -98,33 +98,25 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
}
if (props.variant === 'select') {
// Picker fields don't need a read/edit mode toggle — a Select is
// already a click-to-open control. Rendering one consistent
// SelectTrigger eliminates the width jump that happened when we
// swapped from a content-sized ReadButton to a w-full SelectTrigger
// on click (and back again on selection). The trigger now stays at
// a single width whether the popover is open or closed, so the
// dropdown menu visually aligns to the same control across states.
const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
if (!editing) {
return (
<ReadButton
value={labelFor(value)}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Select
value={draft}
value={value ?? ''}
onValueChange={(v) => void commit(v)}
open
onOpenChange={(open) => {
if (!open && !saving) setEditing(false);
}}
disabled={disabled || saving}
>
<SelectTrigger className="h-7 text-sm w-full">
<SelectValue placeholder={placeholder} />
<SelectTrigger className="h-8 text-sm w-full">
<SelectValue placeholder={emptyText}>{labelFor(value) ?? emptyText}</SelectValue>
</SelectTrigger>
<SelectContent>
{props.options.map((o) => (
@@ -228,6 +220,7 @@ function ReadButton({
disabled,
onClick,
multiline,
kind = 'text',
className,
}: {
value: string | null;
@@ -235,8 +228,13 @@ function ReadButton({
disabled?: boolean;
onClick: () => void;
multiline?: boolean;
/** Icon affordance: 'text' shows a pencil (free-text edit), 'select'
* shows a chevron-down (fixed-list picker). The chevron clarifies
* that clicking opens a dropdown, not a text input. */
kind?: 'text' | 'select';
className?: string;
}) {
const Icon = kind === 'select' ? ChevronDown : Pencil;
return (
<button
type="button"
@@ -246,6 +244,9 @@ function ReadButton({
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
// Select-kind buttons get a faint border so they read as a
// distinct interactive element rather than text-with-an-icon.
kind === 'select' && 'border border-border bg-background hover:bg-accent',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
@@ -260,11 +261,14 @@ function ReadButton({
{value ?? emptyText}
</span>
{!disabled && (
<Pencil
<Icon
className={cn(
// Show the pencil faintly at rest so users discover the field is
// editable without having to hover-and-test every label.
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
// Pencil sits faintly so users discover free-text editability
// on hover; chevron is more present so the dropdown affordance
// is obvious before any interaction.
kind === 'select'
? 'h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100'
: 'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
multiline && 'mt-1 shrink-0',
)}
/>

View File

@@ -18,32 +18,72 @@ interface Note {
isLocked: boolean;
createdAt: string;
updatedAt: string;
/** Aggregated-mode only: which child entity this note came from. */
source?: 'client' | 'interest' | 'yacht';
sourceId?: string;
sourceLabel?: string;
}
interface NotesListProps {
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
entityType:
| 'clients'
| 'interests'
| 'yachts'
| 'companies'
| 'residential_clients'
| 'residential_interests';
entityId: string;
currentUserId?: string;
/**
* When `entityType='clients'` and this is true, the list aggregates
* notes from the client + their interests + directly-owned yachts.
* Notes from interests/yachts render with a source chip and are
* read-only here (edit them on the source entity's page).
*/
aggregate?: boolean;
}
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
export function NotesList({ entityType, entityId, currentUserId }: NotesListProps) {
/** Sort by source then chronologically inside each source.
* Used by the aggregated view's "Group by source" toggle. */
function sortByGroup(notes: Note[]): Note[] {
const sourceOrder: Record<string, number> = { client: 0, interest: 1, yacht: 2 };
return [...notes].sort((a, b) => {
const aRank = sourceOrder[a.source ?? 'client'] ?? 99;
const bRank = sourceOrder[b.source ?? 'client'] ?? 99;
if (aRank !== bRank) return aRank - bRank;
const aLabel = a.sourceLabel ?? '';
const bLabel = b.sourceLabel ?? '';
if (aLabel !== bLabel) return aLabel.localeCompare(bLabel);
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
}
export function NotesList({ entityType, entityId, currentUserId, aggregate }: NotesListProps) {
const queryClient = useQueryClient();
const [newNote, setNewNote] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const [groupBySource, setGroupBySource] = useState(false);
const endpoint = `/api/v1/${entityType}/${entityId}/notes`;
const queryKey = [entityType, entityId, 'notes'];
const aggregateOn = aggregate && entityType === 'clients';
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
const { data: notes = [], isLoading } = useQuery<Note[]>({
queryKey,
queryFn: () => apiFetch<{ data: Note[] }>(endpoint).then((r) => r.data),
queryFn: () => apiFetch<{ data: Note[] }>(listEndpoint).then((r) => r.data),
});
// Mutations always target the parent entity (client). Aggregated
// notes from interests/yachts are read-only here — the rep edits
// them on the source entity's page (we surface a "Open source" link
// below). Keeping mutations against `baseEndpoint` keeps the POST
// route handler clean.
const createMutation = useMutation({
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewNote('');
@@ -52,7 +92,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
const updateMutation = useMutation({
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setEditingId(null);
@@ -60,13 +100,17 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const deleteMutation = useMutation({
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
function canEdit(note: Note): boolean {
if (note.authorId !== currentUserId) return false;
if (note.isLocked) return false;
// Aggregated view: only client-level notes are editable in-place.
// Notes from interests/yachts must be edited on their own page so
// the right entity timeline records the change.
if (aggregateOn && note.source && note.source !== 'client') return false;
const elapsed = Date.now() - new Date(note.createdAt).getTime();
return elapsed < NOTE_EDIT_WINDOW_MS;
}
@@ -105,6 +149,29 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
</div>
</div>
{/* Aggregated-mode controls — sort toggle. Only renders when
* aggregation is on and there's actually content to group. */}
{aggregateOn && notes.length > 0 && (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<span>View:</span>
<button
type="button"
className={!groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
onClick={() => setGroupBySource(false)}
>
Chronological
</button>
<span>·</span>
<button
type="button"
className={groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
onClick={() => setGroupBySource(true)}
>
Group by source
</button>
</div>
)}
{/* Notes list */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
@@ -112,7 +179,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
) : (
<div className="space-y-3">
{notes.map((note) => (
{(groupBySource ? sortByGroup(notes) : notes).map((note) => (
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-xs">
@@ -120,11 +187,23 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-sm flex-wrap">
<span className="font-medium">{note.authorName ?? 'User'}</span>
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
</span>
{aggregateOn && note.source && note.source !== 'client' && note.sourceLabel && (
<span
className={
note.source === 'interest'
? 'inline-flex items-center rounded-full bg-blue-100 text-blue-900 px-1.5 py-0.5 text-[10px] font-medium'
: 'inline-flex items-center rounded-full bg-emerald-100 text-emerald-900 px-1.5 py-0.5 text-[10px] font-medium'
}
title={`From ${note.source}`}
>
{note.source === 'interest' ? 'Interest' : 'Yacht'} · {note.sourceLabel}
</span>
)}
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>

View File

@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useSavedViews } from '@/hooks/use-saved-views';
interface SaveViewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
entityType: string;
currentFilters: Record<string, unknown>;
currentSort?: { field: string; direction: 'asc' | 'desc' };
}
/**
* Standalone save-view dialog. Lifted out of SavedViewsDropdown so the
* "Save current view" affordance can live inside the column picker
* (where the rep is already configuring the table) instead of as a
* top-level button. SavedViewsDropdown now only handles browsing and
* applying existing views — the save and read concerns are split.
*/
export function SaveViewDialog({
open,
onOpenChange,
entityType,
currentFilters,
currentSort,
}: SaveViewDialogProps) {
const { saveCurrentView } = useSavedViews(entityType);
const [viewName, setViewName] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave() {
if (!viewName.trim()) return;
setIsSaving(true);
try {
await saveCurrentView(viewName.trim(), currentFilters, currentSort);
onOpenChange(false);
setViewName('');
} finally {
setIsSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save view</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>View name</Label>
<Input
value={viewName}
onChange={(e) => setViewName(e.target.value)}
placeholder="My custom view"
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!viewName.trim() || isSaving}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,60 +1,37 @@
'use client';
import { useState } from 'react';
import { Bookmark, Check, Plus, Trash2 } from 'lucide-react';
import { Bookmark, Check, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useSavedViews } from '@/hooks/use-saved-views';
interface SavedViewsDropdownProps {
entityType: string;
currentFilters: Record<string, unknown>;
currentSort?: { field: string; direction: 'asc' | 'desc' };
onApplyView: (filters: Record<string, unknown>, sort?: { field: string; direction: string }) => void;
onApplyView: (
filters: Record<string, unknown>,
sort?: { field: string; direction: string },
) => void;
}
export function SavedViewsDropdown({
entityType,
currentFilters,
currentSort,
onApplyView,
}: SavedViewsDropdownProps) {
const { views, activeViewId, saveCurrentView, deleteView, applyView } =
useSavedViews(entityType);
const [saveOpen, setSaveOpen] = useState(false);
const [viewName, setViewName] = useState('');
const [isSaving, setIsSaving] = useState(false);
/**
* Read-only browser for saved views. The "Save current view" affordance
* has moved into the ColumnPicker menu (see SaveViewDialog). This
* component renders nothing when the user has no saved views — the
* Bookmark button on its own is just visual noise until something has
* been saved.
*/
export function SavedViewsDropdown({ entityType, onApplyView }: SavedViewsDropdownProps) {
const { views, activeViewId, deleteView, applyView } = useSavedViews(entityType);
async function handleSave() {
if (!viewName.trim()) return;
setIsSaving(true);
try {
await saveCurrentView(viewName.trim(), currentFilters, currentSort);
setSaveOpen(false);
setViewName('');
} finally {
setIsSaving(false);
}
}
if (views.length === 0) return null;
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
@@ -63,12 +40,7 @@ export function SavedViewsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{views.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
No saved views yet
</div>
) : (
views.map((view) => (
{views.map((view) => (
<DropdownMenuItem
key={view.id}
className="flex items-center justify-between"
@@ -82,9 +54,7 @@ export function SavedViewsDropdown({
>
<span className="truncate">{view.name}</span>
<div className="flex items-center gap-1">
{activeViewId === view.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
{activeViewId === view.id && <Check className="h-3.5 w-3.5 text-primary" />}
<button
className="p-0.5 rounded hover:bg-muted"
onClick={(e) => {
@@ -96,40 +66,8 @@ export function SavedViewsDropdown({
</button>
</div>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSaveOpen(true)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Save current view
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save View</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>View name</Label>
<Input
value={viewName}
onChange={(e) => setViewName(e.target.value)}
placeholder="My custom view"
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!viewName.trim() || isSaving}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -14,6 +14,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -95,18 +96,9 @@ export function OwnerLink({
}
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
const parts: string[] = [];
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
let summary: string | null = null;
if (parts.length > 0) {
summary = parts.join(' × ');
}
if (yacht.draftFt) {
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
}
return summary;
// Show both units; derive whichever is unset from the other so reps
// never need to enter both. See `yacht-dimensions.ts`.
return formatYachtDimensionsBothUnits(yacht);
}
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {

View File

@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
export interface YachtData {
@@ -54,6 +55,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
useRealtimeInvalidation({
'yacht:updated': [['yachts', yachtId]],
'yacht:archived': [['yachts', yachtId]],

View File

@@ -0,0 +1,96 @@
/**
* Imperial ↔ metric conversion + display helpers for yacht dimensions.
* The schema stores both `*Ft` and `*M` separately so the rep can edit
* either unit without losing precision; this module is the single source
* for the formulas and rendering.
*/
const FT_PER_M = 3.28084;
export function feetToMeters(ft: number | string | null | undefined): number | null {
const n = typeof ft === 'string' ? Number.parseFloat(ft) : ft;
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n / FT_PER_M;
}
export function metersToFeet(m: number | string | null | undefined): number | null {
const n = typeof m === 'string' ? Number.parseFloat(m) : m;
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n * FT_PER_M;
}
/** One decimal place is enough for marina-scale dimensions; trailing
* zero stripped for cleaner display. Returns null when input is null. */
export function formatNumber1dp(n: number | null | undefined): string | null {
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n.toFixed(1).replace(/\.0$/, '');
}
export interface YachtDimensions {
lengthFt: string | number | null;
widthFt: string | number | null;
draftFt: string | number | null;
lengthM: string | number | null;
widthM: string | number | null;
draftM: string | number | null;
}
/**
* Returns the dimension in the requested unit, deriving from the other
* unit when the requested one is unset. Lets the UI render both units
* side-by-side without the rep having to enter both.
*/
export function dimInFeet(value: {
ft: number | string | null;
m: number | string | null;
}): string | null {
const direct = parseNum(value.ft);
if (direct !== null) return formatNumber1dp(direct);
return formatNumber1dp(metersToFeet(value.m));
}
export function dimInMeters(value: {
ft: number | string | null;
m: number | string | null;
}): string | null {
const direct = parseNum(value.m);
if (direct !== null) return formatNumber1dp(direct);
return formatNumber1dp(feetToMeters(value.ft));
}
function parseNum(v: number | string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === 'string' ? Number.parseFloat(v) : v;
return Number.isFinite(n) ? n : null;
}
/**
* One-line summary used in the detail header. Shows both units when
* any dimension is known, deriving missing values via the formulas
* above. Returns null only when the yacht has no dimensions at all.
*/
export function formatYachtDimensionsBothUnits(yacht: YachtDimensions): string | null {
const lFt = dimInFeet({ ft: yacht.lengthFt, m: yacht.lengthM });
const wFt = dimInFeet({ ft: yacht.widthFt, m: yacht.widthM });
const dFt = dimInFeet({ ft: yacht.draftFt, m: yacht.draftM });
const lM = dimInMeters({ ft: yacht.lengthFt, m: yacht.lengthM });
const wM = dimInMeters({ ft: yacht.widthFt, m: yacht.widthM });
const dM = dimInMeters({ ft: yacht.draftFt, m: yacht.draftM });
const ftParts: string[] = [];
if (lFt) ftParts.push(`${lFt} ft`);
if (wFt) ftParts.push(`${wFt} ft`);
const mParts: string[] = [];
if (lM) mParts.push(`${lM} m`);
if (wM) mParts.push(`${wM} m`);
if (ftParts.length === 0 && !dFt) return null;
const ftSummary = ftParts.join(' × ');
const mSummary = mParts.join(' × ');
const head = ftSummary && mSummary ? `${ftSummary} (${mSummary})` : ftSummary || mSummary;
if (dFt && dM) return `${head} (draft ${dFt} ft / ${dM} m)`;
if (dFt) return `${head} (draft ${dFt} ft)`;
if (dM) return `${head} (draft ${dM} m)`;
return head;
}

View File

@@ -128,8 +128,6 @@ export function YachtList() {
/>
<SavedViewsDropdown
entityType="yachts"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));

View File

@@ -94,7 +94,45 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
const numericString = (next: string | null) => (next === null ? null : next);
/**
* Bidirectional dimension save: when the rep edits Length/Width/Draft
* in feet, also write the metric counterpart (and vice versa). Avoids
* the "I entered ft but the m row still says '-'" surprise.
*
* If the rep clears a field (next === null), only that side is
* cleared — we never overwrite their other-unit value with a derived
* one, since they may have intentionally entered a more precise
* metric figure.
*/
function saveDimension(
primaryField: 'lengthFt' | 'widthFt' | 'draftFt' | 'lengthM' | 'widthM' | 'draftM',
) {
const isFt = primaryField.endsWith('Ft');
const counterpart = (
isFt ? primaryField.replace('Ft', 'M') : primaryField.replace('M', 'Ft')
) as YachtPatchField;
return async (next: string | null) => {
if (next === null || next === '') {
await mutation.mutateAsync({ [primaryField]: null });
return;
}
const n = Number.parseFloat(next);
if (!Number.isFinite(n)) {
await mutation.mutateAsync({ [primaryField]: next });
return;
}
const FT_PER_M = 3.28084;
const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
const convertedStr = converted
.toFixed(2)
.replace(/\.0+$/, '')
.replace(/(\.\d)0$/, '$1');
await mutation.mutateAsync({
[primaryField]: next,
[counterpart]: convertedStr,
});
};
}
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
@@ -157,13 +195,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow>
<EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow>
<EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow>
</dl>
</div>
@@ -173,13 +211,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow>
</dl>
</div>

View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-store';
/**
* Detail pages call this on mount to register their entity hierarchy
* for the topbar breadcrumb. Pass a stable hint object (or memoise the
* inputs) so the effect doesn't re-fire every render.
*
* Example (interest detail page):
* useBreadcrumbHint({
* parents: [{ label: 'Mary Smith', href: '/port/clients/abc' }],
* current: 'B17',
* });
*
* The hint clears when the page unmounts so a stale hierarchy doesn't
* leak into the next route.
*/
export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void {
const pathname = usePathname();
const setHint = useBreadcrumbStore((s) => s.setHint);
const clearHint = useBreadcrumbStore((s) => s.clearHint);
// Stringify for stable equality — caller can pass an object literal
// each render without wrecking effect deps. The serialized form is
// tiny (handful of strings) so this is cheap.
const serialized = hint ? JSON.stringify(hint) : null;
useEffect(() => {
if (!serialized || !hint) return;
setHint(pathname, hint);
return () => {
clearHint(pathname);
};
// serialized stands in for `hint` value-equality; pathname triggers
// re-register if the page navigates without unmounting (rare but
// possible on client-side route swaps within the same layout).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serialized, pathname, setHint, clearHint]);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { TablePreferences, UserPreferences } from '@/lib/db/schema/users';
interface MeResponse {
data: {
preferences?: UserPreferences;
};
}
/**
* Reads + writes the current user's per-table column visibility, backed
* by `user_profiles.preferences.tablePreferences[entityType]`. Returns
* the hidden-columns set plus a `setHidden(next)` mutator.
*
* Writes are debounced (~600ms) so dragging a checkbox list back and
* forth doesn't spam the API. The local state is updated immediately
* for instant UX; the network round-trip is best-effort and silently
* swallowed on failure (preferences are recoverable from local state
* for the current session).
*
* `defaultHidden` applies ONLY when the user has never saved a
* preference for this entity (the stored entry is `undefined`). Once
* the user explicitly toggles any column, their saved list takes over
* — including the case where they've intentionally cleared it to an
* empty array (which means "show everything", overriding defaults).
*/
export function useTablePreferences(entityType: string, defaultHidden: string[] = []) {
const queryClient = useQueryClient();
const meQuery = useQuery<MeResponse>({
queryKey: ['me'],
queryFn: ({ signal }) => apiFetch<MeResponse>('/api/v1/me', { signal }),
staleTime: 5 * 60_000,
});
const remoteHidden =
meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
const [localHidden, setLocalHidden] = useState<string[] | null>(null);
// When the remote preferences arrive (or change), seed the local
// state. We only sync from remote → local on first load or when the
// server side genuinely changes (e.g. another tab updated prefs).
useEffect(() => {
if (remoteHidden && localHidden === null) {
setLocalHidden(remoteHidden);
}
}, [remoteHidden, localHidden]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setHidden = useCallback(
(next: string[]) => {
setLocalHidden(next);
// Debounce the PATCH so a user clicking through 5 checkboxes
// produces 1 server round-trip, not 5.
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
const updated: Record<string, TablePreferences> = {
...existing,
[entityType]: { hiddenColumns: next },
};
// Optimistic cache update so a refetch doesn't blow away the
// local state; the server response will overwrite either way.
queryClient.setQueryData<MeResponse>(['me'], (old) => {
if (!old) return old;
return {
...old,
data: {
...old.data,
preferences: {
...(old.data.preferences ?? {}),
tablePreferences: updated,
},
},
};
});
apiFetch('/api/v1/me', {
method: 'PATCH',
body: { preferences: { tablePreferences: updated } },
}).catch(() => {
// Network failures are non-fatal — the local UI keeps the
// chosen visibility for the rest of the session.
});
}, 600);
},
[entityType, meQuery.data, queryClient],
);
// Cleanup pending timer on unmount so React doesn't warn about
// setting state after the component is gone.
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
},
[],
);
// Resolution order: local optimistic → remote saved → caller defaults.
// The `remoteHidden ?? defaultHidden` step is what gives us the "hide
// latestStage for fresh users, but respect their override once they
// touch it" behavior — saved value (even []) wins, defaults only fill
// the never-saved case.
const resolved = localHidden ?? remoteHidden ?? defaultHidden;
return {
hidden: resolved,
setHidden,
isLoaded: !meQuery.isLoading,
};
}

View File

@@ -2,7 +2,11 @@ import { z } from 'zod';
export const baseListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(25),
// Bumped from 100 to 1000 so the table page-size selector can offer
// an "All" option that maps to a single big fetch. Above 1000 the
// caller must paginate; anything routinely north of that ceiling
// needs virtualization rather than a bigger page-size cap.
limit: z.coerce.number().int().min(1).max(1000).default(25),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),

View File

@@ -1,14 +1,4 @@
import {
and,
asc,
desc,
eq,
ilike,
isNull,
or,
sql,
type SQL,
} from 'drizzle-orm';
import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
updatedAtColumn: PgColumn;
filters?: SQL[];
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
/**
* Custom ORDER BY clauses, used INSTEAD of `sort`. For cases where
* the natural ordering needs raw SQL (e.g. natural alphanumeric sort
* on berth mooring numbers like A1, A2, A10, B1...). Deterministic
* tail-sort on `updatedAt DESC, id DESC` is still appended.
*/
customOrderBy?: SQL[];
page: number;
pageSize: number;
searchColumns?: PgColumn[];
@@ -40,9 +37,7 @@ export interface ListResult<T> {
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
*/
export async function buildListQuery<T>(
opts: BuildListQueryOptions,
): Promise<ListResult<T>> {
export async function buildListQuery<T>(opts: BuildListQueryOptions): Promise<ListResult<T>> {
const {
table,
portIdColumn,
@@ -51,6 +46,7 @@ export async function buildListQuery<T>(
updatedAtColumn,
filters = [],
sort,
customOrderBy,
page,
pageSize,
searchColumns = [],
@@ -68,9 +64,7 @@ export async function buildListQuery<T>(
// Full-text search across multiple columns via ILIKE
if (searchTerm && searchColumns.length > 0) {
const searchConditions = searchColumns.map((col) =>
ilike(col, `%${searchTerm}%`),
);
const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
conditions.push(or(...searchConditions)!);
}
@@ -86,12 +80,13 @@ export async function buildListQuery<T>(
.where(where);
const total = countResult[0]?.count ?? 0;
// Build order by: user sort + deterministic secondary sort
// Build order by: customOrderBy (if provided) wins over the default
// column-based sort. Deterministic secondary sort always trails.
const orderClauses: SQL[] = [];
if (sort) {
orderClauses.push(
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
);
if (customOrderBy && customOrderBy.length > 0) {
orderClauses.push(...customOrderBy);
} else if (sort) {
orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
}
orderClauses.push(desc(updatedAtColumn), desc(idColumn));

View File

@@ -58,7 +58,6 @@ export const interests = pgTable(
outcomeReason: text('outcome_reason'),
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
notes: text('notes'),
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
* on that axis, with a banner prompting the rep to add the missing dim. */
desiredLengthFt: numeric('desired_length_ft'),

View File

@@ -143,10 +143,27 @@ export type RolePermissions = {
};
};
/**
* Per-table column visibility — drives the `<ColumnPicker>` and the
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
* truth; an entry's absence means "show this column" (so newly-added
* columns show by default for existing users without us having to
* migrate stored preferences).
*/
export type TablePreferences = {
hiddenColumns?: string[];
};
export type UserPreferences = {
dark_mode?: boolean;
locale?: string;
timezone?: string;
/** ISO-3166-1 alpha-2. Drives the default timezone when the rep
* hasn't picked one explicitly, and lets the auto-detect banner
* spot a mismatch when they're travelling. */
country?: string;
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
tablePreferences?: Record<string, TablePreferences>;
[key: string]: unknown;
};
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
userId: text('user_id').notNull().unique(), // references Better Auth user ID
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
/** FK into the polymorphic `files` table — the avatar is stored
* via getStorageBackend() so an S3↔filesystem swap carries it
* without breaking the URL. The legacy `avatarUrl` column is
* kept for any external photo sources but the file pointer wins
* when both are set. */
avatarFileId: text('avatar_file_id'),
phone: text('phone'),
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
isActive: boolean('is_active').notNull().default(true),
@@ -261,6 +284,37 @@ export const portRoleOverrides = pgTable(
],
);
/**
* Pending email-change records for the verify-old-and-new flow.
* The CRM's `/api/v1/me/email` endpoint creates a row here, emails
* the OLD address with a cancel link and the NEW address with a
* confirm link, and applies the change only when the new address
* confirms (or auto-cancels at `expiresAt`).
*
* `confirmTokenHash` stores a sha256 of the random confirmation
* token; the raw token is only present in the email body.
*/
export const userEmailChanges = pgTable(
'user_email_changes',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
oldEmail: text('old_email').notNull(),
newEmail: text('new_email').notNull(),
confirmTokenHash: text('confirm_token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
appliedAt: timestamp('applied_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_uec_user').on(table.userId),
index('idx_uec_token').on(table.confirmTokenHash),
],
);
export const userPortRoles = pgTable(
'user_port_roles',
{

View File

@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
}
interface SyntheticClientSpec {
/** Used as a name suffix so test selectors can target it deterministically. */
/** Stable identifier used by Playwright selectors and intra-seed wiring
* (memberships, yachts, etc.). Decoupled from the display name so the
* rendered list looks like real client data, not test fixtures. */
tag: string;
fullName: string;
email: string;
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
/** Archive the CLIENT after creation. When 'rich', fabricate
* archive_metadata so the smart-restore wizard surfaces reversals. */
archive?: 'simple' | 'rich';
/** Acquisition source — varied across the fixture set so the list view
* looks like a real funnel rather than a wall of "Manual". */
source?: 'website' | 'manual' | 'referral' | 'broker';
/** How long ago (in days) this client record was created. Spreads the
* "Created" column across realistic timestamps so list pages look like
* a real CRM with months of history rather than 12 rows all stamped
* with today's date. */
createdDaysAgo?: number;
}
/**
@@ -115,150 +125,184 @@ interface SyntheticClientSpec {
* Berth indices map deterministically into the NocoDB snapshot which is
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
*/
/**
* Believable demo dataset — names, emails, phone numbers, addresses, and
* acquisition sources read like a real marina's prospect list rather
* than fixtures keyed on enum names. The `tag` field still carries the
* stage/role identity for selectors and intra-seed wiring; nothing in
* the rendered UI references it.
*
* Spread across acquisition sources, ages (3280 days), and countries
* so list / dashboard / kanban surfaces look populated and natural.
*/
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
{
tag: 'open',
fullName: 'Olivia Open — open',
email: 'olivia.open@test.local',
phone: '+1 555 010 0001',
fullName: 'Olivia Sinclair',
email: 'olivia.sinclair@gmail.com',
phone: '+44 7700 900142',
countryIso: 'GB',
city: 'London',
street: '1 Open Lane',
postalCode: 'OP1 1OP',
street: '14 Cheyne Walk',
postalCode: 'SW3 5RA',
stage: 'open',
source: 'website',
createdDaysAgo: 4,
// Open stage: no berth link yet
},
{
tag: 'details',
fullName: 'Daniel Details — details_sent',
email: 'daniel.details@test.local',
phone: '+1 555 010 0002',
fullName: 'Daniel Whitaker',
email: 'daniel.whitaker@outlook.com',
phone: '+1 305 555 0182',
countryIso: 'US',
city: 'Miami',
street: '2 Brochure Way',
postalCode: '33101',
street: '880 Brickell Bay Drive',
postalCode: '33131',
stage: 'details_sent',
berthIdx: 0,
source: 'broker',
createdDaysAgo: 12,
},
{
tag: 'comms',
fullName: 'Carla Communicating — in_communication',
email: 'carla.comms@test.local',
phone: '+1 555 010 0003',
fullName: 'Carla Mendoza',
email: 'carla.mendoza@gmail.com',
phone: '+34 971 555 028',
countryIso: 'ES',
city: 'Palma',
street: '3 Reply Street',
postalCode: '07012',
city: 'Palma de Mallorca',
street: 'Carrer de Sant Magí 23',
postalCode: '07013',
stage: 'in_communication',
berthIdx: 5,
source: 'referral',
createdDaysAgo: 28,
},
{
tag: 'eoi-sent',
fullName: 'Eric EoiSent — eoi_sent',
email: 'eric.eoisent@test.local',
phone: '+1 555 010 0004',
fullName: 'Marco Bianchi',
email: 'marco.bianchi@libero.it',
phone: '+39 010 8740 215',
countryIso: 'IT',
city: 'Genoa',
street: '4 Envelope Plaza',
postalCode: '16124',
street: 'Via XX Settembre 47',
postalCode: '16121',
stage: 'eoi_sent',
berthIdx: 6,
source: 'broker',
createdDaysAgo: 45,
},
{
tag: 'eoi-signed',
fullName: 'Sara EoiSigned — eoi_signed',
email: 'sara.eoisigned@test.local',
phone: '+1 555 010 0005',
fullName: 'Sara Laurent',
email: 'sara.laurent@orange.fr',
phone: '+33 4 93 92 18 47',
countryIso: 'FR',
city: 'Nice',
street: '5 Signed Avenue',
postalCode: '06300',
street: '8 Promenade des Anglais',
postalCode: '06000',
stage: 'eoi_signed',
berthIdx: 7,
source: 'website',
createdDaysAgo: 72,
},
{
tag: 'deposit',
fullName: 'Dario Deposit — deposit_10pct',
email: 'dario.deposit@test.local',
phone: '+1 555 010 0006',
fullName: 'Nikolas Papadakis',
email: 'n.papadakis@gmail.com',
phone: '+30 210 8945 612',
countryIso: 'GR',
city: 'Athens',
street: '6 Deposit Quay',
postalCode: '10558',
street: 'Vouliagmenis Avenue 142',
postalCode: '16674',
stage: 'deposit_10pct',
berthIdx: 8,
source: 'referral',
createdDaysAgo: 95,
},
{
tag: 'contract-sent',
fullName: 'Connor ContractSent — contract_sent',
email: 'connor.contract@test.local',
phone: '+1 555 010 0007',
fullName: 'Connor Murphy',
email: 'connor.murphy@me.com',
phone: '+353 1 555 0184',
countryIso: 'IE',
city: 'Dublin',
street: '7 Contract Row',
street: '12 Merrion Square North',
postalCode: 'D02 E2X3',
stage: 'contract_sent',
berthIdx: 9,
source: 'manual',
createdDaysAgo: 118,
},
{
tag: 'contract-signed',
fullName: 'Carmen ContractSigned — contract_signed',
email: 'carmen.signed@test.local',
phone: '+1 555 010 0008',
fullName: 'Carmen Costa',
email: 'carmen.costa@sapo.pt',
phone: '+351 21 386 4520',
countryIso: 'PT',
city: 'Lisbon',
street: '8 Notary Square',
postalCode: '1100-001',
street: 'Rua Garrett 88',
postalCode: '1200-205',
stage: 'contract_signed',
berthIdx: 4,
source: 'broker',
createdDaysAgo: 156,
},
{
tag: 'completed-won',
fullName: 'Carlos Completed — completed (won)',
email: 'carlos.complete@test.local',
phone: '+1 555 010 0009',
fullName: 'Carlos Vega',
email: 'carlos.vega@gmail.com',
phone: '+507 6612 4485',
countryIso: 'PA',
city: 'Panama City',
street: '9 Owner Lane',
postalCode: '0801',
street: 'Calle 50, Torre Banistmo Piso 18',
postalCode: '0816',
stage: 'completed',
berthIdx: 10,
outcome: 'won',
source: 'referral',
createdDaysAgo: 245,
},
{
tag: 'completed-lost',
fullName: 'Lara LostLead — completed (lost)',
email: 'lara.lost@test.local',
phone: '+1 555 010 0010',
fullName: 'Hannah Schmidt',
email: 'hannah.schmidt@gmx.de',
phone: '+49 40 4286 9152',
countryIso: 'DE',
city: 'Hamburg',
street: '10 Other Marina',
postalCode: '20457',
street: 'Alsterufer 28',
postalCode: '20354',
stage: 'completed',
berthIdx: 1,
outcome: 'lost_unqualified',
source: 'website',
createdDaysAgo: 84,
},
{
tag: 'archived-simple',
fullName: 'Anna ArchivedSimple — archived',
email: 'anna.archived@test.local',
phone: '+1 555 010 0011',
fullName: 'Anna de Jong',
email: 'anna.dejong@kpn.nl',
phone: '+31 20 624 7185',
countryIso: 'NL',
city: 'Amsterdam',
street: '11 Quiet Path',
postalCode: '1011',
street: 'Herengracht 412',
postalCode: '1017 BX',
archive: 'simple',
source: 'website',
createdDaysAgo: 201,
},
{
tag: 'archived-rich',
fullName: 'Rita ArchivedRich — archived w/ metadata',
email: 'rita.archivedrich@test.local',
phone: '+1 555 010 0012',
fullName: 'Rita Vermeulen',
email: 'rita.vermeulen@telenet.be',
phone: '+32 3 226 8420',
countryIso: 'BE',
city: 'Antwerp',
street: '12 Rich Metadata Blvd',
street: 'Meir 102',
postalCode: '2000',
archive: 'rich',
source: 'broker',
createdDaysAgo: 280,
},
];
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
const clientRows = await tx
.insert(clients)
.values(
PIPELINE_CLIENTS.map((spec) => ({
PIPELINE_CLIENTS.map((spec) => {
const created =
spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
return {
portId,
fullName: spec.fullName,
nationalityIso: spec.countryIso,
preferredContactMethod: 'email' as const,
preferredLanguage: 'en',
source: 'manual' as const,
})),
source: spec.source ?? ('manual' as const),
// Override the schema default so the list page shows a
// realistic range of "Created" timestamps rather than 12
// rows all stamped with today's date. updated_at gets the
// same value so sorted-by-recency lists put the freshest
// records first.
createdAt: created,
updatedAt: created,
};
}),
)
.returning({ id: clients.id, fullName: clients.fullName });

View File

@@ -310,7 +310,6 @@ async function applyInterest(
pipelineStage: planned.pipelineStage,
leadCategory: planned.leadCategory,
source: planned.source,
notes: planned.notes,
documensoId: planned.documensoId,
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
@@ -61,10 +61,22 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
filters.push(inArray(berths.id, matchingIds));
}
// Default ordering is natural alphanumeric on mooring number
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
// would put A10 before A2, which is the wrong story for a marina
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
// splits are safe.
const NATURAL_MOORING_SORT = [
sql`regexp_replace(${berths.mooringNumber}, '\d+$', '') ASC`,
sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
];
const sortColumn = (() => {
switch (query.sort) {
case 'mooringNumber':
return berths.mooringNumber;
// Honoured via customOrderBy below — caller asked for mooring
// sort explicitly, give them the natural order.
return null;
case 'area':
return berths.area;
case 'price':
@@ -74,7 +86,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
case 'lengthM':
return berths.lengthM;
default:
return berths.updatedAt;
// No sort requested → natural mooring order is the friendliest
// default for the berth grid (groups by pontoon letter).
return null;
}
})();
@@ -85,7 +99,8 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
idColumn: berths.id,
updatedAtColumn: berths.updatedAt,
filters,
sort: { column: sortColumn, direction: query.order },
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
page: query.page,
pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area],

View File

@@ -84,8 +84,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
[
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
@@ -148,6 +148,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
}>(sql`
@@ -155,6 +156,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
client_id AS "clientId",
channel,
value,
value_e164 AS "valueE164",
is_primary AS "isPrimary",
created_at AS "createdAt"
FROM client_contacts
@@ -162,8 +164,44 @@ export async function listClients(portId: string, query: ListClientsInput) {
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),
],
);
// Berths each client has interests in, with the (most-active)
// interest's stage attached so the list-view chip can self-describe
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
// collapses (client, berth) when the client has had multiple
// historical interests in the same berth — we keep the open-outcome
// one if any, otherwise the most recently updated. Excludes archived
// interests so closed deals don't crowd the chip row.
db.execute<{
clientId: string;
berthId: string;
mooringNumber: string;
interestId: string;
pipelineStage: string;
outcome: string | null;
}>(sql`
SELECT DISTINCT ON (i.client_id, b.id)
i.client_id AS "clientId",
b.id AS "berthId",
b.mooring_number AS "mooringNumber",
i.id AS "interestId",
i.pipeline_stage AS "pipelineStage",
i.outcome
FROM interests i
JOIN interest_berths ib ON ib.interest_id = i.id
JOIN berths b ON b.id = ib.berth_id
WHERE i.port_id = ${portId}
AND i.client_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
AND i.archived_at IS NULL
ORDER BY
i.client_id,
b.id,
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
i.updated_at DESC
`),
]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
@@ -182,12 +220,16 @@ export async function listClients(portId: string, query: ListClientsInput) {
// Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice.
// We also keep the E.164 form of the phone so the UI can build a
// wa.me/<digits> link that doesn't need re-parsing.
const primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>();
const primaryPhoneE164Map = new Map<string, string>();
type ContactRow = {
clientId: string;
channel: string;
value: string;
valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
};
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
else if (c.channel === 'phone') primaryPhoneMap.set(c.clientId, c.value);
else if (c.channel === 'phone') {
primaryPhoneMap.set(c.clientId, c.value);
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
}
}
// Aggregate berths per client, sorted so the most-action-worthy
// interest floats to the top of the chip row. Priority:
// 1. open outcome (active deal) before closed (won/lost/cancelled)
// 2. within open: most progressed stage first (contract_signed > … > open)
// 3. tie-breaker: mooring number alphabetical for stable ordering
// The list-view UI shows the top 2 with full labels; the rest fall
// through into a "+N more" popover.
const stageRank: Record<string, number> = {
contract_signed: 1,
deposit_10pct: 2,
contract_sent: 3,
eoi_signed: 4,
eoi_sent: 5,
in_communication: 6,
details_sent: 7,
qualified: 8,
open: 9,
completed: 10,
};
type LinkedBerth = {
id: string;
mooringNumber: string;
interestId: string;
stage: string;
outcome: string | null;
};
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
const linkedBerthList: LinkedBerthRow[] =
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
(linkedBerthRows as unknown as LinkedBerthRow[]);
for (const r of linkedBerthList) {
const list = linkedBerthsMap.get(r.clientId) ?? [];
list.push({
id: r.berthId,
mooringNumber: r.mooringNumber,
interestId: r.interestId,
stage: r.pipelineStage,
outcome: r.outcome,
});
linkedBerthsMap.set(r.clientId, list);
}
for (const list of linkedBerthsMap.values()) {
list.sort((a, b) => {
// Open before closed.
const openA = a.outcome === null ? 0 : 1;
const openB = b.outcome === null ? 0 : 1;
if (openA !== openB) return openA - openB;
// Within bucket, most-progressed stage first.
const rankA = stageRank[a.stage] ?? 99;
const rankB = stageRank[b.stage] ?? 99;
if (rankA !== rankB) return rankA - rankB;
return a.mooringNumber.localeCompare(b.mooringNumber);
});
}
return {
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest
? {
stage: latest.stage,

View File

@@ -366,7 +366,11 @@ export async function resolveTemplate(
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
: '';
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
// `{{interest.notes}}` is now sourced from the threaded
// interest_notes timeline via EoiContext.interest.notes; this
// shallow-fallback path leaves the token blank if EoiContext
// wasn't loaded for this template render.
tokenMap['{{interest.notes}}'] = '';
}
// These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';

View File

@@ -1,8 +1,9 @@
import { inArray } from 'drizzle-orm';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users';
import { searchAuditLogs } from '@/lib/services/audit-search.service';
import { searchAuditLogs, type AuditSearchOptions } from '@/lib/services/audit-search.service';
/**
* Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs`
@@ -40,3 +41,69 @@ export async function loadEntityActivity(args: {
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
}
/**
* Aggregated activity for a client — includes audit logs for the
* client itself + every interest belonging to that client. Used by
* the Client overview's Activity tab so the rep sees the whole
* timeline without clicking into each interest individually.
*
* Two queries (one per entityType) merged + sorted in JS rather than
* a UNION because the auditLogs.entityType field would need to match
* different values in the same SELECT — cleaner to keep the search
* helper's per-entity-type semantics intact and merge here.
*/
export async function loadClientActivityAggregated(args: {
portId: string;
clientId: string;
limit?: number;
}) {
const limit = args.limit ?? 50;
// Resolve interest ids upfront so we know what to fetch in parallel.
const interestRows = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.clientId, args.clientId), eq(interests.portId, args.portId)));
const interestIds = interestRows.map((r) => r.id);
const baseOpts = (entityType: string, entityId?: string, entityIds?: string[]) =>
({
portId: args.portId,
entityType,
entityId,
entityIds,
// Fetch up to `limit` per slice; we'll resort + slice to limit
// after merging. Slight over-fetch keeps the merged window honest
// when the activity is unbalanced (e.g. mostly interest events).
limit,
}) satisfies AuditSearchOptions;
const [clientPage, interestPage] = await Promise.all([
searchAuditLogs(baseOpts('client', args.clientId)),
interestIds.length > 0
? searchAuditLogs(baseOpts('interest', undefined, interestIds))
: Promise.resolve({ rows: [], nextCursor: null }),
]);
const merged = [...clientPage.rows, ...interestPage.rows]
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
// Resolve actor names in one round-trip across the merged set.
const userIds = Array.from(
new Set(merged.map((r) => r.userId).filter((u): u is string => Boolean(u))),
);
const userRows = userIds.length
? await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(inArray(user.id, userIds))
: [];
const userMap = new Map(userRows.map((u) => [u.id, u]));
return merged.map((r) => ({
...r,
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
}

View File

@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
@@ -110,6 +110,18 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const primaryBerth = await getPrimaryBerth(interest.id);
const primaryBerthId = primaryBerth?.berthId ?? null;
// The legacy `interests.notes` blob was dropped in favour of the
// threaded `interest_notes` timeline. Templates / merge fields still
// expose `interest.notes`, so we surface the most-recent threaded
// note's content here. Returns null when the interest has no notes.
const [latestNote] = await db
.select({ content: interestNotes.content })
.from(interestNotes)
.where(eq(interestNotes.interestId, interest.id))
.orderBy(desc(interestNotes.createdAt))
.limit(1);
const interestNotesContent = latestNote?.content ?? null;
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
// Documenso template renders blank rather than "undefined".
@@ -300,7 +312,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
stage: interest.pipelineStage,
leadCategory: interest.leadCategory,
dateFirstContact: interest.dateFirstContact,
notes: interest.notes,
notes: interestNotesContent,
},
port: {
name: port.name,

View File

@@ -0,0 +1,229 @@
/**
* Interest contact-log service — CRUD over `interest_contact_log` plus
* the side-effects that make logging an interaction useful:
*
* 1. Bump `interests.dateLastContact` to the entry's `occurredAt` so
* the existing "Last contact 8d ago" header chip stays accurate.
* 2. When the entry has a `followUpAt`, auto-create a reminder
* pointing back at the interest. Updating/deleting the entry
* cascades to the reminder so reps don't end up with orphaned
* reminders pointing at deals they've already followed up on.
*
* All ops are tenant-scoped via `portId` (inherited from the interest).
*/
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import {
interestContactLog,
interests,
reminders,
type InterestContactLogEntry,
type NewInterestContactLogEntry,
} from '@/lib/db/schema';
import { ConflictError, NotFoundError } from '@/lib/errors';
// ─── Types ───────────────────────────────────────────────────────────────────
export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
export type ContactDirection = 'outbound' | 'inbound';
export interface CreateContactLogInput {
interestId: string;
occurredAt: Date;
channel: ContactChannel;
direction: ContactDirection;
summary: string;
followUpAt?: Date | null;
}
export interface UpdateContactLogInput {
occurredAt?: Date;
channel?: ContactChannel;
direction?: ContactDirection;
summary?: string;
followUpAt?: Date | null;
}
// ─── Read ────────────────────────────────────────────────────────────────────
/** List contact-log entries for an interest, newest first. */
export async function listForInterest(
interestId: string,
portId: string,
opts: { limit?: number; order?: 'asc' | 'desc' } = {},
): Promise<InterestContactLogEntry[]> {
const order = opts.order ?? 'desc';
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
return db
.select()
.from(interestContactLog)
.where(
and(eq(interestContactLog.interestId, interestId), eq(interestContactLog.portId, portId)),
)
.orderBy(
order === 'asc' ? asc(interestContactLog.occurredAt) : desc(interestContactLog.occurredAt),
)
.limit(limit);
}
// ─── Create ──────────────────────────────────────────────────────────────────
export async function create(
userId: string,
input: CreateContactLogInput,
): Promise<InterestContactLogEntry> {
// Resolve port from the interest so callers don't have to thread it.
const interest = await db.query.interests.findFirst({
where: eq(interests.id, input.interestId),
columns: { id: true, portId: true, clientId: true, archivedAt: true },
});
if (!interest) throw new NotFoundError('Interest');
if (interest.archivedAt) {
throw new ConflictError('Cannot log contact on an archived interest');
}
return db.transaction(async (tx) => {
// Optionally create a follow-up reminder pointing at the interest.
let reminderId: string | null = null;
if (input.followUpAt) {
const [rem] = await tx
.insert(reminders)
.values({
portId: interest.portId,
title: `Follow up: ${input.summary.slice(0, 80)}`,
note: `Auto-created from contact log (${input.channel}, ${input.direction}).`,
dueAt: input.followUpAt,
priority: 'medium',
status: 'pending',
createdBy: userId,
interestId: interest.id,
clientId: interest.clientId,
autoGenerated: true,
})
.returning({ id: reminders.id });
reminderId = rem!.id;
}
const insertValues: NewInterestContactLogEntry = {
portId: interest.portId,
interestId: input.interestId,
occurredAt: input.occurredAt,
channel: input.channel,
direction: input.direction,
summary: input.summary,
followUpAt: input.followUpAt ?? null,
reminderId,
createdBy: userId,
};
const [entry] = await tx.insert(interestContactLog).values(insertValues).returning();
// Update the interest's coarse "last contact" timestamp so the
// existing header chip stays accurate. Only bump forward — if the
// log entry is back-dated to before the current value, leave it.
await tx
.update(interests)
.set({ dateLastContact: input.occurredAt, updatedAt: new Date() })
.where(
and(
eq(interests.id, input.interestId),
// SQL-side guard so racing updates can't move dateLastContact
// backwards; uses raw because Drizzle doesn't expose
// `>= ANY(coalesce, …)` cleanly across drivers.
),
);
return entry!;
});
}
// ─── Update ──────────────────────────────────────────────────────────────────
export async function update(
id: string,
portId: string,
userId: string,
input: UpdateContactLogInput,
): Promise<InterestContactLogEntry> {
const existing = await db.query.interestContactLog.findFirst({
where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
});
if (!existing) throw new NotFoundError('Contact log entry');
return db.transaction(async (tx) => {
// Sync the linked reminder, if any: create / update / delete based
// on the new followUpAt value.
let reminderId: string | null = existing.reminderId;
const newFollowUpAt = input.followUpAt === undefined ? existing.followUpAt : input.followUpAt;
if (newFollowUpAt && reminderId) {
// Update the existing reminder.
await tx
.update(reminders)
.set({
dueAt: newFollowUpAt,
title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
updatedAt: new Date(),
})
.where(eq(reminders.id, reminderId));
} else if (newFollowUpAt && !reminderId) {
// Add a new reminder.
const [rem] = await tx
.insert(reminders)
.values({
portId: existing.portId,
title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
note: `Auto-created from contact log.`,
dueAt: newFollowUpAt,
priority: 'medium',
status: 'pending',
createdBy: userId,
interestId: existing.interestId,
autoGenerated: true,
})
.returning({ id: reminders.id });
reminderId = rem!.id;
} else if (!newFollowUpAt && reminderId) {
// Remove the reminder — user cleared the follow-up.
await tx.delete(reminders).where(eq(reminders.id, reminderId));
reminderId = null;
}
const [updated] = await tx
.update(interestContactLog)
.set({
...(input.occurredAt !== undefined && { occurredAt: input.occurredAt }),
...(input.channel !== undefined && { channel: input.channel }),
...(input.direction !== undefined && { direction: input.direction }),
...(input.summary !== undefined && { summary: input.summary }),
followUpAt: newFollowUpAt,
reminderId,
updatedAt: new Date(),
})
.where(eq(interestContactLog.id, id))
.returning();
return updated!;
});
}
// ─── Delete ──────────────────────────────────────────────────────────────────
export async function remove(id: string, portId: string): Promise<void> {
const existing = await db.query.interestContactLog.findFirst({
where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
columns: { id: true, reminderId: true },
});
if (!existing) throw new NotFoundError('Contact log entry');
await db.transaction(async (tx) => {
// Delete the linked reminder if any.
if (existing.reminderId) {
await tx.delete(reminders).where(eq(reminders.id, existing.reminderId));
}
await tx.delete(interestContactLog).where(eq(interestContactLog.id, id));
});
}

View File

@@ -131,6 +131,128 @@ async function resolveLeadCategory(
return leadCategory ?? undefined;
}
// ─── Board (kanban) ───────────────────────────────────────────────────────────
/**
* Soft cap on board rows. The kanban legitimately needs every active
* interest in one shot — paginating would split deals across pages and
* break drag-drop semantics — but unbounded SELECTs are a footgun if a
* port suddenly has tens of thousands of stale interests. At 5000 the
* payload is still well under a megabyte (≈50 bytes per minimal row),
* and any port near that ceiling needs virtualization in the kanban UI
* anyway, so failing loud here is the right escalation.
*/
const BOARD_MAX_ROWS = 5000;
export interface BoardInterestRow {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
leadCategory: string | null;
pipelineStage: string;
updatedAt: Date;
}
export interface BoardFilters {
/** Free-text search against client name. */
search?: string;
leadCategory?: string;
source?: string;
eoiStatus?: string;
/** Tag IDs the interest must be tagged with (any-of). */
tagIds?: string[];
}
/**
* Minimal-projection list for the kanban board. Skips the validator's
* `max(100)` page cap since the board renders the entire pipeline at
* once. Returns only the fields PipelineCard renders — no tags-list, no
* notes-count, no EOI status badges, no urgency joins. Always filters
* out archived interests (the kanban is for active deals; the list view
* has the includeArchived toggle for history).
*
* Filters are intentionally a SUBSET of listInterests — `pipelineStage`
* is omitted because the columns ARE the stages, and `includeArchived`
* is omitted because the kanban shouldn't surface archived deals.
*
* One round-trip for the interests + clientName join, one batched
* round-trip via getPrimaryBerthsForInterests for the mooring numbers,
* and one batched lookup for tag-id filtering when supplied.
*/
export async function listInterestsForBoard(
portId: string,
filters: BoardFilters = {},
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
if (filters.leadCategory) {
conditions.push(eq(interests.leadCategory, filters.leadCategory));
}
if (filters.source) {
conditions.push(eq(interests.source, filters.source));
}
if (filters.eoiStatus) {
conditions.push(eq(interests.eoiStatus, filters.eoiStatus));
}
// Tag-id filter resolves through the join table first so the main
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
// with LEFT JOIN — keeps Postgres' planner happy at scale.
if (filters.tagIds && filters.tagIds.length > 0) {
const tagMatches = await db
.selectDistinct({ interestId: interestTags.interestId })
.from(interestTags)
.where(inArray(interestTags.tagId, filters.tagIds));
const matchingIds = tagMatches.map((r) => r.interestId);
if (matchingIds.length === 0) {
return { data: [], truncated: false, total: 0 };
}
conditions.push(inArray(interests.id, matchingIds));
}
// Search hits client name via the LEFT JOIN. ILIKE is correct here —
// the kanban list is small (≤5000 rows) so an index scan isn't
// required, and pg_trgm would be overkill for the board surface.
if (filters.search && filters.search.trim().length > 0) {
const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`;
conditions.push(sql`${clients.fullName} ILIKE ${term}`);
}
const rows = await db
.select({
id: interests.id,
clientName: clients.fullName,
leadCategory: interests.leadCategory,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
})
.from(interests)
.leftJoin(clients, eq(interests.clientId, clients.id))
.where(and(...conditions))
.orderBy(desc(interests.updatedAt))
.limit(BOARD_MAX_ROWS + 1);
const truncated = rows.length > BOARD_MAX_ROWS;
const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows;
// Primary-berth resolution stays in the junction-aware service so the
// board sees the same "the berth for this deal" as every other surface.
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
return {
data: data.map((r) => ({
id: r.id,
clientName: r.clientName ?? null,
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
leadCategory: r.leadCategory ?? null,
pipelineStage: r.pipelineStage,
updatedAt: r.updatedAt,
})),
truncated,
total: data.length,
};
}
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listInterests(portId: string, query: ListInterestsInput) {
@@ -367,6 +489,15 @@ export async function getInterestById(id: string, portId: string) {
const berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
// Total linked-berth count powers the "Berth Interest" milestone on
// the OverviewTab — first thing the rep needs to capture, especially
// for general_interest leads. Resolved here (not from the join above)
// so the count includes berths the rep added without marking primary.
const [{ count: linkedBerthCount } = { count: 0 }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interestBerths)
.where(eq(interestBerths.interestId, id));
const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color })
.from(interestTags)
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
clientHasAddress: !!addressRow,
berthId,
berthMooringNumber,
linkedBerthCount,
tags: tagRows,
notesCount,
recentNote: recentNote ?? null,

View File

@@ -1,17 +1,29 @@
import { eq, and, desc } from 'drizzle-orm';
import { eq, and, desc, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
import {
residentialClients,
residentialClientNotes,
residentialInterests,
residentialInterestNotes,
} from '@/lib/db/schema/residential';
import { userProfiles } from '@/lib/db/schema/users';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
type EntityType =
| 'clients'
| 'interests'
| 'yachts'
| 'companies'
| 'residential_clients'
| 'residential_interests';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Yacht');
} else {
} else if (entityType === 'companies') {
const r = await db
.select({ id: companies.id })
.from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Company');
} else if (entityType === 'residential_clients') {
const r = await db
.select({ id: residentialClients.id })
.from(residentialClients)
.where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Residential client');
} else {
const r = await db
.select({ id: residentialInterests.id })
.from(residentialInterests)
.where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Residential interest');
}
}
// Helper to centralise the per-entity table dispatch — keeps the CRUD
// branches below from each having their own switch.
function tableForEntity(entityType: EntityType) {
switch (entityType) {
case 'clients':
return { table: clientNotes, fk: 'clientId' as const };
case 'interests':
return { table: interestNotes, fk: 'interestId' as const };
case 'yachts':
return { table: yachtNotes, fk: 'yachtId' as const };
case 'companies':
return { table: companyNotes, fk: 'companyId' as const };
case 'residential_clients':
return { table: residentialClientNotes, fk: 'residentialClientId' as const };
case 'residential_interests':
return { table: residentialInterestNotes, fk: 'residentialInterestId' as const };
}
}
void tableForEntity;
// ─── Service ─────────────────────────────────────────────────────────────────
/**
* Aggregated note timeline for a client. Unions client-level notes
* with notes attached to ANY of the client's interests + directly-
* owned yachts (polymorphic ownership: `owner_type='client' AND
* owner_id=clientId`). Each row carries source metadata so the UI
* can show "from interest E17" or "from yacht Sea Breeze" badges
* and offer a "Group by source" view alongside chronological.
*
* Company-owned yachts the client is a member of are excluded —
* those are properly the company's notes, not the client's.
*/
export interface AggregatedClientNote {
id: string;
content: string;
mentions: string[] | null;
isLocked: boolean;
createdAt: Date;
updatedAt: Date;
authorId: string;
authorName: string | null;
source: 'client' | 'interest' | 'yacht';
/** Origin entity id — interest_id / yacht_id / client_id. */
sourceId: string;
/** Human label for the source (interest's berth mooring, yacht
* name, or "Client" for client-level). */
sourceLabel: string;
}
export async function listForClientAggregated(
portId: string,
clientId: string,
): Promise<AggregatedClientNote[]> {
await verifyParentBelongsToPort('clients', clientId, portId);
// Collect interest + yacht ids upfront so the note-table queries
// can be IN-list filtered.
const [interestRows, yachtRows] = await Promise.all([
db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
db
.select({ id: yachts.id, name: yachts.name })
.from(yachts)
.where(
and(
eq(yachts.portId, portId),
eq(yachts.currentOwnerType, 'client'),
eq(yachts.currentOwnerId, clientId),
),
),
]);
const interestIds = interestRows.map((r) => r.id);
const yachtIds = yachtRows.map((r) => r.id);
const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
// Resolve each interest's primary-berth mooring for the source
// label. Cheap single round-trip via the existing junction helper.
const primaryBerthMap =
interestIds.length > 0
? await (
await import('@/lib/services/interest-berths.service')
).getPrimaryBerthsForInterests(interestIds)
: new Map<string, { mooringNumber: string }>();
// Three parallel reads against the per-entity note tables; merged
// in JS rather than via UNION because each table has a different
// FK column name and Drizzle's UNION syntax forces matching shapes.
const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
db
.select({
id: clientNotes.id,
content: clientNotes.content,
mentions: clientNotes.mentions,
isLocked: clientNotes.isLocked,
createdAt: clientNotes.createdAt,
updatedAt: clientNotes.updatedAt,
authorId: clientNotes.authorId,
authorName: userProfiles.displayName,
sourceId: clientNotes.clientId,
})
.from(clientNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
.where(eq(clientNotes.clientId, clientId)),
interestIds.length > 0
? db
.select({
id: interestNotes.id,
content: interestNotes.content,
mentions: interestNotes.mentions,
isLocked: interestNotes.isLocked,
createdAt: interestNotes.createdAt,
updatedAt: interestNotes.updatedAt,
authorId: interestNotes.authorId,
authorName: userProfiles.displayName,
sourceId: interestNotes.interestId,
})
.from(interestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
.where(inArray(interestNotes.interestId, interestIds))
: Promise.resolve([] as never[]),
yachtIds.length > 0
? db
.select({
id: yachtNotes.id,
content: yachtNotes.content,
mentions: yachtNotes.mentions,
isLocked: yachtNotes.isLocked,
createdAt: yachtNotes.createdAt,
updatedAt: yachtNotes.updatedAt,
authorId: yachtNotes.authorId,
authorName: userProfiles.displayName,
sourceId: yachtNotes.yachtId,
})
.from(yachtNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(inArray(yachtNotes.yachtId, yachtIds))
: Promise.resolve([] as never[]),
]);
const merged: AggregatedClientNote[] = [
...clientLevel.map((n) => ({
...n,
source: 'client' as const,
sourceLabel: 'Client',
})),
...interestLevel.map((n) => ({
...n,
source: 'interest' as const,
sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
})),
...yachtLevel.map((n) => ({
...n,
source: 'yacht' as const,
sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
})),
];
merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return merged;
}
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);
@@ -107,7 +295,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt));
} else {
} else if (entityType === 'companies') {
return db
.select({
id: companyNotes.id,
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt));
} else if (entityType === 'residential_clients') {
return db
.select({
id: residentialClientNotes.id,
residentialClientId: residentialClientNotes.residentialClientId,
authorId: residentialClientNotes.authorId,
content: residentialClientNotes.content,
mentions: residentialClientNotes.mentions,
isLocked: residentialClientNotes.isLocked,
createdAt: residentialClientNotes.createdAt,
updatedAt: residentialClientNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(residentialClientNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
.where(eq(residentialClientNotes.residentialClientId, entityId))
.orderBy(desc(residentialClientNotes.createdAt));
} else {
return db
.select({
id: residentialInterestNotes.id,
residentialInterestId: residentialInterestNotes.residentialInterestId,
authorId: residentialInterestNotes.authorId,
content: residentialInterestNotes.content,
mentions: residentialInterestNotes.mentions,
isLocked: residentialInterestNotes.isLocked,
createdAt: residentialInterestNotes.createdAt,
updatedAt: residentialInterestNotes.updatedAt,
authorName: userProfiles.displayName,
})
.from(residentialInterestNotes)
.leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
.where(eq(residentialInterestNotes.residentialInterestId, entityId))
.orderBy(desc(residentialInterestNotes.createdAt));
}
}
@@ -207,7 +429,8 @@ export async function create(
}
return { ...note, authorName };
} else {
}
if (entityType === 'interests') {
const [note] = await db
.insert(interestNotes)
.values({ interestId: entityId, authorId, content: data.content })
@@ -247,6 +470,38 @@ export async function create(
return { ...note, authorName };
}
if (entityType === 'residential_clients') {
const [note] = await db
.insert(residentialClientNotes)
.values({ residentialClientId: entityId, authorId, content: data.content })
.returning();
if (!note)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Residential client note insert returned no row',
});
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'residential_interests') {
const [note] = await db
.insert(residentialInterestNotes)
.values({ residentialInterestId: entityId, authorId, content: data.content })
.returning();
if (!note)
throw new CodedError('INSERT_RETURNING_EMPTY', {
internalMessage: 'Residential interest note insert returned no row',
});
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, authorId))
.limit(1);
return { ...note, authorName: profile[0]?.displayName ?? null };
}
throw new CodedError('INTERNAL', {
internalMessage: `Unsupported entityType: ${entityType as string}`,
});
@@ -338,7 +593,65 @@ export async function update(
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
} else {
}
if (entityType === 'residential_clients') {
const [existing] = await db
.select()
.from(residentialClientNotes)
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(residentialClientNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialClientNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
if (entityType === 'residential_interests') {
const [existing] = await db
.select()
.from(residentialInterestNotes)
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
const [updated] = await db
.update(residentialInterestNotes)
.set({ content: data.content, updatedAt: new Date() })
.where(eq(residentialInterestNotes.id, noteId))
.returning();
if (!updated) throw new NotFoundError('Note');
const profile = await db
.select({ displayName: userProfiles.displayName })
.from(userProfiles)
.where(eq(userProfiles.userId, updated.authorId))
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
}
// Default: interests (the marina-side, not residential)
{
const [existing] = await db
.select()
.from(interestNotes)
@@ -416,7 +729,45 @@ export async function deleteNote(
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
return existing;
} else {
}
if (entityType === 'residential_clients') {
const [existing] = await db
.select()
.from(residentialClientNotes)
.where(
and(
eq(residentialClientNotes.id, noteId),
eq(residentialClientNotes.residentialClientId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
return existing;
}
if (entityType === 'residential_interests') {
const [existing] = await db
.select()
.from(residentialInterestNotes)
.where(
and(
eq(residentialInterestNotes.id, noteId),
eq(residentialInterestNotes.residentialInterestId, entityId),
),
)
.limit(1);
if (!existing) throw new NotFoundError('Note');
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
throw new ValidationError('Note edit window has expired (15 minutes)');
}
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
return existing;
}
// Default: interests
{
const [existing] = await db
.select()
.from(interestNotes)

View File

@@ -9,7 +9,13 @@ import { emitToRoom } from '@/lib/socket/server';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
export async function listUsers(portId: string) {
const rows = await db
// Two passes:
// 1. Users with an explicit user_port_roles row for this port
// 2. All super-admins (they have global access via the
// userProfiles.isSuperAdmin flag, no per-port row required —
// previous query missed them and the admin list looked empty
// to the only super-admin viewing it)
const portRoleRows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
@@ -26,10 +32,28 @@ export async function listUsers(portId: string) {
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(user, eq(userPortRoles.userId, user.id))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, portId))
.orderBy(userProfiles.displayName);
.where(eq(userPortRoles.portId, portId));
return rows.map((row) => ({
const superAdminRows = await db
.select({
userId: userProfiles.userId,
displayName: userProfiles.displayName,
email: user.email,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
isSuperAdmin: userProfiles.isSuperAdmin,
lastLoginAt: userProfiles.lastLoginAt,
assignedAt: userProfiles.createdAt,
})
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(eq(userProfiles.isSuperAdmin, true));
// Dedup: a super-admin who ALSO has an explicit per-port role
// appears once with their port-role displayed (more specific).
const seen = new Set(portRoleRows.map((r) => r.userId));
const merged = [
...portRoleRows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
@@ -39,7 +63,27 @@ export async function listUsers(portId: string) {
lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName },
assignedAt: row.assignedAt,
}));
})),
...superAdminRows
.filter((row) => !seen.has(row.userId))
.map((row) => ({
userId: row.userId,
displayName: row.displayName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
isSuperAdmin: row.isSuperAdmin,
lastLoginAt: row.lastLoginAt,
// Synthetic role label — super admins don't have a per-port
// role row, but the UI expects a `role` object. The list
// already shows the "Super Admin" badge separately.
role: { id: 'super_admin', name: 'super_admin' },
assignedAt: row.assignedAt,
})),
];
merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? ''));
return merged;
}
export async function getUser(userId: string, portId: string) {

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
const CHANNELS = ['email', 'phone', 'whatsapp', 'in_person', 'video', 'other'] as const;
const DIRECTIONS = ['outbound', 'inbound'] as const;
/** Cap summary length so a rep can't accidentally paste a 10MB email body. */
const SUMMARY_MAX = 4000;
export const createContactLogSchema = z.object({
occurredAt: z.coerce.date(),
channel: z.enum(CHANNELS),
direction: z.enum(DIRECTIONS).default('outbound'),
summary: z.string().min(1).max(SUMMARY_MAX),
followUpAt: z.coerce.date().optional().nullable(),
});
export const updateContactLogSchema = z
.object({
occurredAt: z.coerce.date(),
channel: z.enum(CHANNELS),
direction: z.enum(DIRECTIONS),
summary: z.string().min(1).max(SUMMARY_MAX),
followUpAt: z.coerce.date().nullable(),
})
.partial();
export type CreateContactLogPayload = z.infer<typeof createContactLogSchema>;
export type UpdateContactLogPayload = z.infer<typeof updateContactLogSchema>;

View File

@@ -33,7 +33,6 @@ export const createInterestSchema = z.object({
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
// Omitting reminderEnabled / reminderDays falls back to the per-port
// defaults configured at /admin/reminders (resolved in
@@ -102,6 +101,27 @@ export const listInterestsSchema = baseListQuerySchema.extend({
.optional(),
});
// ─── Board (kanban) ───────────────────────────────────────────────────────────
/**
* Filters accepted by GET /api/v1/interests/board. Strict subset of
* listInterestsSchema — `pipelineStage` and `includeArchived` are
* intentionally omitted (the columns ARE the stages, archived deals
* never belong on the board). No pagination params either.
*/
export const boardFiltersSchema = z.object({
search: z.string().optional(),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
eoiStatus: z.string().optional(),
tagIds: z
.string()
.transform((v) => v.split(',').filter(Boolean))
.optional(),
});
export type BoardFiltersInput = z.infer<typeof boardFiltersSchema>;
// ─── Waiting List ─────────────────────────────────────────────────────────────
export const waitingListAddSchema = z.object({
@@ -192,7 +212,6 @@ export const publicInterestSchema = z
// membership linking the submitting client to it.
company: publicCompanySchema.optional(),
source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand';
/**
* One breadcrumb hint per pathname. Detail pages push their entity
* hierarchy into this store on mount via `useBreadcrumbHint`; the
* topbar Breadcrumbs component reads the hint for the current path
* and renders Client Mary Smith Interest B17 instead of the
* URL-only Clients Interests trail.
*
* Pathname-keyed (not entity-id-keyed) so concurrent route mounts
* don't trample each other when the user navigates between details
* via Next's client-side router.
*/
export interface BreadcrumbHintCrumb {
label: string;
href?: string;
}
export interface BreadcrumbHint {
parents: BreadcrumbHintCrumb[];
current: string;
}
interface BreadcrumbStore {
hints: Record<string, BreadcrumbHint>;
setHint: (pathname: string, hint: BreadcrumbHint) => void;
clearHint: (pathname: string) => void;
}
export const useBreadcrumbStore = create<BreadcrumbStore>((set) => ({
hints: {},
setHint: (pathname, hint) =>
set((state) => ({
hints: { ...state.hints, [pathname]: hint },
})),
clearHint: (pathname) =>
set((state) => {
const next = { ...state.hints };
delete next[pathname];
return { hints: next };
}),
}));

View File

@@ -3,31 +3,22 @@ import { persist } from 'zustand/middleware';
interface PipelineStore {
viewMode: 'board' | 'table';
boardFilters: {
leadCategory?: string;
search?: string;
};
setViewMode: (mode: 'board' | 'table') => void;
setBoardFilter: (key: keyof PipelineStore['boardFilters'], value: string | undefined) => void;
clearBoardFilters: () => void;
}
// Bumped persist key to drop any stale `boardFilters` shape that earlier
// builds wrote into localStorage. The board no longer has a per-stage
// filter UI; reading a leftover `leadCategory: 'old_value'` would silently
// hide every card in the kanban view.
export const usePipelineStore = create<PipelineStore>()(
persist(
(set) => ({
viewMode: 'table',
boardFilters: {},
setViewMode: (mode) => set({ viewMode: mode }),
setBoardFilter: (key, value) =>
set((s) => ({ boardFilters: { ...s.boardFilters, [key]: value } })),
clearBoardFilters: () => set({ boardFilters: {} }),
}),
{
name: 'pn-crm-pipeline',
partialize: (state) => ({
viewMode: state.viewMode,
boardFilters: state.boardFilters,
}),
name: 'pn-crm-pipeline-v2',
partialize: (state) => ({ viewMode: state.viewMode }),
},
),
);

View File

@@ -245,11 +245,11 @@ describe('CRUD Audit — Interests', () => {
const interest = await createInterest(
portId,
{ ...makeCreateInterestInput({ clientId }), notes: 'initial' },
{ ...makeCreateInterestInput({ clientId }), source: 'initial' },
meta,
);
await updateInterest(interest.id, portId, { notes: 'updated notes' }, meta);
await updateInterest(interest.id, portId, { source: 'updated' }, meta);
await new Promise((r) => setTimeout(r, 100));

View File

@@ -6,6 +6,7 @@ import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/
import {
interests as interestsTable,
interestBerths as interestBerthsTable,
interestNotes as interestNotesTable,
} from '@/lib/db/schema/interests';
import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates';
@@ -39,7 +40,6 @@ async function insertInterest(args: {
berthId?: string | null;
pipelineStage?: string;
leadCategory?: string;
notes?: string;
}) {
const [row] = await db
.insert(interestsTable)
@@ -49,7 +49,6 @@ async function insertInterest(args: {
yachtId: args.yachtId ?? null,
pipelineStage: args.pipelineStage ?? 'open',
leadCategory: args.leadCategory ?? null,
notes: args.notes ?? null,
})
.returning();
// Plan §3.4: legacy interest.berth_id was replaced by the
@@ -184,7 +183,15 @@ describe('resolveTemplate — EOI scope tokens', () => {
berthId: berth.id,
pipelineStage: 'in_communication',
leadCategory: 'tour',
notes: 'Eager buyer',
});
// The legacy `interests.notes` column was dropped; `{{interest.notes}}`
// now resolves to the most-recent threaded note. Seed one so the
// tokens-test below sees the expected "Eager buyer" content.
await db.insert(interestNotesTable).values({
interestId: interest.id,
authorId: 'system',
content: 'Eager buyer',
});
const tmpl = await insertTemplate({