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 { BerthDetail } from '@/components/berths/berth-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface BerthPageProps { interface BerthPageProps {
params: Promise<{ portSlug: string; berthId: string }>; params: Promise<{ portSlug: string; berthId: string }>;
@@ -6,5 +7,10 @@ interface BerthPageProps {
export default async function BerthPage({ params }: BerthPageProps) { export default async function BerthPage({ params }: BerthPageProps) {
const { berthId } = await params; 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 { ClientDetail } from '@/components/clients/client-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; 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 session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; 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 { CompanyDetail } from '@/components/companies/company-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; 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 session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; 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 { DocumentDetail } from '@/components/documents/document-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface PageProps { interface PageProps {
params: Promise<{ portSlug: string; id: string }>; params: Promise<{ portSlug: string; id: string }>;
@@ -6,5 +7,10 @@ interface PageProps {
export default async function DocumentDetailPage({ params }: PageProps) { export default async function DocumentDetailPage({ params }: PageProps) {
const { portSlug, id } = await params; 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 { ExpenseDetail } from '@/components/expenses/expense-detail';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog'; import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from '@/components/expenses/expense-columns'; import type { ExpenseRow } from '@/components/expenses/expense-columns';
@@ -22,6 +23,7 @@ export default function ExpenseDetailPage() {
return ( return (
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<TrackEntityView type="expense" id={params.id} />
<ExpenseDetail <ExpenseDetail
expenseId={params.id} expenseId={params.id}
onEdit={() => setEditOpen(true)} onEdit={() => setEditOpen(true)}
@@ -29,11 +31,7 @@ export default function ExpenseDetailPage() {
/> />
{data?.data && ( {data?.data && (
<ExpenseFormDialog <ExpenseFormDialog open={editOpen} onOpenChange={setEditOpen} expense={data.data} />
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
)} )}
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import { InterestDetail } from '@/components/interests/interest-detail'; import { InterestDetail } from '@/components/interests/interest-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; 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 session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; 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 { use } from 'react';
import { InvoiceDetail } from '@/components/invoices/invoice-detail'; import { InvoiceDetail } from '@/components/invoices/invoice-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface InvoiceDetailPageProps { interface InvoiceDetailPageProps {
params: Promise<{ portSlug: string; id: string }>; params: Promise<{ portSlug: string; id: string }>;
@@ -9,6 +10,7 @@ export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
const { id } = use(params); const { id } = use(params);
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<TrackEntityView type="invoice" id={id} />
<InvoiceDetail invoiceId={id} /> <InvoiceDetail invoiceId={id} />
</div> </div>
); );

View File

@@ -1,4 +1,5 @@
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail'; import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +7,10 @@ interface Props {
export default async function ResidentialClientDetailPage({ params }: Props) { export default async function ResidentialClientDetailPage({ params }: Props) {
const { id } = await params; 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 { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -6,5 +7,10 @@ interface Props {
export default async function ResidentialInterestDetailPage({ params }: Props) { export default async function ResidentialInterestDetailPage({ params }: Props) {
const { id } = await params; 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 { YachtDetail } from '@/components/yachts/yacht-detail';
import { TrackEntityView } from '@/components/search/track-entity-view';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { headers } from 'next/headers'; 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 session = await auth.api.getSession({ headers: await headers() });
const currentUserId = session?.user?.id; 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, yachtId,
source: 'website', source: 'website',
pipelineStage: 'open', pipelineStage: 'open',
notes: data.notes,
}) })
.returning(); .returning();

View File

@@ -4,11 +4,14 @@ import { eq, and } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers'; import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients'; 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'; import { errorResponse, NotFoundError } from '@/lib/errors';
export const GET = withAuth( export const GET = withAuth(
withPermission('clients', 'view', async (_req, ctx, params) => { withPermission('clients', 'view', async (req, ctx, params) => {
try { try {
const id = params.id; const id = params.id;
if (!id) throw new NotFoundError('client'); 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))) .where(and(eq(clients.id, id), eq(clients.portId, ctx.portId)))
.limit(1); .limit(1);
if (exists.length === 0) throw new NotFoundError('client'); 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, portId: ctx.portId,
entityType: 'client', entityType: 'client',
entityId: id, entityId: id,

View File

@@ -9,11 +9,18 @@ import { createNoteSchema } from '@/lib/validators/notes';
import * as notesService from '@/lib/services/notes.service'; import * as notesService from '@/lib/services/notes.service';
export const GET = withAuth( export const GET = withAuth(
withPermission('clients', 'view', async (_req, ctx, params) => { withPermission('clients', 'view', async (req, ctx, params) => {
try { try {
const clientId = params.id; const clientId = params.id;
if (!clientId) throw new NotFoundError('Client'); 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 }); return NextResponse.json({ data: notes });
} catch (error) { } catch (error) {
return errorResponse(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(), dark_mode: z.boolean().optional(),
locale: z.string().optional(), locale: z.string().optional(),
timezone: 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() .strict()
.optional(), .optional(),
}); });
export const GET = withAuth(async (_req, ctx: AuthContext) => { 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({ return NextResponse.json({
data: { data: {
userId: ctx.userId, userId: ctx.userId,
@@ -44,6 +66,11 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
permissions: ctx.permissions, permissions: ctx.permissions,
isSuperAdmin: ctx.isSuperAdmin, isSuperAdmin: ctx.isSuperAdmin,
user: ctx.user, 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 // .passthrough(); the merge prunes them so legacy bloat doesn't
// accumulate forever, and a future schema regression that tries // accumulate forever, and a future schema regression that tries
// to ship arbitrary keys still gets dropped here at write time. // 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 existing = (profile.preferences as Record<string, unknown>) ?? {};
const merged = Object.fromEntries( const merged = Object.fromEntries(
Object.entries({ ...existing, ...body.preferences }).filter(([k]) => Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>

View File

@@ -12,20 +12,100 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { mooringLetterDot } from './mooring-letter-tone';
export type BerthRow = { export type BerthRow = {
id: string; id: string;
mooringNumber: string; mooringNumber: string;
area: string | null; area: string | null;
status: string; status: string;
// Dimensions (both units; row falls back when one is null)
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null; lengthM: string | null;
widthM: 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; price: string | null;
priceCurrency: string; priceCurrency: string;
weeklyRateHighUsd: string | null;
weeklyRateLowUsd: string | null;
dailyRateHighUsd: string | null;
dailyRateLowUsd: string | null;
pricingValidUntil: string | null;
// Tenure
tenureType: string; tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
tags: Array<{ id: string; name: string; color: string }>; 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 }) { function StatusBadge({ status }: { status: string }) {
const variants: Record<string, string> = { const variants: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200', 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>[] = [ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'mooringNumber', accessorKey: 'mooringNumber',
header: 'Mooring #', 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', accessorKey: 'area',
header: 'Area', header: 'Area',
cell: ({ row }) => row.original.area ?? '-', cell: ({ row }) => row.original.area ?? '-',
}, },
{ {
id: 'status',
accessorKey: 'status', accessorKey: 'status',
header: 'Status', header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />, cell: ({ row }) => <StatusBadge status={row.original.status} />,
}, },
{
id: 'sidePontoon',
header: 'Side / Pontoon',
enableSorting: false,
cell: ({ row }) => row.original.sidePontoon ?? '-',
},
{ {
id: 'dimensions', id: 'dimensions',
header: 'Dimensions', header: 'Dimensions',
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const { lengthM, widthM } = row.original; const { lengthM, widthM, draftM, widthIsMinimum } = row.original;
if (!lengthM && !widthM) return '-'; 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', accessorKey: 'price',
header: 'Price', header: 'Price',
cell: ({ row }) => formatMoney(row.original.price, row.original.priceCurrency) ?? '-',
},
{
id: 'rates',
header: 'Rates (USD)',
enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const { price, priceCurrency } = row.original; const { dailyRateLowUsd, dailyRateHighUsd, weeklyRateLowUsd, weeklyRateHighUsd } =
if (!price) return '-'; row.original;
return new Intl.NumberFormat('en-US', { const daily =
style: 'currency', dailyRateLowUsd && dailyRateHighUsd
currency: priceCurrency || 'USD', ? `${dailyRateLowUsd}${dailyRateHighUsd}/d`
maximumFractionDigits: 0, : dailyRateLowUsd
}).format(Number(price)); ? `${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', accessorKey: 'tenureType',
header: 'Tenure', header: 'Tenure',
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'), cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),

View File

@@ -1,25 +1,43 @@
'use client'; 'use client';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { Anchor } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table'; import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar'; import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { ColumnPicker } from '@/components/shared/column-picker';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { BerthCard } from './berth-card'; 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 { berthFilterDefinitions } from './berth-filters';
import { Anchor } from 'lucide-react'; import { mooringLetterTone } from './mooring-letter-tone';
export function BerthList() { export function BerthList() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const { data, pagination, isLoading, sort, setSort, filters, setFilter, clearFilters, setPage } = const {
usePaginatedQuery<BerthRow>({ data,
pagination,
isLoading,
sort,
setSort,
filters,
setFilter,
clearFilters,
setPage,
setPageSize,
} = usePaginatedQuery<BerthRow>({
queryKey: ['berths'], queryKey: ['berths'],
endpoint: '/api/v1/berths', endpoint: '/api/v1/berths',
filterDefinitions: berthFilterDefinitions, filterDefinitions: berthFilterDefinitions,
@@ -30,6 +48,10 @@ export function BerthList() {
'berth:statusChanged': [['berths']], 'berth:statusChanged': [['berths']],
}); });
// Persisted column visibility — same pattern as ClientList / InterestList.
const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -46,21 +68,21 @@ export function BerthList() {
onChange={setFilter} onChange={setFilter}
onClear={clearFilters} onClear={clearFilters}
/> />
<div className="ml-auto"> <div className="ml-auto flex items-center gap-2">
<SavedViewsDropdown <SavedViewsDropdown
entityType="berths" entityType="berths"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => { onApplyView={(savedFilters, _savedSort) => {
clearFilters(); clearFilters();
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value)); Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
}} }}
/> />
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div> </div>
</div> </div>
<DataTable<BerthRow> <DataTable<BerthRow>
columns={berthColumns} columns={berthColumns}
columnVisibility={columnVisibility}
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
pagination={{ pagination={{
@@ -69,11 +91,15 @@ export function BerthList() {
total: pagination.total, total: pagination.total,
totalPages: pagination.totalPages, totalPages: pagination.totalPages,
}} }}
onPaginationChange={(page) => setPage(page)} onPaginationChange={(page, pageSize) => {
setPage(page);
setPageSize(pageSize);
}}
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
getRowId={(row) => row.id} getRowId={(row) => row.id}
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)} onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
cardRender={(row) => <BerthCard berth={row.original} />} cardRender={(row) => <BerthCard berth={row.original} />}
emptyState={ emptyState={
<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 Link from 'next/link';
import { format } from 'date-fns'; 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 type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,7 +13,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { getCountryName } from '@/lib/i18n/countries'; 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 { export interface ClientRow {
id: string; id: string;
@@ -24,24 +28,58 @@ export interface ClientRow {
createdAt: string; createdAt: string;
primaryEmail?: string | null; primaryEmail?: string | null;
primaryPhone?: string | null; primaryPhone?: string | null;
/** E.164 (digits + leading +) — used to build wa.me / tel: links. */
primaryPhoneE164?: string | null;
yachtCount?: number; yachtCount?: number;
companyCount?: number; companyCount?: number;
interestCount?: number; interestCount?: number;
latestInterest?: { stage: string; mooringNumber: string | null } | null; 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 }>; tags?: Array<{ id: string; name: string; color: string }>;
} }
const STAGE_LABELS: Record<string, string> = { /**
open: 'Open', * Picker manifest — drives the `<ColumnPicker>` dropdown next to the
qualified: 'Qualified', * filter bar. Order here is the order shown in the menu. `alwaysVisible`
eoi_sent: 'EOI sent', * marks columns the user can't hide (otherwise the table is unusable).
eoi_signed: 'EOI signed', *
deposit: 'Deposit', * "Latest stage" used to be a default-on column, but each Berths chip
contract: 'Contract', * now carries its own per-interest stage (color dot + label), so the
signed: 'Signed', * standalone column was duplicating the same information. Kept in the
closed_won: 'Won', * picker for users who want a single coarse "what's their most recent
closed_lost: 'Lost', * 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> = { const SOURCE_LABELS: Record<string, string> = {
website: 'Website', website: 'Website',
@@ -83,7 +121,17 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const value = row.original.primaryEmail; const value = row.original.primaryEmail;
if (!value) return <span className="text-muted-foreground">-</span>; 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, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const value = row.original.primaryPhone; const value = row.original.primaryPhone;
const e164 = row.original.primaryPhoneE164;
if (!value) return <span className="text-muted-foreground">-</span>; 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', id: 'latestStage',
header: 'Latest stage', header: 'Latest stage',
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const latest = row.original.latestInterest; const latest = row.original.latestInterest;
if (!latest) return <span className="text-muted-foreground">-</span>; if (!latest) return <span className="text-muted-foreground">-</span>;
const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
return ( return (
<div className="flex items-center gap-2 text-sm"> <Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-xs capitalize"> {stageLabel(latest.stage)}
{stageLabel}
</Badge> </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 { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs'; import { getClientTabs } from '@/components/clients/client-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { Address } from '@/components/shared/addresses-editor'; import type { Address } from '@/components/shared/addresses-editor';
@@ -91,6 +92,10 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [titleForChrome, setChrome]);
// Topbar breadcrumb hint: replaces "Clients <uuid>" with
// "Clients Mary Smith". Hint clears on unmount.
useBreadcrumbHint(data ? { parents: [], current: data.fullName } : null);
useRealtimeInvalidation({ useRealtimeInvalidation({
'client:updated': [['clients', clientId]], 'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]], 'client:archived': [['clients', clientId]],

View File

@@ -16,11 +16,12 @@ export const clientFilterDefinitions: FilterDefinition[] = [
{ label: 'Manual', value: 'manual' }, { label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' }, { label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' }, { label: 'Broker', value: 'broker' },
{ label: 'Other', value: 'other' },
], ],
}, },
{ {
key: 'nationality', key: 'nationality',
label: 'Nationality', label: 'Country',
type: 'text', type: 'text',
placeholder: 'Filter by nationality...', placeholder: 'Filter by nationality...',
}, },

View File

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

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table'; import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar'; import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state'; import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { TableSkeleton } from '@/components/shared/loading-skeleton';
@@ -30,9 +31,16 @@ import {
import { ClientForm } from '@/components/clients/client-form'; import { ClientForm } from '@/components/clients/client-form';
import { clientFilterDefinitions } from '@/components/clients/client-filters'; import { clientFilterDefinitions } from '@/components/clients/client-filters';
import { ClientCard } from '@/components/clients/client-card'; 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 { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
export function ClientList() { export function ClientList() {
@@ -49,6 +57,7 @@ export function ClientList() {
const [tagChoice, setTagChoice] = useState<string[]>([]); const [tagChoice, setTagChoice] = useState<string[]>([]);
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]); const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]); const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]);
const [saveViewOpen, setSaveViewOpen] = useState(false);
const { can } = usePermissions(); const { can } = usePermissions();
const canHardDelete = can('admin', 'permanently_delete_clients'); const canHardDelete = can('admin', 'permanently_delete_clients');
@@ -119,6 +128,13 @@ export function ClientList() {
onArchive: (client) => setArchiveClient(client), 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
@@ -144,20 +160,33 @@ export function ClientList() {
/> />
<SavedViewsDropdown <SavedViewsDropdown
entityType="clients" entityType="clients"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => { onApplyView={(savedFilters, _savedSort) => {
clearFilters(); clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}} }}
/> />
<ColumnPicker
columns={CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</div> </div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="clients"
currentFilters={filters}
currentSort={sort}
/>
{isLoading ? ( {isLoading ? (
<TableSkeleton /> <TableSkeleton />
) : ( ) : (
<DataTable <DataTable
columns={columns} columns={columns}
columnVisibility={columnVisibility}
data={data} data={data}
pagination={pagination} pagination={pagination}
onPaginationChange={(p, ps) => { 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 { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineCountryField } from '@/components/shared/inline-country-field'; import { InlineCountryField } from '@/components/shared/inline-country-field';
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field'; import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
@@ -35,6 +36,7 @@ const SOURCE_OPTIONS = [
{ value: 'manual', label: 'Manual' }, { value: 'manual', label: 'Manual' },
{ value: 'referral', label: 'Referral' }, { value: 'referral', label: 'Referral' },
{ value: 'broker', label: 'Broker' }, { value: 'broker', label: 'Broker' },
{ value: 'other', label: 'Other' },
]; ];
const CONTACT_METHOD_OPTIONS = [ const CONTACT_METHOD_OPTIONS = [
@@ -150,18 +152,36 @@ function OverviewTab({
<EditableRow label="Full Name"> <EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} /> <InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow> </EditableRow>
<EditableRow label="Nationality"> <EditableRow label="Country">
<InlineCountryField <InlineCountryField
value={client.nationalityIso ?? null} value={client.nationalityIso ?? null}
onSave={async (iso) => { 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>
<EditableRow label="Timezone"> <EditableRow label="Timezone">
<InlineTimezoneField <InlineTimezoneField
value={client.timezone} value={
client.timezone ??
(client.nationalityIso
? primaryTimezoneFor(client.nationalityIso as CountryCode)
: null)
}
countryHint={(client.nationalityIso as CountryCode | null) ?? null} countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => { onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz }); await mutation.mutateAsync({ timezone: tz });
@@ -267,7 +287,14 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
id: 'notes', id: 'notes',
label: 'Notes', label: 'Notes',
badge: client.noteCount, badge: client.noteCount,
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />, content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
aggregate
/>
),
}, },
{ {
id: 'files', id: 'files',

View File

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

View File

@@ -69,6 +69,7 @@ export function PortalInviteButton({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8"
onClick={() => { onClick={() => {
reset(); reset();
setOpen(true); 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 { CompanyDetailHeader } from '@/components/companies/company-detail-header';
import { getCompanyTabs } from '@/components/companies/company-tabs'; import { getCompanyTabs } from '@/components/companies/company-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { Address } from '@/components/shared/addresses-editor'; import type { Address } from '@/components/shared/addresses-editor';
@@ -54,6 +55,8 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [titleForChrome, setChrome]);
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
useRealtimeInvalidation({ useRealtimeInvalidation({
'company:updated': [['companies', companyId]], 'company:updated': [['companies', companyId]],
'company:archived': [['companies', companyId]], 'company:archived': [['companies', companyId]],

View File

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

View File

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

View File

@@ -2,9 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -47,37 +48,40 @@ export function InlineStagePicker({
}: InlineStagePickerProps) { }: InlineStagePickerProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [pendingStage, setPendingStage] = useState<string | null>(null); 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 { can } = usePermissions();
const canOverride = can('interests', 'override_stage'); const canOverride = can('interests', 'override_stage');
const stage = safeStage(currentStage); const stage = safeStage(currentStage);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (next: PipelineStage) => { mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
// 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.
const needsOverride = !canTransitionStage(stage, next); const needsOverride = !canTransitionStage(stage, next);
const useOverride = needsOverride && canOverride; const useOverride = needsOverride && canOverride;
return apiFetch(`/api/v1/interests/${interestId}/stage`, { return apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH', method: 'PATCH',
body: { body: {
pipelineStage: next, pipelineStage: next,
reason: reason.trim() || (useOverride ? 'Manual override (inline)' : undefined), reason: reason ?? (useOverride ? 'Manual override (inline)' : undefined),
override: useOverride || undefined, override: useOverride || undefined,
}, },
}); });
}, },
onSuccess: (_data, next) => { onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }); queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] }); queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false); setOpen(false);
setReason(''); setOverrideTarget(null);
setOverrideReason('');
setPendingStage(null); setPendingStage(null);
toast.success(`Stage moved to ${STAGE_LABELS[next]}`); toast.success(`Stage moved to ${STAGE_LABELS[vars.next]}`);
}, },
onError: (err) => { onError: (err) => {
setPendingStage(null); setPendingStage(null);
@@ -90,15 +94,40 @@ export function InlineStagePicker({
setOpen(false); setOpen(false);
return; 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); 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 ( return (
<Popover <Popover
open={open} open={open}
onOpenChange={(o) => { onOpenChange={(o) => {
if (!mutation.isPending) setOpen(o); if (mutation.isPending) return;
setOpen(o);
if (!o) cancelOverride();
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -125,38 +154,98 @@ export function InlineStagePicker({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
align="start" align="start"
className="w-64 p-0" className="w-72 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()} 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 <Textarea
value={reason} id="stage-override-reason"
onChange={(e) => setReason(e.target.value)} value={overrideReason}
placeholder="Reason (optional)…" onChange={(e) => setOverrideReason(e.target.value)}
rows={1} placeholder="e.g. Skipping EOI, client signed contract directly"
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" rows={2}
className="mt-1 text-sm"
disabled={mutation.isPending} disabled={mutation.isPending}
autoFocus
/> />
</div> </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"> <ul role="listbox" aria-label="Pipeline stages" className="py-1">
{PIPELINE_STAGES.map((s) => { {PIPELINE_STAGES.map((s) => {
const isCurrent = s === stage; const isCurrent = s === stage;
const isPending = pendingStage === s && mutation.isPending; const isPending = pendingStage === s && mutation.isPending;
const isOverride = s !== stage && !canTransitionStage(stage, s);
const blockedByPermission = isOverride && !canOverride;
return ( return (
<li key={s}> <li key={s}>
<button <button
type="button" type="button"
role="option" role="option"
aria-selected={isCurrent} aria-selected={isCurrent}
disabled={mutation.isPending} disabled={mutation.isPending || blockedByPermission}
onClick={() => pick(s)} onClick={() => pick(s)}
title={
blockedByPermission
? `Override required (you don't have permission)`
: isOverride
? 'Non-standard transition — confirm step required'
: undefined
}
className={cn( className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm', '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', 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. */} the picker into a visual scan rather than just a list. */}
<span <span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])} 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" /> <Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isCurrent ? ( ) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" /> <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} ) : null}
</button> </button>
</li> </li>
); );
})} })}
</ul> </ul>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@@ -72,6 +72,29 @@ const SOURCE_LABELS: Record<string, string> = {
broker: 'Broker', 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 }> = { const EOI_STATUS_LABELS: Record<string, { label: string; tone: string }> = {
waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' }, waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' },
signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' }, signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' },
@@ -176,7 +199,10 @@ export function getInterestColumns({
const stage = row.original.pipelineStage; const stage = row.original.pipelineStage;
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput); const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
return ( 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 <span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`} 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> </div>
) : null} ) : 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, MessageCircle,
Phone, Phone,
AlarmClock, AlarmClock,
Upload,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; 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 { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form'; 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 { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog'; import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -104,7 +102,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null); 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 isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome); const outcomeBadge = resolveOutcomeBadge(interest.outcome);
@@ -221,7 +219,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<InlineStagePicker <InlineStagePicker
interestId={interest.id} interestId={interest.id}
currentStage={interest.pipelineStage} currentStage={interest.pipelineStage}
className="-ml-2.5"
/> />
</PermissionGate> </PermissionGate>
)} )}
@@ -379,20 +376,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</> </>
)} )}
</PermissionGate> </PermissionGate>
<PermissionGate resource="documents" action="upload_signed"> {/* The "Upload paper-signed EOI" button used to live here.
<button It's now on the dedicated EOI tab (in both the active-EOI
type="button" hero and the empty-state CTA row), where it sits next to
onClick={() => setExternalEoiOpen(true)} the document it relates to. The header was a shotgun of
aria-label="Upload externally-signed EOI" actions that didn't all belong; collecting them per-tab
title="Upload externally-signed EOI (paper / outside Documenso)" is the cleaner UX. */}
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>
<PermissionGate resource="interests" action="edit"> <PermissionGate resource="interests" action="edit">
<button <button
type="button" type="button"
@@ -456,12 +445,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}} }}
isLoading={archiveMutation.isPending || restoreMutation.isPending} 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 { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider'; import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
interface InterestData { interface InterestData {
@@ -102,7 +103,30 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [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 ( return (
<DetailLayout <DetailLayout

View File

@@ -1,12 +1,18 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react'; import { FileSignature } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list'; import { DocumentList } from '@/components/documents/document-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog'; 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'; import { apiFetch } from '@/lib/api/client';
interface InterestDocumentsTabProps { interface InterestDocumentsTabProps {
@@ -15,40 +21,78 @@ interface InterestDocumentsTabProps {
interface InterestData { interface InterestData {
id: string; id: string;
yachtId?: string | null; clientId?: string | null;
berthId?: string | null;
clientName?: string | null;
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
clientPrimaryEmail?: string | null;
clientHasAddress?: boolean;
} }
/**
* 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) { export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const queryClient = useQueryClient();
const [eoiDialogOpen, setEoiDialogOpen] = useState(false); 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>({ const { data: interest } = useQuery<InterestData>({
queryKey: ['interests', interestId], queryKey: ['interests', interestId],
queryFn: () => queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data), apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
}); });
const prerequisites = { // Files attach at the client level (the schema has no interest_id
// Required (EOI Section 2 - top paragraph): name, address, email. // FK on `files`). For an interest, surface every file that belongs
hasName: Boolean(interest?.clientName), // to its parent client — covers the realistic case where a rep
hasEmail: Boolean(interest?.clientPrimaryEmail), // uploaded a passport / scan / photo while working a deal.
hasAddress: Boolean(interest?.clientHasAddress), // Until the interest record loads we pass a sentinel clientId so the
// Optional (EOI Section 3): yacht + berth. Render blank when absent. // server returns empty rather than the unscoped port-wide file list.
hasYacht: Boolean(interest?.yachtId), const clientId = interest?.clientId ?? '__pending__';
hasBerth: Boolean(interest?.berthId), 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 ( return (
<div className="space-y-4"> <div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between"> <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)}> <Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI Generate EOI
</Button> </Button>
@@ -73,12 +117,55 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
</div> </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 <EoiGenerateDialog
interestId={interestId} interestId={interestId}
clientId={interest?.clientId ?? null}
open={eoiDialogOpen} open={eoiDialogOpen}
onOpenChange={setEoiDialogOpen} onOpenChange={setEoiDialogOpen}
prerequisites={prerequisites} />
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/> />
</div> </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: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' }, { label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' }, { 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -62,7 +61,6 @@ interface InterestFormProps {
pipelineStage: string; pipelineStage: string;
leadCategory?: string | null; leadCategory?: string | null;
source?: string | null; source?: string | null;
notes?: string | null;
reminderEnabled?: boolean; reminderEnabled?: boolean;
reminderDays?: number | null; reminderDays?: number | null;
tags?: Array<{ id: string }>; tags?: Array<{ id: string }>;
@@ -130,7 +128,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number], pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined, leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
source: interest.source ?? undefined, source: interest.source ?? undefined,
notes: interest.notes ?? undefined,
reminderEnabled: interest.reminderEnabled ?? false, reminderEnabled: interest.reminderEnabled ?? false,
reminderDays: interest.reminderDays ?? undefined, reminderDays: interest.reminderDays ?? undefined,
tagIds: interest.tags?.map((t) => t.id) ?? [], tagIds: interest.tags?.map((t) => t.id) ?? [],
@@ -457,18 +454,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator /> <Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Add notes about this interest..."
rows={3}
/>
</div>
<Separator />
{/* Reminder */} {/* Reminder */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide"> <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 { InterestForm } from '@/components/interests/interest-form';
import { PipelineBoard } from '@/components/interests/pipeline-board'; import { PipelineBoard } from '@/components/interests/pipeline-board';
import { interestFilterDefinitions } from '@/components/interests/interest-filters'; 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 { InterestCard } from '@/components/interests/interest-card';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { import {
@@ -58,6 +66,7 @@ export function InterestList() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editInterest, setEditInterest] = useState<InterestRow | null>(null); const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null); const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);
// Bulk-action dialog state // Bulk-action dialog state
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null); const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
@@ -134,6 +143,12 @@ export function InterestList() {
onArchive: (interest) => setArchiveInterest(interest), 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
@@ -171,8 +186,19 @@ export function InterestList() {
/> />
<div className="flex flex-wrap items-center gap-2"> <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 <FilterBar
filters={interestFilterDefinitions} filters={
viewMode === 'board'
? interestFilterDefinitions.filter(
(f) => f.key !== 'pipelineStage' && f.key !== 'includeArchived',
)
: interestFilterDefinitions
}
values={filters} values={filters}
onChange={setFilter} onChange={setFilter}
onClear={clearFilters} onClear={clearFilters}
@@ -188,24 +214,44 @@ export function InterestList() {
placeholder="Filter by tag / event…" placeholder="Filter by tag / event…"
/> />
</div> </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 <SavedViewsDropdown
entityType="interests" entityType="interests"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters) => { onApplyView={(savedFilters) => {
clearFilters(); clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}} }}
/> />
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
</div> </div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="interests"
currentFilters={filters}
currentSort={sort}
/>
{viewMode === 'board' ? ( {viewMode === 'board' ? (
<PipelineBoard /> <PipelineBoard filters={filters} />
) : isLoading ? ( ) : isLoading ? (
<TableSkeleton /> <TableSkeleton />
) : ( ) : (
<DataTable <DataTable
columns={columns} columns={columns}
columnVisibility={columnVisibility}
data={data} data={data}
pagination={pagination} pagination={pagination}
onPaginationChange={(p, ps) => { 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 { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns'; import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button'; 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 { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { InterestTimeline } from '@/components/interests/interest-timeline'; import { InterestTimeline } from '@/components/interests/interest-timeline';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import { InterestFilesTab } from '@/components/interests/interest-files-tab'; import {
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants'; 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 { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type InterestPatchField = 'leadCategory' | 'source' | 'notes'; type InterestPatchField = 'leadCategory' | 'source';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({ const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c, value: c,
@@ -37,6 +46,9 @@ function humanizeStatus(value: string | null): string | null {
interface InterestTabsOptions { interface InterestTabsOptions {
interestId: string; interestId: string;
currentUserId?: 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: { interest: {
pipelineStage: string; pipelineStage: string;
/** Drives the recommender panel mounted on the Overview tab. */ /** Drives the recommender panel mounted on the Overview tab. */
@@ -59,6 +71,9 @@ interface InterestTabsOptions {
reminderEnabled: boolean; reminderEnabled: boolean;
reminderDays: number | null; reminderDays: number | null;
reminderLastFired: string | 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; notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note" /** Surfaced by getInterestById for the Overview "most recent note"
* teaser - saves a click into the Notes tab to peek at the latest. */ * 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) { function useStageMutation(interestId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ 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`, { apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH', method: 'PATCH',
body: { pipelineStage: stage, reason }, body: { pipelineStage: stage, reason, override },
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] }); 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({ function OverviewTab({
interestId, interestId,
interest, interest,
@@ -292,25 +384,98 @@ function OverviewTab({
const save = (field: InterestPatchField) => async (next: string | null) => { const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next }); 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; // Determine each milestone's phase relative to the current pipeline
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none. // 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 stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed'); const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct'); const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed'); const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
if (stageIdx === -1 || stageIdx >= contractSignedIdx) { const phaseFor = (milestoneEndStageIdx: number): Phase => {
activeMilestone = null; if (stageIdx === -1) return 'future';
} else if (stageIdx < eoiSignedIdx) { if (stageIdx >= milestoneEndStageIdx) return 'past';
activeMilestone = 'eoi'; // The "current" milestone is the one whose end-stage hasn't been
} else if (stageIdx < depositIdx) { // reached and whose start-stage is at-or-before the current stage.
activeMilestone = 'deposit'; return 'current';
} else { };
activeMilestone = 'contract'; // 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 => { const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null; if (v === null || v === undefined) return null;
@@ -318,23 +483,49 @@ function OverviewTab({
return Number.isFinite(n) ? n : null; return Number.isFinite(n) ? n : null;
}; };
return ( const milestones: Array<{
<div className="space-y-6"> key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
{/* Sales-process milestones - the heart of the system. Each section is a phase: Phase;
mini lifecycle that auto-completes as actions happen on the platform title: string;
(Documenso webhook, paid deposit invoice, signed contract). Until the icon: React.ComponentType<{ className?: string }>;
automation lands, salespeople nudge stages forward via the inline status: string | null;
buttons here, which auto-stamp the milestone date server-side. */} steps: MilestoneSectionProps['steps'];
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> footer?: React.ReactNode;
<MilestoneSection /** Brief one-liner shown when the milestone is in the past-strip. */
title="EOI" pastSummary: React.ReactNode;
icon={Send} }> = [
status={interest.eoiStatus} {
isPending={stageMutation.isPending} key: 'berth_interest',
onAdvance={advance} phase: berthInterestPhase,
currentStage={interest.pipelineStage} title: 'Berth Interest',
isActive={activeMilestone === 'eoi'} icon: Anchor,
steps={[ // 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', label: 'EOI sent',
date: interest.dateEoiSent, date: interest.dateEoiSent,
@@ -347,28 +538,27 @@ function OverviewTab({
advanceStage: 'eoi_signed', advanceStage: 'eoi_signed',
actionLabel: 'Mark EOI as signed', actionLabel: 'Mark EOI as signed',
}, },
]} ],
/> pastSummary: interest.dateEoiSigned
<MilestoneSection ? `Signed ${formatDate(interest.dateEoiSigned)}`
title="Deposit" : 'Completed',
icon={Wallet} },
status={interest.depositStatus} {
isPending={stageMutation.isPending} key: 'deposit',
onAdvance={advance} phase: depositPhase,
currentStage={interest.pipelineStage} title: 'Deposit',
isActive={activeMilestone === 'deposit'} icon: Wallet,
steps={[ status: interest.depositStatus,
steps: [
{ {
label: 'Deposit received', label: 'Deposit received',
date: interest.dateDepositReceived, date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct', 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, hideAutoButton: true,
}, },
]} ],
footer={ footer:
!interest.dateDepositReceived ? ( depositPhase === 'current' && !interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5"> <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"> <Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}> <Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
@@ -385,18 +575,18 @@ function OverviewTab({
Mark received manually Mark received manually
</button> </button>
</div> </div>
) : null ) : null,
} pastSummary: interest.dateDepositReceived
/> ? `Received ${formatDate(interest.dateDepositReceived)}`
<MilestoneSection : 'Recorded',
title="Contract" },
icon={FileSignature} {
status={interest.contractStatus} key: 'contract',
isPending={stageMutation.isPending} phase: contractPhase,
onAdvance={advance} title: 'Contract',
currentStage={interest.pipelineStage} icon: FileSignature,
isActive={activeMilestone === 'contract'} status: interest.contractStatus,
steps={[ steps: [
{ {
label: 'Contract sent', label: 'Contract sent',
date: interest.dateContractSent, date: interest.dateContractSent,
@@ -409,9 +599,74 @@ function OverviewTab({
advanceStage: 'contract_signed', advanceStage: 'contract_signed',
actionLabel: 'Mark contract as 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>
</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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */} {/* Lead & Source (editable) */}
@@ -460,8 +715,9 @@ function OverviewTab({
{/* Most-recent threaded note teaser. Saves a click into the Notes {/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last." tab when the rep just wants to peek at "what was discussed last."
Hidden when there's nothing to show. */} Always rendered now that the redundant `interests.notes` blob is
{interest.recentNote ? ( 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="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3> <h3 className="text-sm font-medium">Latest note</h3>
@@ -469,10 +725,12 @@ function OverviewTab({
href={`/${portSlug}/interests/${interestId}?tab=notes`} href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline" className="text-xs font-medium text-primary hover:underline"
> >
View all {interest.recentNote
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''} ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'}
</Link> </Link>
</div> </div>
{interest.recentNote ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm"> <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"> <p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content} {interest.recentNote.content}
@@ -486,18 +744,11 @@ function OverviewTab({
: ''} : ''}
</p> </p>
</div> </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> </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> </div>
{/* Tags */} {/* Tags */}
@@ -533,14 +784,39 @@ function OverviewTab({
export function getInterestTabs({ export function getInterestTabs({
interestId, interestId,
currentUserId, currentUserId,
clientId = null,
interest, interest,
}: InterestTabsOptions): DetailTab[] { }: 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', id: 'overview',
label: 'Overview', label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />, content: <OverviewTab interestId={interestId} interest={interest} />,
}, },
{
id: 'contact-log',
label: 'Contact log',
content: <InterestContactLogTab interestId={interestId} />,
},
{ {
id: 'notes', id: 'notes',
label: 'Notes', label: 'Notes',
@@ -548,16 +824,38 @@ export function getInterestTabs({
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} /> <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', id: 'documents',
label: 'Documents', label: 'Documents',
content: <InterestDocumentsTab interestId={interestId} />, content: <InterestDocumentsTab interestId={interestId} />,
}, },
{
id: 'files',
label: 'Files',
content: <InterestFilesTab interestId={interestId} />,
},
{ {
id: 'recommendations', id: 'recommendations',
label: 'Recommendations', label: 'Recommendations',
@@ -568,5 +866,7 @@ export function getInterestTabs({
label: 'Activity', label: 'Activity',
content: <InterestTimeline interestId={interestId} />, 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="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between gap-2"> {/* Switch sits next to its label (gap-2.5) instead of being
<Label htmlFor={`specific-${row.berthId}`} className="text-sm font-medium"> flexed to the far right via justify-between — when the
Specifically pitching column is wide, justify-between created a confusing visual
</Label> gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch <Switch
id={`specific-${row.berthId}`} id={`specific-${row.berthId}`}
checked={row.isSpecificInterest} checked={row.isSpecificInterest}
disabled={isPending} disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })} onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
/> />
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF} {row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center gap-2.5">
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium">
Mark in EOI bundle
</Label>
<Switch <Switch
id={`bundle-${row.berthId}`} id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle} checked={row.isInEoiBundle}
disabled={isPending} disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })} 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> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{row.isInEoiBundle {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 { PipelineColumn } from '@/components/interests/pipeline-column';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants'; import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
interface InterestRow { interface InterestRow {
id: string; id: string;
@@ -19,28 +19,77 @@ interface InterestRow {
updatedAt: string; 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 params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { boardFilters } = usePipelineStore();
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({ // Build the board endpoint URL with the supported filter subset.
queryKey: ['interests-board', portSlug], // pipelineStage + includeArchived are intentionally not threaded
queryFn: () => apiFetch('/api/v1/interests?limit=500'), // 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(() => {
const interests = useMemo(() => { if (!filters) return '';
if (!allData?.data) return []; const params = new URLSearchParams();
return allData.data.filter((i) => { const pick = (k: string) => {
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false; const v = filters[k];
if (boardFilters.search) { if (v === null || v === undefined || v === '' || v === false) return;
const q = boardFilters.search.toLowerCase(); if (Array.isArray(v)) {
if (!i.clientName?.toLowerCase().includes(q)) return false; 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 grouped = useMemo(() => {
const map: Record<string, InterestRow[]> = {}; 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" />; 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 ( return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <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"> <div className="flex gap-3 overflow-x-auto pb-4">
{PIPELINE_STAGES.map((stage) => ( {PIPELINE_STAGES.map((stage) => (
<PipelineColumn <PipelineColumn

View File

@@ -13,7 +13,8 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'; } 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 // Human-readable labels for route segments
const SEGMENT_LABELS: Record<string, string> = { const SEGMENT_LABELS: Record<string, string> = {
@@ -51,7 +52,8 @@ function formatSegment(segment: string): string {
export function Breadcrumbs() { export function Breadcrumbs() {
const pathname = usePathname(); 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 // Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean); const rawSegments = pathname.split('/').filter(Boolean);
@@ -62,22 +64,10 @@ export function Breadcrumbs() {
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg)); ).filter((seg) => !isIdSegment(seg));
if (segments.length === 0) { if (segments.length === 0) return null;
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground font-medium">
{currentPort?.name ?? 'Port Nimara CRM'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
// Build href for each segment // Build href for each segment from the URL.
const crumbs = segments.map((segment, index) => { const urlCrumbs = segments.map((segment, index) => {
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1); const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
const href = '/' + segmentsUpToHere.join('/'); const href = '/' + segmentsUpToHere.join('/');
const label = formatSegment(segment); const label = formatSegment(segment);
@@ -86,35 +76,37 @@ export function Breadcrumbs() {
return { label, href, isLast }; 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 ( return (
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList className="text-sm gap-1.5">
{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>
)}
</>
)}
{crumbs.map((crumb, _index) => ( {crumbs.map((crumb, _index) => (
<Fragment key={crumb.href}> <Fragment key={crumb.href}>
<BreadcrumbItem> <BreadcrumbItem>
{crumb.isLast ? ( {crumb.isLast ? (
<BreadcrumbPage className="font-medium text-foreground"> <BreadcrumbPage className="font-medium text-foreground truncate max-w-[160px]">
{crumb.label} {crumb.label}
</BreadcrumbPage> </BreadcrumbPage>
) : ( ) : (
@@ -122,7 +114,7 @@ export function Breadcrumbs() {
<Link <Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
href={crumb.href as 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} {crumb.label}
</Link> </Link>
@@ -130,7 +122,7 @@ export function Breadcrumbs() {
)} )}
</BreadcrumbItem> </BreadcrumbItem>
{!crumb.isLast && ( {!crumb.isLast && (
<BreadcrumbSeparator> <BreadcrumbSeparator className="text-muted-foreground/40">
<ChevronRight className="w-3 h-3" /> <ChevronRight className="w-3 h-3" />
</BreadcrumbSeparator> </BreadcrumbSeparator>
)} )}

View File

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

View File

@@ -123,7 +123,10 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true, marinaRequired: true,
items: [ items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt }, { 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 // Dedicated explainer page covers "Add to Home Screen" install
// + walkthrough; the mobile-only scanner UI itself lives at /scan // + walkthrough; the mobile-only scanner UI itself lives at /scan
// and is reached via the install or the explainer page button. // and is reached via the install or the explainer page button.
@@ -224,6 +227,7 @@ function SidebarContent({
collapsed, collapsed,
portSlug, portSlug,
portRoles, portRoles,
isSuperAdmin,
hasAdminAccess, hasAdminAccess,
hasMarinaAccess, hasMarinaAccess,
hasResidentialAccess, hasResidentialAccess,
@@ -234,6 +238,7 @@ function SidebarContent({
collapsed: boolean; collapsed: boolean;
portSlug: string | undefined; portSlug: string | undefined;
portRoles: SidebarProps['portRoles']; portRoles: SidebarProps['portRoles'];
isSuperAdmin: boolean;
hasAdminAccess: boolean; hasAdminAccess: boolean;
hasMarinaAccess: boolean; hasMarinaAccess: boolean;
hasResidentialAccess: boolean; hasResidentialAccess: boolean;
@@ -246,6 +251,14 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true); const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug); 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 // Pre-compute every nav href the sidebar offers across all sections so
// the active-state check can do longest-prefix-match. Without this, // the active-state check can do longest-prefix-match. Without this,
// /invoices/upload-receipts would highlight both "Invoices" and "How to // /invoices/upload-receipts would highlight both "Invoices" and "How to
@@ -403,8 +416,11 @@ function SidebarContent({
variant="outline" variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5" 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> </Badge>
{currentPortName && (
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p>
)}
</div> </div>
</button> </button>
} }
@@ -417,8 +433,10 @@ function SidebarContent({
} }
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) { export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); // Sidebar collapse removed — design preference is the always-expanded
const toggleSidebar = useUIStore((s) => s.toggleSidebar); // 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); const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Super admins see every section regardless of role rows. // Super admins see every section regardless of role rows.
@@ -448,12 +466,12 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
portSlug={currentPortSlug ?? undefined} portSlug={currentPortSlug ?? undefined}
portRoles={portRoles} portRoles={portRoles}
isSuperAdmin={isSuperAdmin}
hasAdminAccess={hasAdminAccess} hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess} hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess} hasResidentialAccess={hasResidentialAccess}
user={user} user={user}
ports={ports} ports={ports}
onToggleCollapse={toggleSidebar}
/> />
</aside> </aside>
); );

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import { Plus } from 'lucide-react'; import { ChevronLeft, Plus } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store'; 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 { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -28,16 +30,46 @@ interface TopbarProps {
export function Topbar({ ports, user }: TopbarProps) { export function Topbar({ ports, user }: TopbarProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const currentPortSlug = useUIStore((s) => s.currentPortSlug); const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const base = currentPortSlug ? `/${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 ( return (
// Three-column grid: breadcrumbs left, search center, actions right. // Three-column grid: breadcrumbs left, search center, actions right.
// The brand logo lives in the sidebar header (per design feedback) so the // The brand logo lives in the sidebar header (per design feedback) so the
// topbar center is dedicated to the global search bar. // 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"> <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 */} {/* LEFT: optional back button + breadcrumbs / page title */}
<div className="min-w-0"> <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 /> <Breadcrumbs />
</div> </div>
@@ -100,8 +132,12 @@ export function Topbar({ ports, user }: TopbarProps) {
user={user} user={user}
ports={ports} ports={ports}
trigger={ trigger={
<Button variant="ghost" size="icon" className="rounded-full"> // Button shrunk to match the Avatar's visible footprint so
<Avatar className="w-7 h-7 shadow-sm ring-2 ring-background"> // 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} /> <AvatarImage src={undefined} />
<AvatarFallback className="bg-brand text-white text-xs font-semibold"> <AvatarFallback className="bg-brand text-white text-xs font-semibold">
{(user?.name ?? 'U').slice(0, 1).toUpperCase()} {(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 ColumnDef,
type Row, type Row,
type RowSelectionState, type RowSelectionState,
type VisibilityState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
@@ -51,6 +52,12 @@ interface DataTableProps<TData> {
isLoading?: boolean; isLoading?: boolean;
getRowId?: (row: TData) => string; getRowId?: (row: TData) => string;
onRowClick?: (row: TData) => void; 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:` * Mobile card renderer. When provided, the table is hidden below `lg:`
* and replaced with a vertical list of cards built from this callback. * 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. * sort, and selection stay in sync across the breakpoint.
*/ */
cardRender?: (row: Row<TData>) => React.ReactNode; 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>({ export function DataTable<TData>({
@@ -74,7 +88,9 @@ export function DataTable<TData>({
isLoading, isLoading,
getRowId, getRowId,
onRowClick, onRowClick,
getRowClassName,
cardRender, cardRender,
columnVisibility,
}: DataTableProps<TData>) { }: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({}); const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
const rowSelectionState = externalSelection ?? internalSelection; const rowSelectionState = externalSelection ?? internalSelection;
@@ -122,6 +138,7 @@ export function DataTable<TData>({
pagination: pagination pagination: pagination
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize } ? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
: undefined, : undefined,
columnVisibility,
}, },
onRowSelectionChange: (updater) => { onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater; const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater;
@@ -215,7 +232,7 @@ export function DataTable<TData>({
<TableRow <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && 'selected'} data-state={row.getIsSelected() && 'selected'}
className={cn(onRowClick && 'cursor-pointer')} className={cn(onRowClick && 'cursor-pointer', getRowClassName?.(row.original))}
onClick={() => onRowClick?.(row.original)} onClick={() => onRowClick?.(row.original)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
@@ -247,14 +264,39 @@ export function DataTable<TData>({
</ul> </ul>
)} )}
{/* Pagination */} {/* Pagination — render whenever pagination is defined so the
{pagination && pagination.totalPages > 1 && ( page-size selector is reachable even on single-page tables.
<div className="flex items-center justify-between px-2"> 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"> <div className="text-sm text-muted-foreground">
{selectedIds.length > 0 {selectedIds.length > 0
? `${selectedIds.length} of ${pagination.total} row(s) selected` ? `${selectedIds.length} of ${pagination.total} row(s) selected`
: `${pagination.total} row(s) total`} : `${pagination.total} row(s) total`}
</div> </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"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
@@ -276,6 +318,8 @@ export function DataTable<TData>({
Next Next
</Button> </Button>
</div> </div>
)}
</div>
</div> </div>
)} )}

View File

@@ -184,16 +184,22 @@ function FilterField({
</div> </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 ( return (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">{definition.label}</Label> <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"> <SelectTrigger className="h-8">
<SelectValue placeholder={definition.placeholder ?? 'Any'} /> <SelectValue placeholder={definition.placeholder ?? 'Any'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Any</SelectItem> <SelectItem value={ANY}>Any</SelectItem>
{definition.options?.map((opt) => ( {definition.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
@@ -203,6 +209,7 @@ function FilterField({
</Select> </Select>
</div> </div>
); );
}
case 'multi-select': case 'multi-select':
return ( return (

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; 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 { toastError } from '@/lib/api/toast-error';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -98,33 +98,25 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
} }
if (props.variant === 'select') { 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) => const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null; 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 ( return (
<div className={cn('flex items-center gap-1', className)}> <div className={cn('flex items-center gap-1', className)}>
<Select <Select
value={draft} value={value ?? ''}
onValueChange={(v) => void commit(v)} onValueChange={(v) => void commit(v)}
open disabled={disabled || saving}
onOpenChange={(open) => {
if (!open && !saving) setEditing(false);
}}
> >
<SelectTrigger className="h-7 text-sm w-full"> <SelectTrigger className="h-8 text-sm w-full">
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={emptyText}>{labelFor(value) ?? emptyText}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{props.options.map((o) => ( {props.options.map((o) => (
@@ -228,6 +220,7 @@ function ReadButton({
disabled, disabled,
onClick, onClick,
multiline, multiline,
kind = 'text',
className, className,
}: { }: {
value: string | null; value: string | null;
@@ -235,8 +228,13 @@ function ReadButton({
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
multiline?: boolean; 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; className?: string;
}) { }) {
const Icon = kind === 'select' ? ChevronDown : Pencil;
return ( return (
<button <button
type="button" type="button"
@@ -246,6 +244,9 @@ function ReadButton({
'group rounded px-1 -mx-1 py-0.5 text-left text-sm', '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', 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', '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', disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className, className,
)} )}
@@ -260,11 +261,14 @@ function ReadButton({
{value ?? emptyText} {value ?? emptyText}
</span> </span>
{!disabled && ( {!disabled && (
<Pencil <Icon
className={cn( className={cn(
// Show the pencil faintly at rest so users discover the field is // Pencil sits faintly so users discover free-text editability
// editable without having to hover-and-test every label. // on hover; chevron is more present so the dropdown affordance
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60', // 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', multiline && 'mt-1 shrink-0',
)} )}
/> />

View File

@@ -18,32 +18,72 @@ interface Note {
isLocked: boolean; isLocked: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Aggregated-mode only: which child entity this note came from. */
source?: 'client' | 'interest' | 'yacht';
sourceId?: string;
sourceLabel?: string;
} }
interface NotesListProps { interface NotesListProps {
entityType: 'clients' | 'interests' | 'yachts' | 'companies'; entityType:
| 'clients'
| 'interests'
| 'yachts'
| 'companies'
| 'residential_clients'
| 'residential_interests';
entityId: string; entityId: string;
currentUserId?: 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 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 queryClient = useQueryClient();
const [newNote, setNewNote] = useState(''); const [newNote, setNewNote] = useState('');
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState(''); const [editContent, setEditContent] = useState('');
const [groupBySource, setGroupBySource] = useState(false);
const endpoint = `/api/v1/${entityType}/${entityId}/notes`; const aggregateOn = aggregate && entityType === 'clients';
const queryKey = [entityType, entityId, 'notes']; 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[]>({ const { data: notes = [], isLoading } = useQuery<Note[]>({
queryKey, 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({ const createMutation = useMutation({
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }), mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
setNewNote(''); setNewNote('');
@@ -52,7 +92,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ noteId, content }: { noteId: string; content: string }) => mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }), apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
setEditingId(null); setEditingId(null);
@@ -60,13 +100,17 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }), mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }), onSuccess: () => queryClient.invalidateQueries({ queryKey }),
}); });
function canEdit(note: Note): boolean { function canEdit(note: Note): boolean {
if (note.authorId !== currentUserId) return false; if (note.authorId !== currentUserId) return false;
if (note.isLocked) 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(); const elapsed = Date.now() - new Date(note.createdAt).getTime();
return elapsed < NOTE_EDIT_WINDOW_MS; return elapsed < NOTE_EDIT_WINDOW_MS;
} }
@@ -105,6 +149,29 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
</div> </div>
</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 */} {/* Notes list */}
{isLoading ? ( {isLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading notes...</div> <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="text-center py-8 text-muted-foreground">No notes yet</div>
) : ( ) : (
<div className="space-y-3"> <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"> <div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
<Avatar className="h-8 w-8 shrink-0"> <Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-xs"> <AvatarFallback className="text-xs">
@@ -120,11 +187,23 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0 space-y-1"> <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="font-medium">{note.authorName ?? 'User'}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
</span> </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" />} {note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
{canEdit(note) && ( {canEdit(note) && (
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span> <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'; 'use client';
import { useState } from 'react'; import { Bookmark, Check, Trash2 } from 'lucide-react';
import { Bookmark, Check, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } 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'; import { useSavedViews } from '@/hooks/use-saved-views';
interface SavedViewsDropdownProps { interface SavedViewsDropdownProps {
entityType: string; entityType: string;
currentFilters: Record<string, unknown>; onApplyView: (
currentSort?: { field: string; direction: 'asc' | 'desc' }; filters: Record<string, unknown>,
onApplyView: (filters: Record<string, unknown>, sort?: { field: string; direction: string }) => void; sort?: { field: string; direction: string },
) => void;
} }
export function SavedViewsDropdown({ /**
entityType, * Read-only browser for saved views. The "Save current view" affordance
currentFilters, * has moved into the ColumnPicker menu (see SaveViewDialog). This
currentSort, * component renders nothing when the user has no saved views — the
onApplyView, * Bookmark button on its own is just visual noise until something has
}: SavedViewsDropdownProps) { * been saved.
const { views, activeViewId, saveCurrentView, deleteView, applyView } = */
useSavedViews(entityType); export function SavedViewsDropdown({ entityType, onApplyView }: SavedViewsDropdownProps) {
const [saveOpen, setSaveOpen] = useState(false); const { views, activeViewId, deleteView, applyView } = useSavedViews(entityType);
const [viewName, setViewName] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave() { if (views.length === 0) return null;
if (!viewName.trim()) return;
setIsSaving(true);
try {
await saveCurrentView(viewName.trim(), currentFilters, currentSort);
setSaveOpen(false);
setViewName('');
} finally {
setIsSaving(false);
}
}
return ( return (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8"> <Button variant="outline" size="sm" className="h-8">
@@ -63,12 +40,7 @@ export function SavedViewsDropdown({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
{views.length === 0 ? ( {views.map((view) => (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
No saved views yet
</div>
) : (
views.map((view) => (
<DropdownMenuItem <DropdownMenuItem
key={view.id} key={view.id}
className="flex items-center justify-between" className="flex items-center justify-between"
@@ -82,9 +54,7 @@ export function SavedViewsDropdown({
> >
<span className="truncate">{view.name}</span> <span className="truncate">{view.name}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{activeViewId === view.id && ( {activeViewId === view.id && <Check className="h-3.5 w-3.5 text-primary" />}
<Check className="h-3.5 w-3.5 text-primary" />
)}
<button <button
className="p-0.5 rounded hover:bg-muted" className="p-0.5 rounded hover:bg-muted"
onClick={(e) => { onClick={(e) => {
@@ -96,40 +66,8 @@ export function SavedViewsDropdown({
</button> </button>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
)) ))}
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSaveOpen(true)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Save current view
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog'; import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
@@ -95,18 +96,9 @@ export function OwnerLink({
} }
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null { function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
const parts: string[] = []; // Show both units; derive whichever is unset from the other so reps
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`); // never need to enter both. See `yacht-dimensions.ts`.
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`); return formatYachtDimensionsBothUnits(yacht);
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;
} }
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) { 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 { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs'; import { getYachtTabs } from '@/components/yachts/yacht-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
export interface YachtData { export interface YachtData {
@@ -54,6 +55,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [titleForChrome, setChrome]);
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
useRealtimeInvalidation({ useRealtimeInvalidation({
'yacht:updated': [['yachts', yachtId]], 'yacht:updated': [['yachts', yachtId]],
'yacht:archived': [['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 <SavedViewsDropdown
entityType="yachts" entityType="yachts"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => { onApplyView={(savedFilters, _savedSort) => {
clearFilters(); clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val)); 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; const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value }); 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) => { const yearTransform = (next: string | null) => {
if (next === null) return null; if (next === null) return null;
const n = Number.parseInt(next, 10); 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> <h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl> <dl>
<EditableRow label="Length (ft)"> <EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} /> <InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (ft)"> <EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} /> <InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (ft)"> <EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} /> <InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
@@ -173,13 +211,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3> <h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl> <dl>
<EditableRow label="Length (m)"> <EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} /> <InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (m)"> <EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} /> <InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (m)"> <EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} /> <InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </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({ export const baseListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1), 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(), sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'), order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(), search: z.string().optional(),

View File

@@ -1,14 +1,4 @@
import { import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
and,
asc,
desc,
eq,
ilike,
isNull,
or,
sql,
type SQL,
} from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core'; import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index'; import { db } from './index';
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
updatedAtColumn: PgColumn; updatedAtColumn: PgColumn;
filters?: SQL[]; filters?: SQL[];
sort?: { column: PgColumn; direction: 'asc' | 'desc' }; 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; page: number;
pageSize: number; pageSize: number;
searchColumns?: PgColumn[]; searchColumns?: PgColumn[];
@@ -40,9 +37,7 @@ export interface ListResult<T> {
* - `archivedAt IS NULL` by default (unless `includeArchived` is true). * - `archivedAt IS NULL` by default (unless `includeArchived` is true).
* - Deterministic secondary sort: `updatedAt DESC, id DESC`. * - Deterministic secondary sort: `updatedAt DESC, id DESC`.
*/ */
export async function buildListQuery<T>( export async function buildListQuery<T>(opts: BuildListQueryOptions): Promise<ListResult<T>> {
opts: BuildListQueryOptions,
): Promise<ListResult<T>> {
const { const {
table, table,
portIdColumn, portIdColumn,
@@ -51,6 +46,7 @@ export async function buildListQuery<T>(
updatedAtColumn, updatedAtColumn,
filters = [], filters = [],
sort, sort,
customOrderBy,
page, page,
pageSize, pageSize,
searchColumns = [], searchColumns = [],
@@ -68,9 +64,7 @@ export async function buildListQuery<T>(
// Full-text search across multiple columns via ILIKE // Full-text search across multiple columns via ILIKE
if (searchTerm && searchColumns.length > 0) { if (searchTerm && searchColumns.length > 0) {
const searchConditions = searchColumns.map((col) => const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
ilike(col, `%${searchTerm}%`),
);
conditions.push(or(...searchConditions)!); conditions.push(or(...searchConditions)!);
} }
@@ -86,12 +80,13 @@ export async function buildListQuery<T>(
.where(where); .where(where);
const total = countResult[0]?.count ?? 0; 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[] = []; const orderClauses: SQL[] = [];
if (sort) { if (customOrderBy && customOrderBy.length > 0) {
orderClauses.push( orderClauses.push(...customOrderBy);
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column), } else if (sort) {
); orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
} }
orderClauses.push(desc(updatedAtColumn), desc(idColumn)); orderClauses.push(desc(updatedAtColumn), desc(idColumn));

View File

@@ -58,7 +58,6 @@ export const interests = pgTable(
outcomeReason: text('outcome_reason'), outcomeReason: text('outcome_reason'),
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */ /** When the outcome was decided. Lets us age 'how long ago did we lose'. */
outcomeAt: timestamp('outcome_at', { withTimezone: true }), outcomeAt: timestamp('outcome_at', { withTimezone: true }),
notes: text('notes'),
/** Recommender inputs - imperial; resolver treats nulls as "no constraint" /** Recommender inputs - imperial; resolver treats nulls as "no constraint"
* on that axis, with a banner prompting the rep to add the missing dim. */ * on that axis, with a banner prompting the rep to add the missing dim. */
desiredLengthFt: numeric('desired_length_ft'), 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 = { export type UserPreferences = {
dark_mode?: boolean; dark_mode?: boolean;
locale?: string; locale?: string;
timezone?: 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; [key: string]: unknown;
}; };
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
userId: text('user_id').notNull().unique(), // references Better Auth user ID userId: text('user_id').notNull().unique(), // references Better Auth user ID
displayName: text('display_name').notNull(), displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'), 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'), phone: text('phone'),
isSuperAdmin: boolean('is_super_admin').notNull().default(false), isSuperAdmin: boolean('is_super_admin').notNull().default(false),
isActive: boolean('is_active').notNull().default(true), 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( export const userPortRoles = pgTable(
'user_port_roles', 'user_port_roles',
{ {

View File

@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
} }
interface SyntheticClientSpec { 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; tag: string;
fullName: string; fullName: string;
email: string; email: string;
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
/** Archive the CLIENT after creation. When 'rich', fabricate /** Archive the CLIENT after creation. When 'rich', fabricate
* archive_metadata so the smart-restore wizard surfaces reversals. */ * archive_metadata so the smart-restore wizard surfaces reversals. */
archive?: 'simple' | 'rich'; 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 * Berth indices map deterministically into the NocoDB snapshot which is
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold. * 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[] = [ const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
{ {
tag: 'open', tag: 'open',
fullName: 'Olivia Open — open', fullName: 'Olivia Sinclair',
email: 'olivia.open@test.local', email: 'olivia.sinclair@gmail.com',
phone: '+1 555 010 0001', phone: '+44 7700 900142',
countryIso: 'GB', countryIso: 'GB',
city: 'London', city: 'London',
street: '1 Open Lane', street: '14 Cheyne Walk',
postalCode: 'OP1 1OP', postalCode: 'SW3 5RA',
stage: 'open', stage: 'open',
source: 'website',
createdDaysAgo: 4,
// Open stage: no berth link yet // Open stage: no berth link yet
}, },
{ {
tag: 'details', tag: 'details',
fullName: 'Daniel Details — details_sent', fullName: 'Daniel Whitaker',
email: 'daniel.details@test.local', email: 'daniel.whitaker@outlook.com',
phone: '+1 555 010 0002', phone: '+1 305 555 0182',
countryIso: 'US', countryIso: 'US',
city: 'Miami', city: 'Miami',
street: '2 Brochure Way', street: '880 Brickell Bay Drive',
postalCode: '33101', postalCode: '33131',
stage: 'details_sent', stage: 'details_sent',
berthIdx: 0, berthIdx: 0,
source: 'broker',
createdDaysAgo: 12,
}, },
{ {
tag: 'comms', tag: 'comms',
fullName: 'Carla Communicating — in_communication', fullName: 'Carla Mendoza',
email: 'carla.comms@test.local', email: 'carla.mendoza@gmail.com',
phone: '+1 555 010 0003', phone: '+34 971 555 028',
countryIso: 'ES', countryIso: 'ES',
city: 'Palma', city: 'Palma de Mallorca',
street: '3 Reply Street', street: 'Carrer de Sant Magí 23',
postalCode: '07012', postalCode: '07013',
stage: 'in_communication', stage: 'in_communication',
berthIdx: 5, berthIdx: 5,
source: 'referral',
createdDaysAgo: 28,
}, },
{ {
tag: 'eoi-sent', tag: 'eoi-sent',
fullName: 'Eric EoiSent — eoi_sent', fullName: 'Marco Bianchi',
email: 'eric.eoisent@test.local', email: 'marco.bianchi@libero.it',
phone: '+1 555 010 0004', phone: '+39 010 8740 215',
countryIso: 'IT', countryIso: 'IT',
city: 'Genoa', city: 'Genoa',
street: '4 Envelope Plaza', street: 'Via XX Settembre 47',
postalCode: '16124', postalCode: '16121',
stage: 'eoi_sent', stage: 'eoi_sent',
berthIdx: 6, berthIdx: 6,
source: 'broker',
createdDaysAgo: 45,
}, },
{ {
tag: 'eoi-signed', tag: 'eoi-signed',
fullName: 'Sara EoiSigned — eoi_signed', fullName: 'Sara Laurent',
email: 'sara.eoisigned@test.local', email: 'sara.laurent@orange.fr',
phone: '+1 555 010 0005', phone: '+33 4 93 92 18 47',
countryIso: 'FR', countryIso: 'FR',
city: 'Nice', city: 'Nice',
street: '5 Signed Avenue', street: '8 Promenade des Anglais',
postalCode: '06300', postalCode: '06000',
stage: 'eoi_signed', stage: 'eoi_signed',
berthIdx: 7, berthIdx: 7,
source: 'website',
createdDaysAgo: 72,
}, },
{ {
tag: 'deposit', tag: 'deposit',
fullName: 'Dario Deposit — deposit_10pct', fullName: 'Nikolas Papadakis',
email: 'dario.deposit@test.local', email: 'n.papadakis@gmail.com',
phone: '+1 555 010 0006', phone: '+30 210 8945 612',
countryIso: 'GR', countryIso: 'GR',
city: 'Athens', city: 'Athens',
street: '6 Deposit Quay', street: 'Vouliagmenis Avenue 142',
postalCode: '10558', postalCode: '16674',
stage: 'deposit_10pct', stage: 'deposit_10pct',
berthIdx: 8, berthIdx: 8,
source: 'referral',
createdDaysAgo: 95,
}, },
{ {
tag: 'contract-sent', tag: 'contract-sent',
fullName: 'Connor ContractSent — contract_sent', fullName: 'Connor Murphy',
email: 'connor.contract@test.local', email: 'connor.murphy@me.com',
phone: '+1 555 010 0007', phone: '+353 1 555 0184',
countryIso: 'IE', countryIso: 'IE',
city: 'Dublin', city: 'Dublin',
street: '7 Contract Row', street: '12 Merrion Square North',
postalCode: 'D02 E2X3', postalCode: 'D02 E2X3',
stage: 'contract_sent', stage: 'contract_sent',
berthIdx: 9, berthIdx: 9,
source: 'manual',
createdDaysAgo: 118,
}, },
{ {
tag: 'contract-signed', tag: 'contract-signed',
fullName: 'Carmen ContractSigned — contract_signed', fullName: 'Carmen Costa',
email: 'carmen.signed@test.local', email: 'carmen.costa@sapo.pt',
phone: '+1 555 010 0008', phone: '+351 21 386 4520',
countryIso: 'PT', countryIso: 'PT',
city: 'Lisbon', city: 'Lisbon',
street: '8 Notary Square', street: 'Rua Garrett 88',
postalCode: '1100-001', postalCode: '1200-205',
stage: 'contract_signed', stage: 'contract_signed',
berthIdx: 4, berthIdx: 4,
source: 'broker',
createdDaysAgo: 156,
}, },
{ {
tag: 'completed-won', tag: 'completed-won',
fullName: 'Carlos Completed — completed (won)', fullName: 'Carlos Vega',
email: 'carlos.complete@test.local', email: 'carlos.vega@gmail.com',
phone: '+1 555 010 0009', phone: '+507 6612 4485',
countryIso: 'PA', countryIso: 'PA',
city: 'Panama City', city: 'Panama City',
street: '9 Owner Lane', street: 'Calle 50, Torre Banistmo Piso 18',
postalCode: '0801', postalCode: '0816',
stage: 'completed', stage: 'completed',
berthIdx: 10, berthIdx: 10,
outcome: 'won', outcome: 'won',
source: 'referral',
createdDaysAgo: 245,
}, },
{ {
tag: 'completed-lost', tag: 'completed-lost',
fullName: 'Lara LostLead — completed (lost)', fullName: 'Hannah Schmidt',
email: 'lara.lost@test.local', email: 'hannah.schmidt@gmx.de',
phone: '+1 555 010 0010', phone: '+49 40 4286 9152',
countryIso: 'DE', countryIso: 'DE',
city: 'Hamburg', city: 'Hamburg',
street: '10 Other Marina', street: 'Alsterufer 28',
postalCode: '20457', postalCode: '20354',
stage: 'completed', stage: 'completed',
berthIdx: 1, berthIdx: 1,
outcome: 'lost_unqualified', outcome: 'lost_unqualified',
source: 'website',
createdDaysAgo: 84,
}, },
{ {
tag: 'archived-simple', tag: 'archived-simple',
fullName: 'Anna ArchivedSimple — archived', fullName: 'Anna de Jong',
email: 'anna.archived@test.local', email: 'anna.dejong@kpn.nl',
phone: '+1 555 010 0011', phone: '+31 20 624 7185',
countryIso: 'NL', countryIso: 'NL',
city: 'Amsterdam', city: 'Amsterdam',
street: '11 Quiet Path', street: 'Herengracht 412',
postalCode: '1011', postalCode: '1017 BX',
archive: 'simple', archive: 'simple',
source: 'website',
createdDaysAgo: 201,
}, },
{ {
tag: 'archived-rich', tag: 'archived-rich',
fullName: 'Rita ArchivedRich — archived w/ metadata', fullName: 'Rita Vermeulen',
email: 'rita.archivedrich@test.local', email: 'rita.vermeulen@telenet.be',
phone: '+1 555 010 0012', phone: '+32 3 226 8420',
countryIso: 'BE', countryIso: 'BE',
city: 'Antwerp', city: 'Antwerp',
street: '12 Rich Metadata Blvd', street: 'Meir 102',
postalCode: '2000', postalCode: '2000',
archive: 'rich', archive: 'rich',
source: 'broker',
createdDaysAgo: 280,
}, },
]; ];
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
const clientRows = await tx const clientRows = await tx
.insert(clients) .insert(clients)
.values( .values(
PIPELINE_CLIENTS.map((spec) => ({ PIPELINE_CLIENTS.map((spec) => {
const created =
spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
return {
portId, portId,
fullName: spec.fullName, fullName: spec.fullName,
nationalityIso: spec.countryIso, nationalityIso: spec.countryIso,
preferredContactMethod: 'email' as const, preferredContactMethod: 'email' as const,
preferredLanguage: 'en', 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 }); .returning({ id: clients.id, fullName: clients.fullName });

View File

@@ -310,7 +310,6 @@ async function applyInterest(
pipelineStage: planned.pipelineStage, pipelineStage: planned.pipelineStage,
leadCategory: planned.leadCategory, leadCategory: planned.leadCategory,
source: planned.source, source: planned.source,
notes: planned.notes,
documensoId: planned.documensoId, documensoId: planned.documensoId,
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null, dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : 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 { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths'; 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)); 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 = (() => { const sortColumn = (() => {
switch (query.sort) { switch (query.sort) {
case 'mooringNumber': case 'mooringNumber':
return berths.mooringNumber; // Honoured via customOrderBy below — caller asked for mooring
// sort explicitly, give them the natural order.
return null;
case 'area': case 'area':
return berths.area; return berths.area;
case 'price': case 'price':
@@ -74,7 +86,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
case 'lengthM': case 'lengthM':
return berths.lengthM; return berths.lengthM;
default: 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, idColumn: berths.id,
updatedAtColumn: berths.updatedAt, updatedAtColumn: berths.updatedAt,
filters, filters,
sort: { column: sortColumn, direction: query.order }, sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
page: query.page, page: query.page,
pageSize: query.limit, pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area], 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 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 db
.select({ ownerId: yachts.currentOwnerId, count: count() }) .select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts) .from(yachts)
@@ -148,6 +148,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
clientId: string; clientId: string;
channel: string; channel: string;
value: string; value: string;
valueE164: string | null;
isPrimary: boolean; isPrimary: boolean;
createdAt: Date; createdAt: Date;
}>(sql` }>(sql`
@@ -155,6 +156,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
client_id AS "clientId", client_id AS "clientId",
channel, channel,
value, value,
value_e164 AS "valueE164",
is_primary AS "isPrimary", is_primary AS "isPrimary",
created_at AS "createdAt" created_at AS "createdAt"
FROM client_contacts FROM client_contacts
@@ -162,8 +164,44 @@ export async function listClients(portId: string, query: ListClientsInput) {
AND channel IN ('email', 'phone') AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC 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 yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, 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 // Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is // returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice. // 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 primaryEmailMap = new Map<string, string>();
const primaryPhoneMap = new Map<string, string>(); const primaryPhoneMap = new Map<string, string>();
const primaryPhoneE164Map = new Map<string, string>();
type ContactRow = { type ContactRow = {
clientId: string; clientId: string;
channel: string; channel: string;
value: string; value: string;
valueE164: string | null;
isPrimary: boolean; isPrimary: boolean;
createdAt: Date; createdAt: Date;
}; };
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]); (contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) { for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value); 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 { return {
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
interestCount: interestCountMap.get(row.id) ?? 0, interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null, primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null, primaryPhone: primaryPhoneMap.get(row.id) ?? null,
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest latestInterest: latest
? { ? {
stage: latest.stage, stage: latest.stage,

View File

@@ -366,7 +366,11 @@ export async function resolveTemplate(
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB') ? 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. // These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? ''; 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 { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users'; 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` * 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, 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 { berths } from '@/lib/db/schema/berths';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients'; import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies'; 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 { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries'; 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 primaryBerth = await getPrimaryBerth(interest.id);
const primaryBerthId = primaryBerth?.berthId ?? null; 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 // 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 // multi-berth EOI compact-range merge field. Empty bundle → "" so the
// Documenso template renders blank rather than "undefined". // Documenso template renders blank rather than "undefined".
@@ -300,7 +312,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
stage: interest.pipelineStage, stage: interest.pipelineStage,
leadCategory: interest.leadCategory, leadCategory: interest.leadCategory,
dateFirstContact: interest.dateFirstContact, dateFirstContact: interest.dateFirstContact,
notes: interest.notes, notes: interestNotesContent,
}, },
port: { port: {
name: port.name, 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; 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 ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
export async function listInterests(portId: string, query: ListInterestsInput) { 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 berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? 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 const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color }) .select({ id: tags.id, name: tags.name, color: tags.color })
.from(interestTags) .from(interestTags)
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
clientHasAddress: !!addressRow, clientHasAddress: !!addressRow,
berthId, berthId,
berthMooringNumber, berthMooringNumber,
linkedBerthCount,
tags: tagRows, tags: tagRows,
notesCount, notesCount,
recentNote: recentNote ?? null, 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 { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients'; import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests'; import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts'; import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies'; 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 { userProfiles } from '@/lib/db/schema/users';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors'; import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes'; import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes 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 ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId))) .where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1); .limit(1);
if (!r.length) throw new NotFoundError('Yacht'); if (!r.length) throw new NotFoundError('Yacht');
} else { } else if (entityType === 'companies') {
const r = await db const r = await db
.select({ id: companies.id }) .select({ id: companies.id })
.from(companies) .from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId))) .where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1); .limit(1);
if (!r.length) throw new NotFoundError('Company'); 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 ───────────────────────────────────────────────────────────────── // ─── 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) { export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId); 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)) .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId)) .where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt)); .orderBy(desc(yachtNotes.createdAt));
} else { } else if (entityType === 'companies') {
return db return db
.select({ .select({
id: companyNotes.id, id: companyNotes.id,
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId)) .leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId)) .where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt)); .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 }; return { ...note, authorName };
} else { }
if (entityType === 'interests') {
const [note] = await db const [note] = await db
.insert(interestNotes) .insert(interestNotes)
.values({ interestId: entityId, authorId, content: data.content }) .values({ interestId: entityId, authorId, content: data.content })
@@ -247,6 +470,38 @@ export async function create(
return { ...note, authorName }; 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', { throw new CodedError('INTERNAL', {
internalMessage: `Unsupported entityType: ${entityType as string}`, internalMessage: `Unsupported entityType: ${entityType as string}`,
}); });
@@ -338,7 +593,65 @@ export async function update(
.limit(1); .limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null }; 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 const [existing] = await db
.select() .select()
.from(interestNotes) .from(interestNotes)
@@ -416,7 +729,45 @@ export async function deleteNote(
await db.delete(clientNotes).where(eq(clientNotes.id, noteId)); await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
return existing; 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 const [existing] = await db
.select() .select()
.from(interestNotes) .from(interestNotes)

View File

@@ -9,7 +9,13 @@ import { emitToRoom } from '@/lib/socket/server';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users'; import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
export async function listUsers(portId: string) { 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({ .select({
userId: userPortRoles.userId, userId: userPortRoles.userId,
displayName: userProfiles.displayName, displayName: userProfiles.displayName,
@@ -26,10 +32,28 @@ export async function listUsers(portId: string) {
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId)) .innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(user, eq(userPortRoles.userId, user.id)) .innerJoin(user, eq(userPortRoles.userId, user.id))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id)) .innerJoin(roles, eq(userPortRoles.roleId, roles.id))
.where(eq(userPortRoles.portId, portId)) .where(eq(userPortRoles.portId, portId));
.orderBy(userProfiles.displayName);
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, userId: row.userId,
displayName: row.displayName, displayName: row.displayName,
email: row.email, email: row.email,
@@ -39,7 +63,27 @@ export async function listUsers(portId: string) {
lastLoginAt: row.lastLoginAt, lastLoginAt: row.lastLoginAt,
role: { id: row.roleId, name: row.roleName }, role: { id: row.roleId, name: row.roleName },
assignedAt: row.assignedAt, 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) { 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'), pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(), leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(), source: z.string().optional(),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]), tagIds: z.array(z.string()).optional().default([]),
// Omitting reminderEnabled / reminderDays falls back to the per-port // Omitting reminderEnabled / reminderDays falls back to the per-port
// defaults configured at /admin/reminders (resolved in // defaults configured at /admin/reminders (resolved in
@@ -102,6 +101,27 @@ export const listInterestsSchema = baseListQuerySchema.extend({
.optional(), .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 ───────────────────────────────────────────────────────────── // ─── Waiting List ─────────────────────────────────────────────────────────────
export const waitingListAddSchema = z.object({ export const waitingListAddSchema = z.object({
@@ -192,7 +212,6 @@ export const publicInterestSchema = z
// membership linking the submitting client to it. // membership linking the submitting client to it.
company: publicCompanySchema.optional(), company: publicCompanySchema.optional(),
source: z.literal('website').default('website'), source: z.literal('website').default('website'),
notes: z.string().max(2000).optional(),
address: addressSchema.optional(), address: addressSchema.optional(),
}) })
.refine((data) => data.fullName || (data.firstName && data.lastName), { .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 { interface PipelineStore {
viewMode: 'board' | 'table'; viewMode: 'board' | 'table';
boardFilters: {
leadCategory?: string;
search?: string;
};
setViewMode: (mode: 'board' | 'table') => void; 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>()( export const usePipelineStore = create<PipelineStore>()(
persist( persist(
(set) => ({ (set) => ({
viewMode: 'table', viewMode: 'table',
boardFilters: {},
setViewMode: (mode) => set({ viewMode: mode }), setViewMode: (mode) => set({ viewMode: mode }),
setBoardFilter: (key, value) =>
set((s) => ({ boardFilters: { ...s.boardFilters, [key]: value } })),
clearBoardFilters: () => set({ boardFilters: {} }),
}), }),
{ {
name: 'pn-crm-pipeline', name: 'pn-crm-pipeline-v2',
partialize: (state) => ({ partialize: (state) => ({ viewMode: state.viewMode }),
viewMode: state.viewMode,
boardFilters: state.boardFilters,
}),
}, },
), ),
); );

View File

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

View File

@@ -6,6 +6,7 @@ import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/
import { import {
interests as interestsTable, interests as interestsTable,
interestBerths as interestBerthsTable, interestBerths as interestBerthsTable,
interestNotes as interestNotesTable,
} from '@/lib/db/schema/interests'; } from '@/lib/db/schema/interests';
import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates'; import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates';
@@ -39,7 +40,6 @@ async function insertInterest(args: {
berthId?: string | null; berthId?: string | null;
pipelineStage?: string; pipelineStage?: string;
leadCategory?: string; leadCategory?: string;
notes?: string;
}) { }) {
const [row] = await db const [row] = await db
.insert(interestsTable) .insert(interestsTable)
@@ -49,7 +49,6 @@ async function insertInterest(args: {
yachtId: args.yachtId ?? null, yachtId: args.yachtId ?? null,
pipelineStage: args.pipelineStage ?? 'open', pipelineStage: args.pipelineStage ?? 'open',
leadCategory: args.leadCategory ?? null, leadCategory: args.leadCategory ?? null,
notes: args.notes ?? null,
}) })
.returning(); .returning();
// Plan §3.4: legacy interest.berth_id was replaced by the // Plan §3.4: legacy interest.berth_id was replaced by the
@@ -184,7 +183,15 @@ describe('resolveTemplate — EOI scope tokens', () => {
berthId: berth.id, berthId: berth.id,
pipelineStage: 'in_communication', pipelineStage: 'in_communication',
leadCategory: 'tour', 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({ const tmpl = await insertTemplate({