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:
@@ -1,4 +1,5 @@
|
||||
import { BerthDetail } from '@/components/berths/berth-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
|
||||
interface BerthPageProps {
|
||||
params: Promise<{ portSlug: string; berthId: string }>;
|
||||
@@ -6,5 +7,10 @@ interface BerthPageProps {
|
||||
|
||||
export default async function BerthPage({ params }: BerthPageProps) {
|
||||
const { berthId } = await params;
|
||||
return <BerthDetail berthId={berthId} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="berth" id={berthId} />
|
||||
<BerthDetail berthId={berthId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ClientDetail } from '@/components/clients/client-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -12,5 +13,10 @@ export default async function ClientDetailPage({ params }: ClientDetailPageProps
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <ClientDetail clientId={clientId} currentUserId={currentUserId} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="client" id={clientId} />
|
||||
<ClientDetail clientId={clientId} currentUserId={currentUserId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CompanyDetail } from '@/components/companies/company-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -12,5 +13,10 @@ export default async function CompanyDetailPage({ params }: CompanyDetailPagePro
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <CompanyDetail companyId={companyId} currentUserId={currentUserId} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="company" id={companyId} />
|
||||
<CompanyDetail companyId={companyId} currentUserId={currentUserId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DocumentDetail } from '@/components/documents/document-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ portSlug: string; id: string }>;
|
||||
@@ -6,5 +7,10 @@ interface PageProps {
|
||||
|
||||
export default async function DocumentDetailPage({ params }: PageProps) {
|
||||
const { portSlug, id } = await params;
|
||||
return <DocumentDetail documentId={id} portSlug={portSlug} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="document" id={id} />
|
||||
<DocumentDetail documentId={id} portSlug={portSlug} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
||||
|
||||
import { ExpenseDetail } from '@/components/expenses/expense-detail';
|
||||
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { ExpenseRow } from '@/components/expenses/expense-columns';
|
||||
@@ -22,6 +23,7 @@ export default function ExpenseDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<TrackEntityView type="expense" id={params.id} />
|
||||
<ExpenseDetail
|
||||
expenseId={params.id}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
@@ -29,11 +31,7 @@ export default function ExpenseDetailPage() {
|
||||
/>
|
||||
|
||||
{data?.data && (
|
||||
<ExpenseFormDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
expense={data.data}
|
||||
/>
|
||||
<ExpenseFormDialog open={editOpen} onOpenChange={setEditOpen} expense={data.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InterestDetail } from '@/components/interests/interest-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -12,5 +13,10 @@ export default async function InterestDetailPage({ params }: InterestDetailPageP
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <InterestDetail interestId={interestId} currentUserId={currentUserId} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="interest" id={interestId} />
|
||||
<InterestDetail interestId={interestId} currentUserId={currentUserId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { use } from 'react';
|
||||
import { InvoiceDetail } from '@/components/invoices/invoice-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
|
||||
interface InvoiceDetailPageProps {
|
||||
params: Promise<{ portSlug: string; id: string }>;
|
||||
@@ -9,6 +10,7 @@ export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<TrackEntityView type="invoice" id={id} />
|
||||
<InvoiceDetail invoiceId={id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResidentialClientDetail } from '@/components/residential/residential-client-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -6,5 +7,10 @@ interface Props {
|
||||
|
||||
export default async function ResidentialClientDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialClientDetail clientId={id} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="residential-client" id={id} />
|
||||
<ResidentialClientDetail clientId={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResidentialInterestDetail } from '@/components/residential/residential-interest-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -6,5 +7,10 @@ interface Props {
|
||||
|
||||
export default async function ResidentialInterestDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ResidentialInterestDetail interestId={id} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="residential-interest" id={id} />
|
||||
<ResidentialInterestDetail interestId={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { YachtDetail } from '@/components/yachts/yacht-detail';
|
||||
import { TrackEntityView } from '@/components/search/track-entity-view';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
@@ -12,5 +13,10 @@ export default async function YachtDetailPage({ params }: YachtDetailPageProps)
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
|
||||
return (
|
||||
<>
|
||||
<TrackEntityView type="yacht" id={yachtId} />
|
||||
<YachtDetail yachtId={yachtId} currentUserId={currentUserId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,6 @@ export async function POST(req: NextRequest) {
|
||||
yachtId,
|
||||
source: 'website',
|
||||
pipelineStage: 'open',
|
||||
notes: data.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import { eq, and } from 'drizzle-orm';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { loadEntityActivity } from '@/lib/services/entity-activity.service';
|
||||
import {
|
||||
loadClientActivityAggregated,
|
||||
loadEntityActivity,
|
||||
} from '@/lib/services/entity-activity.service';
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (_req, ctx, params) => {
|
||||
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id;
|
||||
if (!id) throw new NotFoundError('client');
|
||||
@@ -18,11 +21,19 @@ export const GET = withAuth(
|
||||
.where(and(eq(clients.id, id), eq(clients.portId, ctx.portId)))
|
||||
.limit(1);
|
||||
if (exists.length === 0) throw new NotFoundError('client');
|
||||
const data = await loadEntityActivity({
|
||||
portId: ctx.portId,
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
});
|
||||
// ?aggregate=false opts out for callers that need just the
|
||||
// client-level audit log (no interest events). Default is on
|
||||
// since the Client overview Activity tab benefits from the
|
||||
// full timeline.
|
||||
const url = new URL(req.url);
|
||||
const aggregate = url.searchParams.get('aggregate') !== 'false';
|
||||
const data = aggregate
|
||||
? await loadClientActivityAggregated({ portId: ctx.portId, clientId: id })
|
||||
: await loadEntityActivity({
|
||||
portId: ctx.portId,
|
||||
entityType: 'client',
|
||||
entityId: id,
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
@@ -9,11 +9,18 @@ import { createNoteSchema } from '@/lib/validators/notes';
|
||||
import * as notesService from '@/lib/services/notes.service';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (_req, ctx, params) => {
|
||||
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const clientId = params.id;
|
||||
if (!clientId) throw new NotFoundError('Client');
|
||||
const notes = await notesService.listForEntity(ctx.portId, 'clients', clientId);
|
||||
// ?aggregate=true unions client-level notes with notes from
|
||||
// every interest + directly-owned yacht so the Notes tab on
|
||||
// the client overview shows the full timeline.
|
||||
const url = new URL(req.url);
|
||||
const aggregate = url.searchParams.get('aggregate') === 'true';
|
||||
const notes = aggregate
|
||||
? await notesService.listForClientAggregated(ctx.portId, clientId)
|
||||
: await notesService.listForEntity(ctx.portId, 'clients', clientId);
|
||||
return NextResponse.json({ data: notes });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
36
src/app/api/v1/contact-log/[id]/route.ts
Normal file
36
src/app/api/v1/contact-log/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
37
src/app/api/v1/interests/[id]/contact-log/route.ts
Normal file
37
src/app/api/v1/interests/[id]/contact-log/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
26
src/app/api/v1/interests/[id]/eoi-context/route.ts
Normal file
26
src/app/api/v1/interests/[id]/eoi-context/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
30
src/app/api/v1/interests/board/route.ts
Normal file
30
src/app/api/v1/interests/board/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -30,12 +30,34 @@ const updateProfileSchema = z.object({
|
||||
dark_mode: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
// Per-table column visibility. Keyed by entity type — entries
|
||||
// with an empty `hiddenColumns` mean "all visible". The validator
|
||||
// caps total entries / IDs so a malicious client can't bloat the
|
||||
// 8 KB preferences blob; see merge step below for the byte cap.
|
||||
tablePreferences: z
|
||||
.record(
|
||||
z.string().min(1).max(64),
|
||||
z
|
||||
.object({
|
||||
hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
// Hydrate preferences from user_profiles so the client can read its
|
||||
// saved table-column visibility (and other prefs) without a second
|
||||
// round-trip on app boot.
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
columns: { preferences: true, avatarFileId: true, avatarUrl: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
userId: ctx.userId,
|
||||
@@ -44,6 +66,11 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
permissions: ctx.permissions,
|
||||
isSuperAdmin: ctx.isSuperAdmin,
|
||||
user: ctx.user,
|
||||
preferences: profile?.preferences ?? {},
|
||||
profile: {
|
||||
avatarFileId: profile?.avatarFileId ?? null,
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -67,7 +94,7 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
// .passthrough(); the merge prunes them so legacy bloat doesn't
|
||||
// accumulate forever, and a future schema regression that tries
|
||||
// to ship arbitrary keys still gets dropped here at write time.
|
||||
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone']);
|
||||
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']);
|
||||
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
||||
const merged = Object.fromEntries(
|
||||
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
||||
|
||||
@@ -12,20 +12,100 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
|
||||
export type BerthRow = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
// Dimensions (both units; row falls back when one is null)
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
lengthM: string | null;
|
||||
widthM: string | null;
|
||||
draftM: string | null;
|
||||
widthIsMinimum: boolean | null;
|
||||
// Capacity
|
||||
nominalBoatSize: string | null;
|
||||
nominalBoatSizeM: string | null;
|
||||
waterDepth: string | null;
|
||||
waterDepthM: string | null;
|
||||
waterDepthIsMinimum: boolean | null;
|
||||
// Pontoon details (NocoDB)
|
||||
sidePontoon: string | null;
|
||||
mooringType: string | null;
|
||||
cleatType: string | null;
|
||||
cleatCapacity: string | null;
|
||||
bollardType: string | null;
|
||||
bollardCapacity: string | null;
|
||||
access: string | null;
|
||||
bowFacing: string | null;
|
||||
berthApproved: boolean | null;
|
||||
// Power
|
||||
powerCapacity: string | null;
|
||||
voltage: string | null;
|
||||
// Pricing
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
weeklyRateHighUsd: string | null;
|
||||
weeklyRateLowUsd: string | null;
|
||||
dailyRateHighUsd: string | null;
|
||||
dailyRateLowUsd: string | null;
|
||||
pricingValidUntil: string | null;
|
||||
// Tenure
|
||||
tenureType: string;
|
||||
tenureYears: number | null;
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggleable columns for the berth list ColumnPicker. Heavy NocoDB
|
||||
* fields default to hidden; reps can switch them on per-table-view.
|
||||
* `mooringNumber` is intentionally omitted from this list — it's the
|
||||
* primary identifier and always visible.
|
||||
*/
|
||||
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: 'area', label: 'Area' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'sidePontoon', label: 'Side / Pontoon' },
|
||||
{ id: 'dimensions', label: 'Dimensions' },
|
||||
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
|
||||
{ id: 'waterDepth', label: 'Water depth' },
|
||||
{ id: 'mooringType', label: 'Mooring type' },
|
||||
{ id: 'cleat', label: 'Cleat (type · capacity)' },
|
||||
{ id: 'bollard', label: 'Bollard (type · capacity)' },
|
||||
{ id: 'access', label: 'Access' },
|
||||
{ id: 'bowFacing', label: 'Bow facing' },
|
||||
{ id: 'berthApproved', label: 'Approved' },
|
||||
{ id: 'power', label: 'Power (kW · V)' },
|
||||
{ id: 'price', label: 'Price' },
|
||||
{ id: 'rates', label: 'Daily / Weekly rates' },
|
||||
{ id: 'pricingValidUntil', label: 'Pricing valid until' },
|
||||
{ id: 'tenure', label: 'Tenure' },
|
||||
{ id: 'tags', label: 'Tags' },
|
||||
];
|
||||
|
||||
/** Hidden by default — power-users turn them on via the picker. */
|
||||
export const BERTH_DEFAULT_HIDDEN: string[] = [
|
||||
'tenure',
|
||||
'sidePontoon',
|
||||
'nominalBoatSize',
|
||||
'waterDepth',
|
||||
'mooringType',
|
||||
'cleat',
|
||||
'bollard',
|
||||
'access',
|
||||
'bowFacing',
|
||||
'berthApproved',
|
||||
'power',
|
||||
'rates',
|
||||
'pricingValidUntil',
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variants: Record<string, string> = {
|
||||
available: 'bg-green-100 text-green-800 border-green-200',
|
||||
@@ -90,46 +170,167 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
|
||||
);
|
||||
}
|
||||
|
||||
function joinNonNull(parts: Array<string | null | undefined>, sep = ' · '): string {
|
||||
return parts.filter((p): p is string => Boolean(p)).join(sep);
|
||||
}
|
||||
|
||||
function formatMoney(amount: string | null, currency: string): string | null {
|
||||
if (!amount) return null;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(amount));
|
||||
}
|
||||
|
||||
export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'mooringNumber',
|
||||
header: 'Mooring #',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
|
||||
cell: ({ row }) => {
|
||||
const dot = mooringLetterDot(row.original.mooringNumber);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
{dot && <span className={`inline-block size-2 rounded-full ${dot}`} aria-hidden />}
|
||||
{row.original.mooringNumber}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'area',
|
||||
accessorKey: 'area',
|
||||
header: 'Area',
|
||||
cell: ({ row }) => row.original.area ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'sidePontoon',
|
||||
header: 'Side / Pontoon',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.sidePontoon ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'dimensions',
|
||||
header: 'Dimensions',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { lengthM, widthM } = row.original;
|
||||
const { lengthM, widthM, draftM, widthIsMinimum } = row.original;
|
||||
if (!lengthM && !widthM) return '-';
|
||||
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
|
||||
const widthLabel = widthM ? `${widthIsMinimum ? '≥' : ''}${widthM}m` : '?';
|
||||
const base = `${lengthM ?? '?'}m × ${widthLabel}`;
|
||||
return draftM ? `${base} (draft ${draftM}m)` : base;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nominalBoatSize',
|
||||
header: 'Boat size',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const m = row.original.nominalBoatSizeM;
|
||||
const ft = row.original.nominalBoatSize;
|
||||
if (!m && !ft) return '-';
|
||||
return m ? `${m}m` : `${ft}ft`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'waterDepth',
|
||||
header: 'Water depth',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { waterDepthM, waterDepthIsMinimum } = row.original;
|
||||
if (!waterDepthM) return '-';
|
||||
return `${waterDepthIsMinimum ? '≥' : ''}${waterDepthM}m`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mooringType',
|
||||
header: 'Mooring type',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.mooringType ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'cleat',
|
||||
header: 'Cleat',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => joinNonNull([row.original.cleatType, row.original.cleatCapacity]) || '-',
|
||||
},
|
||||
{
|
||||
id: 'bollard',
|
||||
header: 'Bollard',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => joinNonNull([row.original.bollardType, row.original.bollardCapacity]) || '-',
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
header: 'Access',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.access ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'bowFacing',
|
||||
header: 'Bow facing',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.bowFacing ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'berthApproved',
|
||||
header: 'Approved',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (row.original.berthApproved ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
id: 'power',
|
||||
header: 'Power',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const kw = row.original.powerCapacity;
|
||||
const v = row.original.voltage;
|
||||
if (!kw && !v) return '-';
|
||||
return joinNonNull([kw ? `${kw}kW` : null, v ? `${v}V` : null]);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'price',
|
||||
accessorKey: 'price',
|
||||
header: 'Price',
|
||||
cell: ({ row }) => formatMoney(row.original.price, row.original.priceCurrency) ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'rates',
|
||||
header: 'Rates (USD)',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const { price, priceCurrency } = row.original;
|
||||
if (!price) return '-';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: priceCurrency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(price));
|
||||
const { dailyRateLowUsd, dailyRateHighUsd, weeklyRateLowUsd, weeklyRateHighUsd } =
|
||||
row.original;
|
||||
const daily =
|
||||
dailyRateLowUsd && dailyRateHighUsd
|
||||
? `${dailyRateLowUsd}–${dailyRateHighUsd}/d`
|
||||
: dailyRateLowUsd
|
||||
? `${dailyRateLowUsd}/d`
|
||||
: null;
|
||||
const weekly =
|
||||
weeklyRateLowUsd && weeklyRateHighUsd
|
||||
? `${weeklyRateLowUsd}–${weeklyRateHighUsd}/wk`
|
||||
: weeklyRateLowUsd
|
||||
? `${weeklyRateLowUsd}/wk`
|
||||
: null;
|
||||
return joinNonNull([daily, weekly]) || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pricingValidUntil',
|
||||
header: 'Pricing valid',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => row.original.pricingValidUntil ?? '-',
|
||||
},
|
||||
{
|
||||
id: 'tenure',
|
||||
accessorKey: 'tenureType',
|
||||
header: 'Tenure',
|
||||
cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { Anchor } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { BerthCard } from './berth-card';
|
||||
import { berthColumns, type BerthRow } from './berth-columns';
|
||||
import {
|
||||
berthColumns,
|
||||
BERTH_COLUMN_OPTIONS,
|
||||
BERTH_DEFAULT_HIDDEN,
|
||||
type BerthRow,
|
||||
} from './berth-columns';
|
||||
import { berthFilterDefinitions } from './berth-filters';
|
||||
import { Anchor } from 'lucide-react';
|
||||
import { mooringLetterTone } from './mooring-letter-tone';
|
||||
|
||||
export function BerthList() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
|
||||
const { data, pagination, isLoading, sort, setSort, filters, setFilter, clearFilters, setPage } =
|
||||
usePaginatedQuery<BerthRow>({
|
||||
queryKey: ['berths'],
|
||||
endpoint: '/api/v1/berths',
|
||||
filterDefinitions: berthFilterDefinitions,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
setPage,
|
||||
setPageSize,
|
||||
} = usePaginatedQuery<BerthRow>({
|
||||
queryKey: ['berths'],
|
||||
endpoint: '/api/v1/berths',
|
||||
filterDefinitions: berthFilterDefinitions,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'berth:updated': [['berths']],
|
||||
'berth:statusChanged': [['berths']],
|
||||
});
|
||||
|
||||
// Persisted column visibility — same pattern as ClientList / InterestList.
|
||||
const { hidden, setHidden } = useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
@@ -46,21 +68,21 @@ export function BerthList() {
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<BerthRow>
|
||||
columns={berthColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
pagination={{
|
||||
@@ -69,11 +91,15 @@ export function BerthList() {
|
||||
total: pagination.total,
|
||||
totalPages: pagination.totalPages,
|
||||
}}
|
||||
onPaginationChange={(page) => setPage(page)}
|
||||
onPaginationChange={(page, pageSize) => {
|
||||
setPage(page);
|
||||
setPageSize(pageSize);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
getRowId={(row) => row.id}
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
||||
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
|
||||
50
src/components/berths/mooring-letter-tone.ts
Normal file
50
src/components/berths/mooring-letter-tone.ts
Normal 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];
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
||||
import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageDotClass, stageLabel } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ColumnPickerOption } from '@/components/shared/column-picker';
|
||||
|
||||
export interface ClientRow {
|
||||
id: string;
|
||||
@@ -24,24 +28,58 @@ export interface ClientRow {
|
||||
createdAt: string;
|
||||
primaryEmail?: string | null;
|
||||
primaryPhone?: string | null;
|
||||
/** E.164 (digits + leading +) — used to build wa.me / tel: links. */
|
||||
primaryPhoneE164?: string | null;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
interestCount?: number;
|
||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||
/**
|
||||
* Berths the client has interests in (active only) with the most-active
|
||||
* interest's stage attached. Sorted server-side: open deals first, most
|
||||
* progressed stage first, then mooring alphabetical. Each chip in the
|
||||
* list view links to the interest, not the berth — that's the action
|
||||
* sales reps want.
|
||||
*/
|
||||
linkedBerths?: Array<{
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
stage: string;
|
||||
outcome: string | null;
|
||||
}>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
qualified: 'Qualified',
|
||||
eoi_sent: 'EOI sent',
|
||||
eoi_signed: 'EOI signed',
|
||||
deposit: 'Deposit',
|
||||
contract: 'Contract',
|
||||
signed: 'Signed',
|
||||
closed_won: 'Won',
|
||||
closed_lost: 'Lost',
|
||||
};
|
||||
/**
|
||||
* Picker manifest — drives the `<ColumnPicker>` dropdown next to the
|
||||
* filter bar. Order here is the order shown in the menu. `alwaysVisible`
|
||||
* marks columns the user can't hide (otherwise the table is unusable).
|
||||
*
|
||||
* "Latest stage" used to be a default-on column, but each Berths chip
|
||||
* now carries its own per-interest stage (color dot + label), so the
|
||||
* standalone column was duplicating the same information. Kept in the
|
||||
* picker for users who want a single coarse "what's their most recent
|
||||
* stage" indicator regardless of berth.
|
||||
*/
|
||||
export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
|
||||
{ id: 'fullName', label: 'Name', alwaysVisible: true },
|
||||
{ id: 'email', label: 'Email' },
|
||||
{ id: 'phone', label: 'Phone' },
|
||||
{ id: 'country', label: 'Country' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
{ id: 'berths', label: 'Berths' },
|
||||
{ id: 'latestStage', label: 'Latest stage (legacy)' },
|
||||
{ id: 'createdAt', label: 'Created' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default-hidden columns for a fresh user. The hook merges this with
|
||||
* the user's saved overrides — once they explicitly toggle a column,
|
||||
* their choice wins. New columns surface for existing users by default
|
||||
* (they're absent from the user's stored hidden list).
|
||||
*/
|
||||
export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage'];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
@@ -83,7 +121,17 @@ export function getClientColumns({
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.primaryEmail;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
return (
|
||||
<a
|
||||
href={`mailto:${value}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
|
||||
title={`Email ${value}`}
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{value}</span>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,8 +140,38 @@ export function getClientColumns({
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.primaryPhone;
|
||||
const e164 = row.original.primaryPhoneE164;
|
||||
if (!value) return <span className="text-muted-foreground">-</span>;
|
||||
return <span className="text-sm">{value}</span>;
|
||||
// wa.me requires the E.164 digits without the leading +; fall
|
||||
// back to a tel: link when the contact hasn't been normalized
|
||||
// yet (legacy rows imported before the i18n PhoneInput shipped).
|
||||
const waDigits = e164 ? e164.replace(/[^0-9]/g, '') : null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<a
|
||||
href={e164 ? `tel:${e164}` : `tel:${value}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
|
||||
title={`Call ${value}`}
|
||||
>
|
||||
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span>{value}</span>
|
||||
</a>
|
||||
{waDigits && (
|
||||
<a
|
||||
href={`https://wa.me/${waDigits}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-emerald-600 hover:text-emerald-700"
|
||||
title={`WhatsApp ${value}`}
|
||||
aria-label={`WhatsApp ${value}`}
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -122,22 +200,88 @@ export function getClientColumns({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'berths',
|
||||
header: 'Berths',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const list = row.original.linkedBerths ?? [];
|
||||
if (list.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
// Show the 2 most-actionable interests inline (sorted server-
|
||||
// side: open before closed, most-progressed stage first). The
|
||||
// remainder collapses behind a "+N" popover so the row stays
|
||||
// single-line even for clients with many historical interests.
|
||||
const VISIBLE = 2;
|
||||
const head = list.slice(0, VISIBLE);
|
||||
const overflow = list.slice(VISIBLE);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{head.map((b) => (
|
||||
<BerthInterestChip key={b.id} berth={b} portSlug={portSlug} />
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
+{overflow.length}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 p-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
All linked berths
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{list.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/${portSlug}/interests/${b.interestId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
b.outcome ? 'bg-muted-foreground/40' : stageDotClass(b.stage),
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium text-foreground">{b.mooringNumber}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{b.outcome
|
||||
? `${stageLabel(b.stage)} · ${b.outcome.replace(/_/g, ' ')}`
|
||||
: stageLabel(b.stage)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden by default — the per-berth stage is now carried by each
|
||||
// chip in the Berths column, so this standalone column is only
|
||||
// useful when a user has explicitly toggled it on.
|
||||
id: 'latestStage',
|
||||
header: 'Latest stage',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const latest = row.original.latestInterest;
|
||||
if (!latest) return <span className="text-muted-foreground">-</span>;
|
||||
const stageLabel = STAGE_LABELS[latest.stage] ?? latest.stage;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{stageLabel}
|
||||
</Badge>
|
||||
{latest.mooringNumber && (
|
||||
<span className="text-muted-foreground">{latest.mooringNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stageLabel(latest.stage)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
@@ -91,6 +92,10 @@ export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
// Topbar breadcrumb hint: replaces "Clients › <uuid>" with
|
||||
// "Clients › Mary Smith". Hint clears on unmount.
|
||||
useBreadcrumbHint(data ? { parents: [], current: data.fullName } : null);
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'client:updated': [['clients', clientId]],
|
||||
'client:archived': [['clients', clientId]],
|
||||
|
||||
@@ -16,11 +16,12 @@ export const clientFilterDefinitions: FilterDefinition[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Referral', value: 'referral' },
|
||||
{ label: 'Broker', value: 'broker' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'nationality',
|
||||
label: 'Nationality',
|
||||
label: 'Country',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by nationality...',
|
||||
},
|
||||
|
||||
@@ -334,7 +334,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<Select
|
||||
value={watch('source') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')
|
||||
setValue('source', v as 'website' | 'manual' | 'referral' | 'broker' | 'other')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -345,6 +345,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
@@ -30,9 +31,16 @@ import {
|
||||
import { ClientForm } from '@/components/clients/client-form';
|
||||
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
||||
import { ClientCard } from '@/components/clients/client-card';
|
||||
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
||||
import {
|
||||
CLIENT_COLUMN_OPTIONS,
|
||||
CLIENT_DEFAULT_HIDDEN,
|
||||
getClientColumns,
|
||||
type ClientRow,
|
||||
} from '@/components/clients/client-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export function ClientList() {
|
||||
@@ -49,6 +57,7 @@ export function ClientList() {
|
||||
const [tagChoice, setTagChoice] = useState<string[]>([]);
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[]>([]);
|
||||
const [bulkArchiveIds, setBulkArchiveIds] = useState<string[]>([]);
|
||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||
|
||||
const { can } = usePermissions();
|
||||
const canHardDelete = can('admin', 'permanently_delete_clients');
|
||||
@@ -119,6 +128,13 @@ export function ClientList() {
|
||||
onArchive: (client) => setArchiveClient(client),
|
||||
});
|
||||
|
||||
// Per-user column visibility, persisted into user_profiles.preferences
|
||||
// via /api/v1/me. Hidden IDs are the source of truth — `actions` and
|
||||
// `select` columns aren't user-toggleable so they're never in the
|
||||
// hidden set. New columns surface for existing users by default.
|
||||
const { hidden, setHidden } = useTablePreferences('clients', CLIENT_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -144,20 +160,33 @@ export function ClientList() {
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="clients"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker
|
||||
columns={CLIENT_COLUMN_OPTIONS}
|
||||
hidden={hidden}
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveViewDialog
|
||||
open={saveViewOpen}
|
||||
onOpenChange={setSaveViewOpen}
|
||||
entityType="clients"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineCountryField } from '@/components/shared/inline-country-field';
|
||||
import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
@@ -35,6 +36,7 @@ const SOURCE_OPTIONS = [
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const CONTACT_METHOD_OPTIONS = [
|
||||
@@ -150,18 +152,36 @@ function OverviewTab({
|
||||
<EditableRow label="Full Name">
|
||||
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Nationality">
|
||||
<EditableRow label="Country">
|
||||
<InlineCountryField
|
||||
value={client.nationalityIso ?? null}
|
||||
onSave={async (iso) => {
|
||||
await mutation.mutateAsync({ nationalityIso: iso });
|
||||
// Auto-default the timezone to the country's primary
|
||||
// zone when none is set yet — saves the rep a click
|
||||
// and matches what a marina actually wants for first
|
||||
// contact (London for GB, NYC for US, etc.). Only
|
||||
// fires when timezone is empty so we never clobber a
|
||||
// value the rep deliberately picked.
|
||||
const patch: { nationalityIso: string | null; timezone?: string | null } = {
|
||||
nationalityIso: iso,
|
||||
};
|
||||
if (iso && !client.timezone) {
|
||||
const defaultTz = primaryTimezoneFor(iso as CountryCode);
|
||||
if (defaultTz) patch.timezone = defaultTz;
|
||||
}
|
||||
await mutation.mutateAsync(patch);
|
||||
}}
|
||||
data-testid="client-nationality-inline"
|
||||
data-testid="client-country-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
value={
|
||||
client.timezone ??
|
||||
(client.nationalityIso
|
||||
? primaryTimezoneFor(client.nationalityIso as CountryCode)
|
||||
: null)
|
||||
}
|
||||
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
|
||||
onSave={async (tz) => {
|
||||
await mutation.mutateAsync({ timezone: tz });
|
||||
@@ -267,7 +287,14 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
badge: client.noteCount,
|
||||
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
|
||||
@@ -110,7 +110,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||
GDPR export
|
||||
</Button>
|
||||
|
||||
@@ -69,6 +69,7 @@ export function PortalInviteButton({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpen(true);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
||||
import { CompanyDetailHeader } from '@/components/companies/company-detail-header';
|
||||
import { getCompanyTabs } from '@/components/companies/company-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { Address } from '@/components/shared/addresses-editor';
|
||||
|
||||
@@ -54,6 +55,8 @@ export function CompanyDetail({ companyId, currentUserId }: CompanyDetailProps)
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'company:updated': [['companies', companyId]],
|
||||
'company:archived': [['companies', companyId]],
|
||||
|
||||
@@ -127,8 +127,6 @@ export function CompanyList() {
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
|
||||
@@ -206,7 +206,7 @@ export function getCompanyTabs({
|
||||
},
|
||||
{
|
||||
id: 'members',
|
||||
label: 'Members',
|
||||
label: 'Contacts',
|
||||
content: <CompanyMembersTab companyId={companyId} portSlug={portSlug} />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -47,37 +48,40 @@ export function InlineStagePicker({
|
||||
}: InlineStagePickerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
||||
// When a user picks a stage that isn't a legal next step (and has the
|
||||
// override permission), the popover transitions into a confirm view
|
||||
// that asks for a reason before committing. Reasons are not exposed
|
||||
// for legal transitions — they're stored as audit-log notes on the
|
||||
// interest's history, accessible via the activity timeline.
|
||||
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
|
||||
const [overrideReason, setOverrideReason] = useState('');
|
||||
const { can } = usePermissions();
|
||||
const canOverride = can('interests', 'override_stage');
|
||||
|
||||
const stage = safeStage(currentStage);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: PipelineStage) => {
|
||||
// Auto-set override:true when the picked stage isn't a legal
|
||||
// transition AND the user has override_stage. Without this, the
|
||||
// permission was unreachable from the inline picker (audit R2-M7)
|
||||
// and users had to fall back to the modal InterestStagePicker.
|
||||
mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
|
||||
const needsOverride = !canTransitionStage(stage, next);
|
||||
const useOverride = needsOverride && canOverride;
|
||||
return apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
pipelineStage: next,
|
||||
reason: reason.trim() || (useOverride ? 'Manual override (inline)' : undefined),
|
||||
reason: reason ?? (useOverride ? 'Manual override (inline)' : undefined),
|
||||
override: useOverride || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, next) => {
|
||||
onSuccess: (_data, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setOpen(false);
|
||||
setReason('');
|
||||
setOverrideTarget(null);
|
||||
setOverrideReason('');
|
||||
setPendingStage(null);
|
||||
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
|
||||
toast.success(`Stage moved to ${STAGE_LABELS[vars.next]}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setPendingStage(null);
|
||||
@@ -90,15 +94,40 @@ export function InlineStagePicker({
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const isOverride = !canTransitionStage(stage, next);
|
||||
if (isOverride && canOverride) {
|
||||
// Switch into the confirm view rather than firing the mutation
|
||||
// immediately — overrides bypass the transition guard so a reason
|
||||
// is genuinely useful for the audit trail.
|
||||
setOverrideTarget(next);
|
||||
setOverrideReason('');
|
||||
return;
|
||||
}
|
||||
setPendingStage(next);
|
||||
mutation.mutate(next);
|
||||
mutation.mutate({ next, reason: null });
|
||||
}
|
||||
|
||||
function commitOverride() {
|
||||
if (!overrideTarget) return;
|
||||
setPendingStage(overrideTarget);
|
||||
mutation.mutate({
|
||||
next: overrideTarget,
|
||||
reason: overrideReason.trim() || 'Manual override (inline)',
|
||||
});
|
||||
}
|
||||
|
||||
function cancelOverride() {
|
||||
setOverrideTarget(null);
|
||||
setOverrideReason('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!mutation.isPending) setOpen(o);
|
||||
if (mutation.isPending) return;
|
||||
setOpen(o);
|
||||
if (!o) cancelOverride();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -125,54 +154,122 @@ export function InlineStagePicker({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 p-0"
|
||||
className="w-72 p-0"
|
||||
onClick={(e) => stopPropagation && e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Reason (optional)…"
|
||||
rows={1}
|
||||
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const isCurrent = s === stage;
|
||||
const isPending = pendingStage === s && mutation.isPending;
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => pick(s)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'transition-colors hover:bg-muted/60 disabled:opacity-60',
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) - turns
|
||||
the picker into a visual scan rather than just a list. */}
|
||||
<span
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{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'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
|
||||
id="stage-override-reason"
|
||||
value={overrideReason}
|
||||
onChange={(e) => setOverrideReason(e.target.value)}
|
||||
placeholder="e.g. Skipping EOI, client signed contract directly"
|
||||
rows={2}
|
||||
className="mt-1 text-sm"
|
||||
disabled={mutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelOverride}
|
||||
disabled={mutation.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={commitOverride}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="size-3.5 animate-spin mr-1" />}
|
||||
Confirm override
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Default view: just the stage list. No upfront textarea —
|
||||
// earlier UX put a "Reason (optional)…" field at the top
|
||||
// which read as visually noisy for the >90% of changes that
|
||||
// are normal transitions and never get a reason attached.
|
||||
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const isCurrent = s === stage;
|
||||
const isPending = pendingStage === s && mutation.isPending;
|
||||
const isOverride = s !== stage && !canTransitionStage(stage, s);
|
||||
const blockedByPermission = isOverride && !canOverride;
|
||||
return (
|
||||
<li key={s}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
disabled={mutation.isPending || blockedByPermission}
|
||||
onClick={() => pick(s)}
|
||||
title={
|
||||
blockedByPermission
|
||||
? `Override required (you don't have permission)`
|
||||
: isOverride
|
||||
? 'Non-standard transition — confirm step required'
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
|
||||
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isCurrent && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{/* Colored chip (mirrors the inline stage badge) — turns
|
||||
the picker into a visual scan rather than just a list. */}
|
||||
<span
|
||||
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1">{STAGE_LABELS[s]}</span>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isCurrent ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : isOverride && canOverride ? (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide text-amber-600"
|
||||
title="Override required"
|
||||
>
|
||||
⚑
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -72,6 +72,29 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggleable columns for the InterestList ColumnPicker. `actions` and
|
||||
* `clientName` are intentionally omitted from this list — actions is a
|
||||
* row-control column that should never be hidden, and clientName is the
|
||||
* primary entity identifier (a row with no name has no useful purpose).
|
||||
*/
|
||||
export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: 'yachtName', label: 'Yacht' },
|
||||
{ id: 'berthMooringNumber', label: 'Berth' },
|
||||
{ id: 'desiredSize', label: 'Desired size' },
|
||||
{ id: 'pipelineStage', label: 'Stage' },
|
||||
{ id: 'eoiStatus', label: 'EOI status' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
{ id: 'dateLastContact', label: 'Last contact' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Columns hidden by default for users who haven't customised their view.
|
||||
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default —
|
||||
* power-users can turn them back on via the column picker.
|
||||
*/
|
||||
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
|
||||
|
||||
const EOI_STATUS_LABELS: Record<string, { label: string; tone: string }> = {
|
||||
waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' },
|
||||
signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' },
|
||||
@@ -176,7 +199,10 @@ export function getInterestColumns({
|
||||
const stage = row.original.pipelineStage;
|
||||
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${row.original.id}`}
|
||||
className="flex flex-col gap-1 items-start hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||||
>
|
||||
@@ -196,7 +222,7 @@ export function getInterestColumns({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
435
src/components/interests/interest-contact-log-tab.tsx
Normal file
435
src/components/interests/interest-contact-log-tab.tsx
Normal 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 “Last contact”
|
||||
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())}`;
|
||||
}
|
||||
416
src/components/interests/interest-contract-tab.tsx
Normal file
416
src/components/interests/interest-contract-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
MessageCircle,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -24,7 +23,6 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
|
||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
|
||||
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
|
||||
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -104,7 +102,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||
const [externalEoiOpen, setExternalEoiOpen] = useState(false);
|
||||
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
@@ -221,7 +219,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
<InlineStagePicker
|
||||
interestId={interest.id}
|
||||
currentStage={interest.pipelineStage}
|
||||
className="-ml-2.5"
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
@@ -379,20 +376,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
</>
|
||||
)}
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="documents" action="upload_signed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExternalEoiOpen(true)}
|
||||
aria-label="Upload externally-signed EOI"
|
||||
title="Upload externally-signed EOI (paper / outside Documenso)"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
{/* The "Upload paper-signed EOI" button used to live here.
|
||||
It's now on the dedicated EOI tab (in both the active-EOI
|
||||
hero and the empty-state CTA row), where it sits next to
|
||||
the document it relates to. The header was a shotgun of
|
||||
actions that didn't all belong; collecting them per-tab
|
||||
is the cleaner UX. */}
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<button
|
||||
type="button"
|
||||
@@ -456,12 +445,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
}}
|
||||
isLoading={archiveMutation.isPending || restoreMutation.isPending}
|
||||
/>
|
||||
|
||||
<ExternalEoiUploadDialog
|
||||
open={externalEoiOpen}
|
||||
onOpenChange={setExternalEoiOpen}
|
||||
interestId={interest.id}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { InterestDetailHeader } from '@/components/interests/interest-detail-hea
|
||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface InterestData {
|
||||
@@ -102,7 +103,30 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
|
||||
// Topbar breadcrumb: Clients › Mary Smith › Interest › B17.
|
||||
// Parent client links straight back to the client detail; the
|
||||
// current crumb is the primary berth's mooring (or "Interest" if
|
||||
// no berth linked yet — same trick the page H1 uses).
|
||||
useBreadcrumbHint(
|
||||
data
|
||||
? {
|
||||
parents:
|
||||
data.clientId && data.clientName
|
||||
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
|
||||
: [],
|
||||
current: data.berthMooringNumber ?? 'Interest',
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const tabs = data
|
||||
? getInterestTabs({
|
||||
interestId,
|
||||
currentUserId,
|
||||
clientId: data.clientId ?? null,
|
||||
interest: data,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileSignature } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentList } from '@/components/documents/document-list';
|
||||
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
|
||||
import { FileGrid, type FileRow } from '@/components/files/file-grid';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface InterestDocumentsTabProps {
|
||||
@@ -15,70 +21,151 @@ interface InterestDocumentsTabProps {
|
||||
|
||||
interface InterestData {
|
||||
id: string;
|
||||
yachtId?: string | null;
|
||||
berthId?: string | null;
|
||||
clientName?: string | null;
|
||||
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientHasAddress?: boolean;
|
||||
clientId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents tab — legal instruments (EOI / contract / reservation) with
|
||||
* full signing status, plus an Attachments section for any other file the
|
||||
* rep wants on the deal. Replaces the standalone Files tab — at the
|
||||
* interest level virtually everything is either a legal doc or rare
|
||||
* one-off, and a separate tab was dead weight 95% of the time.
|
||||
*/
|
||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
|
||||
// Same query key + queryFn shape as InterestDetail's parent query, so the
|
||||
// cache is consistent. (Mismatched shapes on the same key clobber each other
|
||||
// and the parent header degenerates to "Unknown Client".)
|
||||
const { data: interest } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const prerequisites = {
|
||||
// Required (EOI Section 2 - top paragraph): name, address, email.
|
||||
hasName: Boolean(interest?.clientName),
|
||||
hasEmail: Boolean(interest?.clientPrimaryEmail),
|
||||
hasAddress: Boolean(interest?.clientHasAddress),
|
||||
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
|
||||
hasYacht: Boolean(interest?.yachtId),
|
||||
hasBerth: Boolean(interest?.berthId),
|
||||
// Files attach at the client level (the schema has no interest_id
|
||||
// FK on `files`). For an interest, surface every file that belongs
|
||||
// to its parent client — covers the realistic case where a rep
|
||||
// uploaded a passport / scan / photo while working a deal.
|
||||
// Until the interest record loads we pass a sentinel clientId so the
|
||||
// server returns empty rather than the unscoped port-wide file list.
|
||||
const clientId = interest?.clientId ?? '__pending__';
|
||||
const filesQueryKey = ['files', { clientId }] as const;
|
||||
const { data: files, isLoading: filesLoading } = usePaginatedQuery<FileRow>({
|
||||
queryKey: filesQueryKey,
|
||||
endpoint: `/api/v1/files?clientId=${encodeURIComponent(clientId)}`,
|
||||
filterDefinitions: [],
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'file:uploaded': [filesQueryKey],
|
||||
'file:updated': [filesQueryKey],
|
||||
'file:deleted': [filesQueryKey],
|
||||
});
|
||||
|
||||
const handleDownload = async (file: FileRow) => {
|
||||
try {
|
||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||
`/api/v1/files/${file.id}/download`,
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = res.data.url;
|
||||
a.download = res.data.filename;
|
||||
a.click();
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
<DocumentList
|
||||
interestId={interestId}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||
<FileSignature className="size-5" />
|
||||
const hasAttachments = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Legal documents</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentList
|
||||
interestId={interestId}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
|
||||
<FileSignature className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">No documents yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate the EOI to send it for signing in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">No documents yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate the EOI to send it for signing in one click.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
|
||||
{hasAttachments ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{files.length} file{files.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PermissionGate resource="files" action="upload">
|
||||
{interest?.clientId ? (
|
||||
<FileUploadZone
|
||||
entityType="client"
|
||||
entityId={interest.clientId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</PermissionGate>
|
||||
|
||||
{hasAttachments ? (
|
||||
<FileGrid
|
||||
files={files}
|
||||
onDownload={handleDownload}
|
||||
onPreview={setPreviewFile}
|
||||
onRename={() => {}}
|
||||
onDelete={handleDelete}
|
||||
isLoading={filesLoading}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
clientId={interest?.clientId ?? null}
|
||||
open={eoiDialogOpen}
|
||||
onOpenChange={setEoiDialogOpen}
|
||||
prerequisites={prerequisites}
|
||||
/>
|
||||
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
362
src/components/interests/interest-eoi-tab.tsx
Normal file
362
src/components/interests/interest-eoi-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Referral', value: 'referral' },
|
||||
{ label: 'Broker', value: 'broker' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -62,7 +61,6 @@ interface InterestFormProps {
|
||||
pipelineStage: string;
|
||||
leadCategory?: string | null;
|
||||
source?: string | null;
|
||||
notes?: string | null;
|
||||
reminderEnabled?: boolean;
|
||||
reminderDays?: number | null;
|
||||
tags?: Array<{ id: string }>;
|
||||
@@ -130,7 +128,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
|
||||
source: interest.source ?? undefined,
|
||||
notes: interest.notes ?? undefined,
|
||||
reminderEnabled: interest.reminderEnabled ?? false,
|
||||
reminderDays: interest.reminderDays ?? undefined,
|
||||
tagIds: interest.tags?.map((t) => t.id) ?? [],
|
||||
@@ -457,18 +454,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
{...register('notes')}
|
||||
placeholder="Add notes about this interest..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reminder */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
|
||||
@@ -25,7 +25,15 @@ import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { PipelineBoard } from '@/components/interests/pipeline-board';
|
||||
import { interestFilterDefinitions } from '@/components/interests/interest-filters';
|
||||
import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns';
|
||||
import {
|
||||
getInterestColumns,
|
||||
INTEREST_COLUMN_OPTIONS,
|
||||
INTEREST_DEFAULT_HIDDEN,
|
||||
type InterestRow,
|
||||
} from '@/components/interests/interest-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { InterestCard } from '@/components/interests/interest-card';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import {
|
||||
@@ -58,6 +66,7 @@ export function InterestList() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
|
||||
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
|
||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||
|
||||
// Bulk-action dialog state
|
||||
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
|
||||
@@ -134,6 +143,12 @@ export function InterestList() {
|
||||
onArchive: (interest) => setArchiveInterest(interest),
|
||||
});
|
||||
|
||||
// Persisted per-user column visibility — same pattern as ClientList.
|
||||
// The hidden array is the source of truth; built columns stay
|
||||
// declared and we drive table visibility via columnVisibility.
|
||||
const { hidden, setHidden } = useTablePreferences('interests', INTEREST_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -171,8 +186,19 @@ export function InterestList() {
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* On the kanban view we strip filters that don't make sense
|
||||
* there: `pipelineStage` (the columns ARE the stages) and
|
||||
* `includeArchived` (the board is for active deals — the
|
||||
* list view is the place to see history). The board endpoint
|
||||
* rejects these via boardFiltersSchema if they're sent. */}
|
||||
<FilterBar
|
||||
filters={interestFilterDefinitions}
|
||||
filters={
|
||||
viewMode === 'board'
|
||||
? interestFilterDefinitions.filter(
|
||||
(f) => f.key !== 'pipelineStage' && f.key !== 'includeArchived',
|
||||
)
|
||||
: interestFilterDefinitions
|
||||
}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
@@ -188,24 +214,44 @@ export function InterestList() {
|
||||
placeholder="Filter by tag / event…"
|
||||
/>
|
||||
</div>
|
||||
<SavedViewsDropdown
|
||||
entityType="interests"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
{/* Columns + saved views are table-only concepts; the kanban
|
||||
* always shows the same compact card across every stage so
|
||||
* hiding both controls in board mode keeps the toolbar honest. */}
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<SavedViewsDropdown
|
||||
entityType="interests"
|
||||
onApplyView={(savedFilters) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker
|
||||
columns={INTEREST_COLUMN_OPTIONS}
|
||||
hidden={hidden}
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SaveViewDialog
|
||||
open={saveViewOpen}
|
||||
onOpenChange={setSaveViewOpen}
|
||||
entityType="interests"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
/>
|
||||
|
||||
{viewMode === 'board' ? (
|
||||
<PipelineBoard />
|
||||
<PipelineBoard filters={filters} />
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
|
||||
419
src/components/interests/interest-reservation-tab.tsx
Normal file
419
src/components/interests/interest-reservation-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,12 +17,20 @@ import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-
|
||||
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
|
||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
||||
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
|
||||
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import {
|
||||
LEAD_CATEGORIES,
|
||||
PIPELINE_STAGES,
|
||||
canTransitionStage,
|
||||
type PipelineStage,
|
||||
} from '@/lib/constants';
|
||||
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
|
||||
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
|
||||
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
|
||||
type InterestPatchField = 'leadCategory' | 'source';
|
||||
|
||||
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
@@ -37,6 +46,9 @@ function humanizeStatus(value: string | null): string | null {
|
||||
interface InterestTabsOptions {
|
||||
interestId: string;
|
||||
currentUserId?: string;
|
||||
/** Used by the dedicated EOI tab to deep-link to the client's record
|
||||
* for inline edits ("wrong details? edit on the client's page"). */
|
||||
clientId?: string | null;
|
||||
interest: {
|
||||
pipelineStage: string;
|
||||
/** Drives the recommender panel mounted on the Overview tab. */
|
||||
@@ -59,6 +71,9 @@ interface InterestTabsOptions {
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
/** Count of berths linked via the interest_berths junction —
|
||||
* drives the "Berth Interest" milestone on the Overview tab. */
|
||||
linkedBerthCount?: number;
|
||||
notes: string | null;
|
||||
/** Surfaced by getInterestById for the Overview "most recent note"
|
||||
* teaser - saves a click into the Notes tab to peek at the latest. */
|
||||
@@ -87,13 +102,23 @@ function useInterestPatch(interestId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type Phase = 'past' | 'current' | 'future';
|
||||
|
||||
function useStageMutation(interestId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ stage, reason }: { stage: string; reason?: string }) =>
|
||||
mutationFn: async ({
|
||||
stage,
|
||||
reason,
|
||||
override,
|
||||
}: {
|
||||
stage: string;
|
||||
reason?: string;
|
||||
override?: boolean;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: stage, reason },
|
||||
body: { pipelineStage: stage, reason, override },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
@@ -278,6 +303,73 @@ function MilestoneSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible wrapper for future-phase milestones. Hidden by default so
|
||||
* the overview stays focused on the current stage; expanding lets reps
|
||||
* record skipped milestones (the action click then routes through the
|
||||
* advance() override-confirm).
|
||||
*/
|
||||
function FutureMilestones({
|
||||
milestones,
|
||||
stageMutation,
|
||||
advance,
|
||||
activeMilestone,
|
||||
currentStage,
|
||||
}: {
|
||||
milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
status: string | null;
|
||||
steps: MilestoneSectionProps['steps'];
|
||||
footer?: React.ReactNode;
|
||||
}>;
|
||||
stageMutation: ReturnType<typeof useStageMutation>;
|
||||
advance: (stage: string) => void;
|
||||
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
|
||||
currentStage: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{expanded ? 'Hide' : 'Show'} upcoming milestones
|
||||
<span className="ml-2 text-xs">({milestones.map((m) => m.title).join(' · ')})</span>
|
||||
</span>
|
||||
<span className="text-xs">{expanded ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4 p-4 pt-0',
|
||||
milestones.length === 1 ? '' : 'lg:grid-cols-2',
|
||||
)}
|
||||
>
|
||||
{milestones.map((m) => (
|
||||
<MilestoneSection
|
||||
key={m.key}
|
||||
title={m.title}
|
||||
icon={m.icon}
|
||||
status={m.status}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={currentStage}
|
||||
isActive={activeMilestone === m.key}
|
||||
steps={m.steps}
|
||||
footer={m.footer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
interestId,
|
||||
interest,
|
||||
@@ -292,25 +384,98 @@ function OverviewTab({
|
||||
const save = (field: InterestPatchField) => async (next: string | null) => {
|
||||
await mutation.mutateAsync({ [field]: next });
|
||||
};
|
||||
const advance = (stage: string) =>
|
||||
stageMutation.mutate({ stage, reason: 'Marked from overview' });
|
||||
/**
|
||||
* Advance the pipeline. When the requested target isn't a legal next
|
||||
* step (e.g. user clicked "Mark deposit received" while still on
|
||||
* EOI Sent), prompt for confirmation and pass `override:true` so the
|
||||
* backend transition guard lets the change through. Mirrors the
|
||||
* skip-ahead pattern from the inline stage picker so audit trails
|
||||
* stay consistent regardless of which surface the rep used.
|
||||
*/
|
||||
const advance = (stage: string) => {
|
||||
const fromStage = interest.pipelineStage as PipelineStage;
|
||||
const toStage = stage as PipelineStage;
|
||||
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
|
||||
if (isOverride) {
|
||||
const ok = window.confirm(
|
||||
`This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace(
|
||||
/_/g,
|
||||
' ',
|
||||
)}", which isn't a standard next step. Continue?\n\nThe change will be flagged in the audit log.`,
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
stageMutation.mutate({
|
||||
stage,
|
||||
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
|
||||
override: isOverride || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
|
||||
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
|
||||
// Determine each milestone's phase relative to the current pipeline
|
||||
// stage. The overview hides future-phase milestones by default — it
|
||||
// was visually noisy to see Deposit + Contract cards on a deal still
|
||||
// at the EOI stage, and the empty cards invited mis-clicks.
|
||||
//
|
||||
// Past milestones still render (collapsed history) so reps can see
|
||||
// what's been completed. Future milestones are gated behind a "Show
|
||||
// upcoming milestones" toggle so the rep CAN reach them when a deal
|
||||
// genuinely skips stages — the click then routes through the same
|
||||
// override-confirm flow as the inline stage picker.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
|
||||
if (stageIdx === -1 || stageIdx >= contractSignedIdx) {
|
||||
activeMilestone = null;
|
||||
} else if (stageIdx < eoiSignedIdx) {
|
||||
activeMilestone = 'eoi';
|
||||
} else if (stageIdx < depositIdx) {
|
||||
activeMilestone = 'deposit';
|
||||
} else {
|
||||
activeMilestone = 'contract';
|
||||
}
|
||||
|
||||
const phaseFor = (milestoneEndStageIdx: number): Phase => {
|
||||
if (stageIdx === -1) return 'future';
|
||||
if (stageIdx >= milestoneEndStageIdx) return 'past';
|
||||
// The "current" milestone is the one whose end-stage hasn't been
|
||||
// reached and whose start-stage is at-or-before the current stage.
|
||||
return 'current';
|
||||
};
|
||||
// Berth Interest milestone — first thing the rep needs to capture
|
||||
// (especially for general_interest leads). Completes the moment ANY
|
||||
// berth is linked to the interest via the junction. While unset, it
|
||||
// sits as the "current" milestone unless the deal has already moved
|
||||
// past EOI sent (in which case the rep clearly didn't need a berth
|
||||
// pinned first, so we mark it 'past' implicitly).
|
||||
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
|
||||
const berthInterestPhase: Phase = hasLinkedBerth
|
||||
? 'past'
|
||||
: stageIdx === -1 || stageIdx >= eoiSignedIdx
|
||||
? 'past'
|
||||
: 'current';
|
||||
|
||||
const eoiPhase = phaseFor(eoiSignedIdx);
|
||||
// Deposit is current once the EOI is signed but before deposit is in.
|
||||
const depositPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= depositIdx
|
||||
? 'past'
|
||||
: stageIdx >= eoiSignedIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
const contractPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= contractSignedIdx
|
||||
? 'past'
|
||||
: stageIdx >= depositIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
|
||||
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
|
||||
berthInterestPhase === 'current'
|
||||
? 'berth_interest'
|
||||
: eoiPhase === 'current'
|
||||
? 'eoi'
|
||||
: depositPhase === 'current'
|
||||
? 'deposit'
|
||||
: contractPhase === 'current'
|
||||
? 'contract'
|
||||
: null;
|
||||
|
||||
const toNum = (v: string | null | undefined): number | null => {
|
||||
if (v === null || v === undefined) return null;
|
||||
@@ -318,100 +483,190 @@ function OverviewTab({
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
const milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
phase: Phase;
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
status: string | null;
|
||||
steps: MilestoneSectionProps['steps'];
|
||||
footer?: React.ReactNode;
|
||||
/** Brief one-liner shown when the milestone is in the past-strip. */
|
||||
pastSummary: React.ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'berth_interest',
|
||||
phase: berthInterestPhase,
|
||||
title: 'Berth Interest',
|
||||
icon: Anchor,
|
||||
// No status badge — the count IS the status. Showing "0 berths"
|
||||
// would just duplicate the empty-state copy below.
|
||||
status: hasLinkedBerth
|
||||
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'}`
|
||||
: null,
|
||||
// No advanceStage step — the milestone tracks a state (berths
|
||||
// linked) rather than a stage transition. Hide the row chrome by
|
||||
// passing an empty steps array; the footer renders the action.
|
||||
steps: [],
|
||||
footer:
|
||||
berthInterestPhase === 'current' ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Add a berth from the Recommendations tab or the client's active interest panel to
|
||||
mark this milestone complete.
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: hasLinkedBerth
|
||||
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'} linked`
|
||||
: 'Skipped',
|
||||
},
|
||||
{
|
||||
key: 'eoi',
|
||||
phase: eoiPhase,
|
||||
title: 'EOI',
|
||||
icon: Send,
|
||||
status: interest.eoiStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'EOI sent',
|
||||
date: interest.dateEoiSent,
|
||||
advanceStage: 'eoi_sent',
|
||||
actionLabel: 'Mark EOI as sent',
|
||||
},
|
||||
{
|
||||
label: 'EOI signed',
|
||||
date: interest.dateEoiSigned,
|
||||
advanceStage: 'eoi_signed',
|
||||
actionLabel: 'Mark EOI as signed',
|
||||
},
|
||||
],
|
||||
pastSummary: interest.dateEoiSigned
|
||||
? `Signed ${formatDate(interest.dateEoiSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
{
|
||||
key: 'deposit',
|
||||
phase: depositPhase,
|
||||
title: 'Deposit',
|
||||
icon: Wallet,
|
||||
status: interest.depositStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'Deposit received',
|
||||
date: interest.dateDepositReceived,
|
||||
advanceStage: 'deposit_10pct',
|
||||
hideAutoButton: true,
|
||||
},
|
||||
],
|
||||
footer:
|
||||
depositPhase === 'current' && !interest.dateDepositReceived ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
||||
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
|
||||
<Plus className="size-3.5" />
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advance('deposit_10pct')}
|
||||
disabled={stageMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Mark received manually
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: interest.dateDepositReceived
|
||||
? `Received ${formatDate(interest.dateDepositReceived)}`
|
||||
: 'Recorded',
|
||||
},
|
||||
{
|
||||
key: 'contract',
|
||||
phase: contractPhase,
|
||||
title: 'Contract',
|
||||
icon: FileSignature,
|
||||
status: interest.contractStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'Contract sent',
|
||||
date: interest.dateContractSent,
|
||||
advanceStage: 'contract_sent',
|
||||
actionLabel: 'Mark contract as sent',
|
||||
},
|
||||
{
|
||||
label: 'Contract signed',
|
||||
date: interest.dateContractSigned,
|
||||
advanceStage: 'contract_signed',
|
||||
actionLabel: 'Mark contract as signed',
|
||||
},
|
||||
],
|
||||
pastSummary: interest.dateContractSigned
|
||||
? `Signed ${formatDate(interest.dateContractSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
];
|
||||
|
||||
const pastMilestones = milestones.filter((m) => m.phase === 'past');
|
||||
const currentMilestones = milestones.filter((m) => m.phase === 'current');
|
||||
const futureMilestones = milestones.filter((m) => m.phase === 'future');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sales-process milestones - the heart of the system. Each section is a
|
||||
mini lifecycle that auto-completes as actions happen on the platform
|
||||
(Documenso webhook, paid deposit invoice, signed contract). Until the
|
||||
automation lands, salespeople nudge stages forward via the inline
|
||||
buttons here, which auto-stamp the milestone date server-side. */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<MilestoneSection
|
||||
title="EOI"
|
||||
icon={Send}
|
||||
status={interest.eoiStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
{/* Sales-process milestones — phase-aware so the user only sees
|
||||
what's actionable now. Past milestones collapse into a tight
|
||||
history strip; the current milestone gets the full card; future
|
||||
milestones are hidden behind a toggle so reps can still
|
||||
skip-ahead when reality calls for it (an override-confirm
|
||||
gates the actual stage move). */}
|
||||
{pastMilestones.length > 0 && (
|
||||
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
|
||||
{pastMilestones.map((m) => (
|
||||
<span key={m.key} className="inline-flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3 text-emerald-600" />
|
||||
<span className="font-medium text-foreground">{m.title}</span>
|
||||
<span>·</span>
|
||||
<span>{m.pastSummary}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentMilestones.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
|
||||
)}
|
||||
>
|
||||
{currentMilestones.map((m) => (
|
||||
<MilestoneSection
|
||||
key={m.key}
|
||||
title={m.title}
|
||||
icon={m.icon}
|
||||
status={m.status}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === m.key}
|
||||
steps={m.steps}
|
||||
footer={m.footer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{futureMilestones.length > 0 && (
|
||||
<FutureMilestones
|
||||
milestones={futureMilestones}
|
||||
stageMutation={stageMutation}
|
||||
advance={advance}
|
||||
activeMilestone={activeMilestone}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'eoi'}
|
||||
steps={[
|
||||
{
|
||||
label: 'EOI sent',
|
||||
date: interest.dateEoiSent,
|
||||
advanceStage: 'eoi_sent',
|
||||
actionLabel: 'Mark EOI as sent',
|
||||
},
|
||||
{
|
||||
label: 'EOI signed',
|
||||
date: interest.dateEoiSigned,
|
||||
advanceStage: 'eoi_signed',
|
||||
actionLabel: 'Mark EOI as signed',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<MilestoneSection
|
||||
title="Deposit"
|
||||
icon={Wallet}
|
||||
status={interest.depositStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'deposit'}
|
||||
steps={[
|
||||
{
|
||||
label: 'Deposit received',
|
||||
date: interest.dateDepositReceived,
|
||||
advanceStage: 'deposit_10pct',
|
||||
// The richer invoice-first CTA lives in `footer`. We still pass
|
||||
// advanceStage so the milestone derives its done-state correctly.
|
||||
hideAutoButton: true,
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
!interest.dateDepositReceived ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
||||
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
|
||||
<Plus className="size-3.5" />
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advance('deposit_10pct')}
|
||||
disabled={stageMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Mark received manually
|
||||
</button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<MilestoneSection
|
||||
title="Contract"
|
||||
icon={FileSignature}
|
||||
status={interest.contractStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'contract'}
|
||||
steps={[
|
||||
{
|
||||
label: 'Contract sent',
|
||||
date: interest.dateContractSent,
|
||||
advanceStage: 'contract_sent',
|
||||
actionLabel: 'Mark contract as sent',
|
||||
},
|
||||
{
|
||||
label: 'Contract signed',
|
||||
date: interest.dateContractSigned,
|
||||
advanceStage: 'contract_signed',
|
||||
actionLabel: 'Mark contract as signed',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Lead & Source (editable) */}
|
||||
@@ -460,19 +715,22 @@ function OverviewTab({
|
||||
|
||||
{/* Most-recent threaded note teaser. Saves a click into the Notes
|
||||
tab when the rep just wants to peek at "what was discussed last."
|
||||
Hidden when there's nothing to show. */}
|
||||
{interest.recentNote ? (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Latest note</h3>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}
|
||||
</Link>
|
||||
</div>
|
||||
Always rendered now that the redundant `interests.notes` blob is
|
||||
gone — falls back to an empty-state prompt so reps still have an
|
||||
obvious entry point to the Notes tab from Overview. */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Latest note</h3>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
{interest.recentNote
|
||||
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
|
||||
: 'Add note'}
|
||||
</Link>
|
||||
</div>
|
||||
{interest.recentNote ? (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
|
||||
{interest.recentNote.content}
|
||||
@@ -486,18 +744,11 @@ function OverviewTab({
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : 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 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>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -533,14 +784,39 @@ function OverviewTab({
|
||||
export function getInterestTabs({
|
||||
interestId,
|
||||
currentUserId,
|
||||
clientId = null,
|
||||
interest,
|
||||
}: InterestTabsOptions): DetailTab[] {
|
||||
return [
|
||||
// The EOI / Contract / Reservation tabs are stage-conditional —
|
||||
// each appears only at the stages where the rep is likely to act
|
||||
// on it. Hides clutter from later-stage deals where earlier docs
|
||||
// are ancient history. Each tab still queries for its own past
|
||||
// documents; if a deal regresses the past docs remain accessible
|
||||
// via the generic Documents tab.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
// EOI: from details_sent through contract_signed (the deal's whole life)
|
||||
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
|
||||
// Contract: appears once the deposit's been paid (deal is committed)
|
||||
// and stays visible until the contract is signed
|
||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
|
||||
// Reservation: appears once the contract's signed and stays visible
|
||||
// through completion (reservation is the post-contract milestone)
|
||||
const showReservationTab = stageIdx >= contractSignedIdx;
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab interestId={interestId} interest={interest} />,
|
||||
},
|
||||
{
|
||||
id: 'contact-log',
|
||||
label: 'Contact log',
|
||||
content: <InterestContactLogTab interestId={interestId} />,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
@@ -548,16 +824,38 @@ export function getInterestTabs({
|
||||
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (showEoiTab) {
|
||||
tabs.push({
|
||||
id: 'eoi',
|
||||
label: 'EOI',
|
||||
content: <InterestEoiTab interestId={interestId} clientId={clientId} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (showContractTab) {
|
||||
tabs.push({
|
||||
id: 'contract',
|
||||
label: 'Contract',
|
||||
content: <InterestContractTab interestId={interestId} clientId={clientId} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (showReservationTab) {
|
||||
tabs.push({
|
||||
id: 'reservation',
|
||||
label: 'Reservation',
|
||||
content: <InterestReservationTab interestId={interestId} clientId={clientId} />,
|
||||
});
|
||||
}
|
||||
|
||||
tabs.push(
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
content: <InterestDocumentsTab interestId={interestId} />,
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
content: <InterestFilesTab interestId={interestId} />,
|
||||
},
|
||||
{
|
||||
id: 'recommendations',
|
||||
label: 'Recommendations',
|
||||
@@ -568,5 +866,7 @@ export function getInterestTabs({
|
||||
label: 'Activity',
|
||||
content: <InterestTimeline interestId={interestId} />,
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@@ -305,32 +305,39 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`specific-${row.berthId}`} className="text-sm font-medium">
|
||||
Specifically pitching
|
||||
</Label>
|
||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||
flexed to the far right via justify-between — when the
|
||||
column is wide, justify-between created a confusing visual
|
||||
gulf between the action and what it controls. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`specific-${row.berthId}`}
|
||||
checked={row.isSpecificInterest}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`specific-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Specifically pitching
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium">
|
||||
Mark in EOI bundle
|
||||
</Label>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`bundle-${row.berthId}`}
|
||||
checked={row.isInEoiBundle}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
|
||||
/>
|
||||
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
|
||||
Mark in EOI bundle
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
|
||||
@@ -7,8 +7,8 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
|
||||
|
||||
import { PipelineColumn } from '@/components/interests/pipeline-column';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePipelineStore } from '@/stores/pipeline-store';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
|
||||
interface InterestRow {
|
||||
id: string;
|
||||
@@ -19,28 +19,77 @@ interface InterestRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function PipelineBoard() {
|
||||
interface BoardResponse {
|
||||
data: InterestRow[];
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface PipelineBoardProps {
|
||||
/** Filter values from the parent's FilterBar — passed through to the
|
||||
* /api/v1/interests/board endpoint. Subset of listInterests filters
|
||||
* (no pipelineStage, no includeArchived). Optional; board works
|
||||
* fine without filters. */
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const { boardFilters } = usePipelineStore();
|
||||
|
||||
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
|
||||
queryKey: ['interests-board', portSlug],
|
||||
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
|
||||
// Build the board endpoint URL with the supported filter subset.
|
||||
// pipelineStage + includeArchived are intentionally not threaded
|
||||
// through — see boardFiltersSchema on the backend. Stable JSON-string
|
||||
// form is reused as the queryKey so React Query caches per filter combo.
|
||||
const queryString = useMemo(() => {
|
||||
if (!filters) return '';
|
||||
const params = new URLSearchParams();
|
||||
const pick = (k: string) => {
|
||||
const v = filters[k];
|
||||
if (v === null || v === undefined || v === '' || v === false) return;
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return;
|
||||
params.set(k, v.join(','));
|
||||
} else {
|
||||
params.set(k, String(v));
|
||||
}
|
||||
};
|
||||
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}`),
|
||||
});
|
||||
|
||||
const interests = useMemo(() => {
|
||||
if (!allData?.data) return [];
|
||||
return allData.data.filter((i) => {
|
||||
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
|
||||
if (boardFilters.search) {
|
||||
const q = boardFilters.search.toLowerCase();
|
||||
if (!i.clientName?.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allData, boardFilters]);
|
||||
// Invalidate the entire ['interests-board', portSlug, *] family so
|
||||
// realtime events refresh whatever filter combo is currently active.
|
||||
// Using the prefix keeps stale per-filter caches from lingering after
|
||||
// the underlying data changes elsewhere in the app.
|
||||
useRealtimeInvalidation({
|
||||
'interest:created': [['interests-board', portSlug]],
|
||||
'interest:updated': [['interests-board', portSlug]],
|
||||
'interest:stageChanged': [['interests-board', portSlug]],
|
||||
'interest:archived': [['interests-board', portSlug]],
|
||||
});
|
||||
|
||||
const interests = useMemo(() => allData?.data ?? [], [allData]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, InterestRow[]> = {};
|
||||
@@ -98,8 +147,31 @@ export function PipelineBoard() {
|
||||
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
|
||||
}
|
||||
|
||||
// Surface fetch failures instead of silently rendering nine "Empty"
|
||||
// columns, which is indistinguishable from "no interests yet" and was
|
||||
// exactly the bug that hid this view's silent failure for so long.
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
|
||||
Couldn't load the pipeline board.{' '}
|
||||
<button
|
||||
className="underline underline-offset-2"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: boardQueryKey })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
{allData?.truncated ? (
|
||||
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
|
||||
deals aren't on the board — archive completed work to keep the kanban readable.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{PIPELINE_STAGES.map((stage) => (
|
||||
<PipelineColumn
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useBreadcrumbStore } from '@/stores/breadcrumb-store';
|
||||
|
||||
// Human-readable labels for route segments
|
||||
const SEGMENT_LABELS: Record<string, string> = {
|
||||
@@ -51,7 +52,8 @@ function formatSegment(segment: string): string {
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname();
|
||||
const { currentPort, currentPortSlug } = usePortContext();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const hint = useBreadcrumbStore((s) => s.hints[pathname]);
|
||||
|
||||
// Split pathname and filter empty segments
|
||||
const rawSegments = pathname.split('/').filter(Boolean);
|
||||
@@ -62,22 +64,10 @@ export function Breadcrumbs() {
|
||||
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
|
||||
).filter((seg) => !isIdSegment(seg));
|
||||
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-foreground font-medium">
|
||||
{currentPort?.name ?? 'Port Nimara CRM'}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
// Build href for each segment
|
||||
const crumbs = segments.map((segment, index) => {
|
||||
// Build href for each segment from the URL.
|
||||
const urlCrumbs = segments.map((segment, index) => {
|
||||
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
|
||||
const href = '/' + segmentsUpToHere.join('/');
|
||||
const label = formatSegment(segment);
|
||||
@@ -86,35 +76,37 @@ export function Breadcrumbs() {
|
||||
return { label, href, isLast };
|
||||
});
|
||||
|
||||
// When a detail page registered a hint, splice in the parent crumbs
|
||||
// (e.g. the parent client name) and replace the trailing label with
|
||||
// the entity's actual name (e.g. "B17"). This turns the URL-only
|
||||
// "Clients › Interests" into "Clients › Mary Smith › Interest › B17"
|
||||
// when the rep clicked from a client page. URL-only renders untouched
|
||||
// when no hint is registered.
|
||||
const crumbs = (() => {
|
||||
if (!hint) return urlCrumbs;
|
||||
const head = urlCrumbs.slice(0, -1).map((c) => ({ ...c, isLast: false }));
|
||||
const parents = hint.parents.map((p) => ({
|
||||
label: p.label,
|
||||
href: p.href ?? pathname,
|
||||
isLast: false,
|
||||
}));
|
||||
const lastUrlCrumb = urlCrumbs[urlCrumbs.length - 1];
|
||||
const tail = {
|
||||
label: hint.current,
|
||||
href: lastUrlCrumb?.href ?? pathname,
|
||||
isLast: true,
|
||||
};
|
||||
return [...head, ...parents, tail];
|
||||
})();
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{currentPort && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${currentPortSlug}/dashboard` as any}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{currentPort.name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{crumbs.length > 0 && (
|
||||
<BreadcrumbSeparator>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<BreadcrumbList className="text-sm gap-1.5">
|
||||
{crumbs.map((crumb, _index) => (
|
||||
<Fragment key={crumb.href}>
|
||||
<BreadcrumbItem>
|
||||
{crumb.isLast ? (
|
||||
<BreadcrumbPage className="font-medium text-foreground">
|
||||
<BreadcrumbPage className="font-medium text-foreground truncate max-w-[160px]">
|
||||
{crumb.label}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
@@ -122,7 +114,7 @@ export function Breadcrumbs() {
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={crumb.href as any}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 truncate max-w-[160px]"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
@@ -130,7 +122,7 @@ export function Breadcrumbs() {
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{!crumb.isLast && (
|
||||
<BreadcrumbSeparator>
|
||||
<BreadcrumbSeparator className="text-muted-foreground/40">
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
BellRing,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileText,
|
||||
Globe,
|
||||
Home,
|
||||
Mail,
|
||||
@@ -43,7 +42,6 @@ const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Inbox', icon: Mail, segment: 'email' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
|
||||
@@ -123,7 +123,10 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
|
||||
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
|
||||
// Invoices nav entry removed — the expense-to-PDF flow is the
|
||||
// only invoicing surface now (employee expense reports). The
|
||||
// standalone /invoices route still exists for any back-compat
|
||||
// links but is no longer surfaced in nav.
|
||||
// Dedicated explainer page covers "Add to Home Screen" install
|
||||
// + walkthrough; the mobile-only scanner UI itself lives at /scan
|
||||
// and is reached via the install or the explainer page button.
|
||||
@@ -224,6 +227,7 @@ function SidebarContent({
|
||||
collapsed,
|
||||
portSlug,
|
||||
portRoles,
|
||||
isSuperAdmin,
|
||||
hasAdminAccess,
|
||||
hasMarinaAccess,
|
||||
hasResidentialAccess,
|
||||
@@ -234,6 +238,7 @@ function SidebarContent({
|
||||
collapsed: boolean;
|
||||
portSlug: string | undefined;
|
||||
portRoles: SidebarProps['portRoles'];
|
||||
isSuperAdmin: boolean;
|
||||
hasAdminAccess: boolean;
|
||||
hasMarinaAccess: boolean;
|
||||
hasResidentialAccess: boolean;
|
||||
@@ -246,6 +251,14 @@ function SidebarContent({
|
||||
const [adminExpanded, setAdminExpanded] = useState(true);
|
||||
const sections = buildNavSections(portSlug);
|
||||
|
||||
// Small label under the user identity when the user has access to more
|
||||
// than one port — disambiguates which port is currently active without
|
||||
// pulling the port name back into the breadcrumbs.
|
||||
const showPortLabel = !!ports && ports.length > 1;
|
||||
const currentPortName = showPortLabel
|
||||
? (ports.find((p) => p.slug === portSlug)?.name ?? null)
|
||||
: null;
|
||||
|
||||
// Pre-compute every nav href the sidebar offers across all sections so
|
||||
// the active-state check can do longest-prefix-match. Without this,
|
||||
// /invoices/upload-receipts would highlight both "Invoices" and "How to
|
||||
@@ -403,8 +416,11 @@ function SidebarContent({
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
|
||||
>
|
||||
{humanizeRole(portRoles[0]?.role?.name)}
|
||||
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
|
||||
</Badge>
|
||||
{currentPortName && (
|
||||
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
@@ -417,8 +433,10 @@ function SidebarContent({
|
||||
}
|
||||
|
||||
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
|
||||
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
|
||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||
// Sidebar collapse removed — design preference is the always-expanded
|
||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||
// any code still reading it.
|
||||
const sidebarCollapsed = false;
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
// Super admins see every section regardless of role rows.
|
||||
@@ -448,12 +466,12 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
|
||||
collapsed={sidebarCollapsed}
|
||||
portSlug={currentPortSlug ?? undefined}
|
||||
portRoles={portRoles}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
hasAdminAccess={hasAdminAccess}
|
||||
hasMarinaAccess={hasMarinaAccess}
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
user={user}
|
||||
ports={ports}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChevronLeft, Plus } from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -28,16 +30,46 @@ interface TopbarProps {
|
||||
|
||||
export function Topbar({ ports, user }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const base = currentPortSlug ? `/${currentPortSlug}` : '';
|
||||
// Reuse the existing per-page chrome state (originally built for the
|
||||
// mobile topbar) so any detail page that already declares
|
||||
// `showBackButton: true` automatically gets the back affordance on
|
||||
// desktop too. Saves duplicating the wiring across N detail headers.
|
||||
const { showBackButton: mobileShowBack } = useMobileChrome();
|
||||
// Auto-show on entity-detail pages: `/[portSlug]/[section]/[id]` and
|
||||
// deeper. Top-level lists like `/[portSlug]/clients` stay clean.
|
||||
// The mobile-chrome flag still wins when a page explicitly opts in.
|
||||
// Pages that already render their own "back to X" link inline
|
||||
// (residential interest detail, expense scan flow, etc.) opt OUT
|
||||
// by setting the chrome flag to false on mount — the flag override
|
||||
// path here lets them suppress this auto-show.
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const isDeepPage = segments.length > 2;
|
||||
const showBackButton = mobileShowBack || isDeepPage;
|
||||
|
||||
return (
|
||||
// Three-column grid: breadcrumbs left, search center, actions right.
|
||||
// The brand logo lives in the sidebar header (per design feedback) so the
|
||||
// topbar center is dedicated to the global search bar.
|
||||
<header className="grid h-14 grid-cols-[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* LEFT: breadcrumbs / page title */}
|
||||
<div className="min-w-0">
|
||||
{/* LEFT: optional back button + breadcrumbs / page title */}
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label="Go back"
|
||||
title="Go back"
|
||||
className={cn(
|
||||
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-accent transition-colors',
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
@@ -100,8 +132,12 @@ export function Topbar({ ports, user }: TopbarProps) {
|
||||
user={user}
|
||||
ports={ports}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<Avatar className="w-7 h-7 shadow-sm ring-2 ring-background">
|
||||
// Button shrunk to match the Avatar's visible footprint so
|
||||
// the hover halo lands as a tight circle behind the avatar
|
||||
// (was h-11 w-11 default — the rounded-full halo extended
|
||||
// well past the visible avatar and read as a square glow).
|
||||
<Button variant="ghost" className="rounded-full h-9 w-9 p-0">
|
||||
<Avatar className="w-7 h-7">
|
||||
<AvatarImage src={undefined} />
|
||||
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
|
||||
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
|
||||
|
||||
133
src/components/shared/column-picker.tsx
Normal file
133
src/components/shared/column-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
|
||||
|
||||
@@ -51,6 +52,12 @@ interface DataTableProps<TData> {
|
||||
isLoading?: boolean;
|
||||
getRowId?: (row: TData) => string;
|
||||
onRowClick?: (row: TData) => void;
|
||||
/**
|
||||
* Optional row class hook — return a string of Tailwind utilities
|
||||
* applied to the `<TableRow>`. Use for visual grouping (e.g. tinting
|
||||
* berths by mooring letter so the kanban-like grid reads at a glance).
|
||||
*/
|
||||
getRowClassName?: (row: TData) => string | undefined;
|
||||
/**
|
||||
* Mobile card renderer. When provided, the table is hidden below `lg:`
|
||||
* and replaced with a vertical list of cards built from this callback.
|
||||
@@ -58,6 +65,13 @@ interface DataTableProps<TData> {
|
||||
* sort, and selection stay in sync across the breakpoint.
|
||||
*/
|
||||
cardRender?: (row: Row<TData>) => React.ReactNode;
|
||||
/**
|
||||
* Per-column visibility map. Keys are column IDs, values mean
|
||||
* "currently visible". Columns absent from the map are visible by
|
||||
* default — newly-added columns surface for existing users without
|
||||
* needing a preferences migration.
|
||||
*/
|
||||
columnVisibility?: VisibilityState;
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
@@ -74,7 +88,9 @@ export function DataTable<TData>({
|
||||
isLoading,
|
||||
getRowId,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
cardRender,
|
||||
columnVisibility,
|
||||
}: DataTableProps<TData>) {
|
||||
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
||||
const rowSelectionState = externalSelection ?? internalSelection;
|
||||
@@ -122,6 +138,7 @@ export function DataTable<TData>({
|
||||
pagination: pagination
|
||||
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
|
||||
: undefined,
|
||||
columnVisibility,
|
||||
},
|
||||
onRowSelectionChange: (updater) => {
|
||||
const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater;
|
||||
@@ -215,7 +232,7 @@ export function DataTable<TData>({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={cn(onRowClick && 'cursor-pointer')}
|
||||
className={cn(onRowClick && 'cursor-pointer', getRowClassName?.(row.original))}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
@@ -247,34 +264,61 @@ export function DataTable<TData>({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{/* Pagination — render whenever pagination is defined so the
|
||||
page-size selector is reachable even on single-page tables.
|
||||
Prev/Next group only renders when there's actually more than
|
||||
one page. */}
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between px-2 gap-3 flex-wrap">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedIds.length > 0
|
||||
? `${selectedIds.length} of ${pagination.total} row(s) selected`
|
||||
: `${pagination.total} row(s) total`}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Page-size selector — "All" maps to the validator's
|
||||
max(1000) cap. If a port has more than 1000 active rows
|
||||
the user paginates; we don't quietly drop rows. */}
|
||||
<label className="text-sm text-muted-foreground inline-flex items-center gap-1.5">
|
||||
Show
|
||||
<select
|
||||
value={String(pagination.pageSize)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
|
||||
onPaginationChange?.(1, next);
|
||||
}}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page <= 1}
|
||||
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -184,16 +184,22 @@ function FilterField({
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
case 'select': {
|
||||
// Radix Select forbids empty-string item values (throws at render
|
||||
// time, crashes the page). Use a sentinel and translate.
|
||||
const ANY = '__any__';
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{definition.label}</Label>
|
||||
<Select value={(value as string) ?? ''} onValueChange={(v) => onChange(v || undefined)}>
|
||||
<Select
|
||||
value={(value as string) || ANY}
|
||||
onValueChange={(v) => onChange(v === ANY ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={definition.placeholder ?? 'Any'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Any</SelectItem>
|
||||
<SelectItem value={ANY}>Any</SelectItem>
|
||||
{definition.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -203,6 +209,7 @@ function FilterField({
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'multi-select':
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { ChevronDown, Loader2, Pencil } from 'lucide-react';
|
||||
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -98,33 +98,25 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
}
|
||||
|
||||
if (props.variant === 'select') {
|
||||
// Picker fields don't need a read/edit mode toggle — a Select is
|
||||
// already a click-to-open control. Rendering one consistent
|
||||
// SelectTrigger eliminates the width jump that happened when we
|
||||
// swapped from a content-sized ReadButton to a w-full SelectTrigger
|
||||
// on click (and back again on selection). The trigger now stays at
|
||||
// a single width whether the popover is open or closed, so the
|
||||
// dropdown menu visually aligns to the same control across states.
|
||||
const labelFor = (v: string | null | undefined) =>
|
||||
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={labelFor(value)}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Select
|
||||
value={draft}
|
||||
value={value ?? ''}
|
||||
onValueChange={(v) => void commit(v)}
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !saving) setEditing(false);
|
||||
}}
|
||||
disabled={disabled || saving}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-sm w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
<SelectTrigger className="h-8 text-sm w-full">
|
||||
<SelectValue placeholder={emptyText}>{labelFor(value) ?? emptyText}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((o) => (
|
||||
@@ -228,6 +220,7 @@ function ReadButton({
|
||||
disabled,
|
||||
onClick,
|
||||
multiline,
|
||||
kind = 'text',
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
@@ -235,8 +228,13 @@ function ReadButton({
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
multiline?: boolean;
|
||||
/** Icon affordance: 'text' shows a pencil (free-text edit), 'select'
|
||||
* shows a chevron-down (fixed-list picker). The chevron clarifies
|
||||
* that clicking opens a dropdown, not a text input. */
|
||||
kind?: 'text' | 'select';
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = kind === 'select' ? ChevronDown : Pencil;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -246,6 +244,9 @@ function ReadButton({
|
||||
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
|
||||
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
|
||||
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
// Select-kind buttons get a faint border so they read as a
|
||||
// distinct interactive element rather than text-with-an-icon.
|
||||
kind === 'select' && 'border border-border bg-background hover:bg-accent',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
@@ -260,11 +261,14 @@ function ReadButton({
|
||||
{value ?? emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Pencil
|
||||
<Icon
|
||||
className={cn(
|
||||
// Show the pencil faintly at rest so users discover the field is
|
||||
// editable without having to hover-and-test every label.
|
||||
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
|
||||
// Pencil sits faintly so users discover free-text editability
|
||||
// on hover; chevron is more present so the dropdown affordance
|
||||
// is obvious before any interaction.
|
||||
kind === 'select'
|
||||
? 'h-3.5 w-3.5 opacity-60 transition-opacity group-hover:opacity-100'
|
||||
: 'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
|
||||
multiline && 'mt-1 shrink-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -18,32 +18,72 @@ interface Note {
|
||||
isLocked: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Aggregated-mode only: which child entity this note came from. */
|
||||
source?: 'client' | 'interest' | 'yacht';
|
||||
sourceId?: string;
|
||||
sourceLabel?: string;
|
||||
}
|
||||
|
||||
interface NotesListProps {
|
||||
entityType: 'clients' | 'interests' | 'yachts' | 'companies';
|
||||
entityType:
|
||||
| 'clients'
|
||||
| 'interests'
|
||||
| 'yachts'
|
||||
| 'companies'
|
||||
| 'residential_clients'
|
||||
| 'residential_interests';
|
||||
entityId: string;
|
||||
currentUserId?: string;
|
||||
/**
|
||||
* When `entityType='clients'` and this is true, the list aggregates
|
||||
* notes from the client + their interests + directly-owned yachts.
|
||||
* Notes from interests/yachts render with a source chip and are
|
||||
* read-only here (edit them on the source entity's page).
|
||||
*/
|
||||
aggregate?: boolean;
|
||||
}
|
||||
|
||||
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export function NotesList({ entityType, entityId, currentUserId }: NotesListProps) {
|
||||
/** Sort by source then chronologically inside each source.
|
||||
* Used by the aggregated view's "Group by source" toggle. */
|
||||
function sortByGroup(notes: Note[]): Note[] {
|
||||
const sourceOrder: Record<string, number> = { client: 0, interest: 1, yacht: 2 };
|
||||
return [...notes].sort((a, b) => {
|
||||
const aRank = sourceOrder[a.source ?? 'client'] ?? 99;
|
||||
const bRank = sourceOrder[b.source ?? 'client'] ?? 99;
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
const aLabel = a.sourceLabel ?? '';
|
||||
const bLabel = b.sourceLabel ?? '';
|
||||
if (aLabel !== bLabel) return aLabel.localeCompare(bLabel);
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export function NotesList({ entityType, entityId, currentUserId, aggregate }: NotesListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [groupBySource, setGroupBySource] = useState(false);
|
||||
|
||||
const endpoint = `/api/v1/${entityType}/${entityId}/notes`;
|
||||
const queryKey = [entityType, entityId, 'notes'];
|
||||
const aggregateOn = aggregate && entityType === 'clients';
|
||||
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
|
||||
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
|
||||
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
|
||||
|
||||
const { data: notes = [], isLoading } = useQuery<Note[]>({
|
||||
queryKey,
|
||||
queryFn: () => apiFetch<{ data: Note[] }>(endpoint).then((r) => r.data),
|
||||
queryFn: () => apiFetch<{ data: Note[] }>(listEndpoint).then((r) => r.data),
|
||||
});
|
||||
|
||||
// Mutations always target the parent entity (client). Aggregated
|
||||
// notes from interests/yachts are read-only here — the rep edits
|
||||
// them on the source entity's page (we surface a "Open source" link
|
||||
// below). Keeping mutations against `baseEndpoint` keeps the POST
|
||||
// route handler clean.
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
|
||||
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setNewNote('');
|
||||
@@ -52,7 +92,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
||||
apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
||||
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
setEditingId(null);
|
||||
@@ -60,13 +100,17 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||
});
|
||||
|
||||
function canEdit(note: Note): boolean {
|
||||
if (note.authorId !== currentUserId) return false;
|
||||
if (note.isLocked) return false;
|
||||
// Aggregated view: only client-level notes are editable in-place.
|
||||
// Notes from interests/yachts must be edited on their own page so
|
||||
// the right entity timeline records the change.
|
||||
if (aggregateOn && note.source && note.source !== 'client') return false;
|
||||
const elapsed = Date.now() - new Date(note.createdAt).getTime();
|
||||
return elapsed < NOTE_EDIT_WINDOW_MS;
|
||||
}
|
||||
@@ -105,6 +149,29 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aggregated-mode controls — sort toggle. Only renders when
|
||||
* aggregation is on and there's actually content to group. */}
|
||||
{aggregateOn && notes.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
||||
<span>View:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={!groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
||||
onClick={() => setGroupBySource(false)}
|
||||
>
|
||||
Chronological
|
||||
</button>
|
||||
<span>·</span>
|
||||
<button
|
||||
type="button"
|
||||
className={groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
||||
onClick={() => setGroupBySource(true)}
|
||||
>
|
||||
Group by source
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes list */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
|
||||
@@ -112,7 +179,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => (
|
||||
{(groupBySource ? sortByGroup(notes) : notes).map((note) => (
|
||||
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className="text-xs">
|
||||
@@ -120,11 +187,23 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span className="font-medium">{note.authorName ?? 'User'}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
{aggregateOn && note.source && note.source !== 'client' && note.sourceLabel && (
|
||||
<span
|
||||
className={
|
||||
note.source === 'interest'
|
||||
? 'inline-flex items-center rounded-full bg-blue-100 text-blue-900 px-1.5 py-0.5 text-[10px] font-medium'
|
||||
: 'inline-flex items-center rounded-full bg-emerald-100 text-emerald-900 px-1.5 py-0.5 text-[10px] font-medium'
|
||||
}
|
||||
title={`From ${note.source}`}
|
||||
>
|
||||
{note.source === 'interest' ? 'Interest' : 'Yacht'} · {note.sourceLabel}
|
||||
</span>
|
||||
)}
|
||||
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" />}
|
||||
{canEdit(note) && (
|
||||
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
||||
|
||||
82
src/components/shared/save-view-dialog.tsx
Normal file
82
src/components/shared/save-view-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Bookmark, Check, Plus, Trash2 } from 'lucide-react';
|
||||
import { Bookmark, Check, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useSavedViews } from '@/hooks/use-saved-views';
|
||||
|
||||
interface SavedViewsDropdownProps {
|
||||
entityType: string;
|
||||
currentFilters: Record<string, unknown>;
|
||||
currentSort?: { field: string; direction: 'asc' | 'desc' };
|
||||
onApplyView: (filters: Record<string, unknown>, sort?: { field: string; direction: string }) => void;
|
||||
onApplyView: (
|
||||
filters: Record<string, unknown>,
|
||||
sort?: { field: string; direction: string },
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function SavedViewsDropdown({
|
||||
entityType,
|
||||
currentFilters,
|
||||
currentSort,
|
||||
onApplyView,
|
||||
}: SavedViewsDropdownProps) {
|
||||
const { views, activeViewId, saveCurrentView, deleteView, applyView } =
|
||||
useSavedViews(entityType);
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [viewName, setViewName] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
/**
|
||||
* Read-only browser for saved views. The "Save current view" affordance
|
||||
* has moved into the ColumnPicker menu (see SaveViewDialog). This
|
||||
* component renders nothing when the user has no saved views — the
|
||||
* Bookmark button on its own is just visual noise until something has
|
||||
* been saved.
|
||||
*/
|
||||
export function SavedViewsDropdown({ entityType, onApplyView }: SavedViewsDropdownProps) {
|
||||
const { views, activeViewId, deleteView, applyView } = useSavedViews(entityType);
|
||||
|
||||
async function handleSave() {
|
||||
if (!viewName.trim()) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await saveCurrentView(viewName.trim(), currentFilters, currentSort);
|
||||
setSaveOpen(false);
|
||||
setViewName('');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
if (views.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<Bookmark className="mr-1.5 h-3.5 w-3.5" />
|
||||
Views
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{views.length === 0 ? (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
|
||||
No saved views yet
|
||||
</div>
|
||||
) : (
|
||||
views.map((view) => (
|
||||
<DropdownMenuItem
|
||||
key={view.id}
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
applyView(view.id);
|
||||
onApplyView(
|
||||
view.filters as Record<string, unknown>,
|
||||
view.sortConfig as { field: string; direction: string } | undefined,
|
||||
);
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<Bookmark className="mr-1.5 h-3.5 w-3.5" />
|
||||
Views
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{views.map((view) => (
|
||||
<DropdownMenuItem
|
||||
key={view.id}
|
||||
className="flex items-center justify-between"
|
||||
onClick={() => {
|
||||
applyView(view.id);
|
||||
onApplyView(
|
||||
view.filters as Record<string, unknown>,
|
||||
view.sortConfig as { field: string; direction: string } | undefined,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{view.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{activeViewId === view.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteView(view.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{view.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{activeViewId === view.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteView(view.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setSaveOpen(true)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Save current view
|
||||
<Trash2 className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save View</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>View name</Label>
|
||||
<Input
|
||||
value={viewName}
|
||||
onChange={(e) => setViewName(e.target.value)}
|
||||
placeholder="My custom view"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!viewName.trim() || isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
|
||||
import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
@@ -95,18 +96,9 @@ export function OwnerLink({
|
||||
}
|
||||
|
||||
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
|
||||
const parts: string[] = [];
|
||||
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
|
||||
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
|
||||
|
||||
let summary: string | null = null;
|
||||
if (parts.length > 0) {
|
||||
summary = parts.join(' × ');
|
||||
}
|
||||
if (yacht.draftFt) {
|
||||
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
|
||||
}
|
||||
return summary;
|
||||
// Show both units; derive whichever is unset from the other so reps
|
||||
// never need to enter both. See `yacht-dimensions.ts`.
|
||||
return formatYachtDimensionsBothUnits(yacht);
|
||||
}
|
||||
|
||||
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
||||
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
||||
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export interface YachtData {
|
||||
@@ -54,6 +55,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'yacht:updated': [['yachts', yachtId]],
|
||||
'yacht:archived': [['yachts', yachtId]],
|
||||
|
||||
96
src/components/yachts/yacht-dimensions.ts
Normal file
96
src/components/yachts/yacht-dimensions.ts
Normal 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;
|
||||
}
|
||||
@@ -128,8 +128,6 @@ export function YachtList() {
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="yachts"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
|
||||
@@ -94,7 +94,45 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
||||
const value = transform ? transform(next) : next;
|
||||
await mutation.mutateAsync({ [field]: value });
|
||||
};
|
||||
const numericString = (next: string | null) => (next === null ? null : next);
|
||||
/**
|
||||
* Bidirectional dimension save: when the rep edits Length/Width/Draft
|
||||
* in feet, also write the metric counterpart (and vice versa). Avoids
|
||||
* the "I entered ft but the m row still says '-'" surprise.
|
||||
*
|
||||
* If the rep clears a field (next === null), only that side is
|
||||
* cleared — we never overwrite their other-unit value with a derived
|
||||
* one, since they may have intentionally entered a more precise
|
||||
* metric figure.
|
||||
*/
|
||||
function saveDimension(
|
||||
primaryField: 'lengthFt' | 'widthFt' | 'draftFt' | 'lengthM' | 'widthM' | 'draftM',
|
||||
) {
|
||||
const isFt = primaryField.endsWith('Ft');
|
||||
const counterpart = (
|
||||
isFt ? primaryField.replace('Ft', 'M') : primaryField.replace('M', 'Ft')
|
||||
) as YachtPatchField;
|
||||
return async (next: string | null) => {
|
||||
if (next === null || next === '') {
|
||||
await mutation.mutateAsync({ [primaryField]: null });
|
||||
return;
|
||||
}
|
||||
const n = Number.parseFloat(next);
|
||||
if (!Number.isFinite(n)) {
|
||||
await mutation.mutateAsync({ [primaryField]: next });
|
||||
return;
|
||||
}
|
||||
const FT_PER_M = 3.28084;
|
||||
const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
|
||||
const convertedStr = converted
|
||||
.toFixed(2)
|
||||
.replace(/\.0+$/, '')
|
||||
.replace(/(\.\d)0$/, '$1');
|
||||
await mutation.mutateAsync({
|
||||
[primaryField]: next,
|
||||
[counterpart]: convertedStr,
|
||||
});
|
||||
};
|
||||
}
|
||||
const yearTransform = (next: string | null) => {
|
||||
if (next === null) return null;
|
||||
const n = Number.parseInt(next, 10);
|
||||
@@ -157,13 +195,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (ft)">
|
||||
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
|
||||
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (ft)">
|
||||
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
|
||||
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (ft)">
|
||||
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
|
||||
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -173,13 +211,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (m)">
|
||||
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
|
||||
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (m)">
|
||||
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
|
||||
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (m)">
|
||||
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
|
||||
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
43
src/hooks/use-breadcrumb-hint.ts
Normal file
43
src/hooks/use-breadcrumb-hint.ts
Normal 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]);
|
||||
}
|
||||
119
src/hooks/use-table-preferences.ts
Normal file
119
src/hooks/use-table-preferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import { z } from 'zod';
|
||||
|
||||
export const baseListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(25),
|
||||
// Bumped from 100 to 1000 so the table page-size selector can offer
|
||||
// an "All" option that maps to a single big fetch. Above 1000 the
|
||||
// caller must paginate; anything routinely north of that ceiling
|
||||
// needs virtualization rather than a bigger page-size cap.
|
||||
limit: z.coerce.number().int().min(1).max(1000).default(25),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(['asc', 'desc']).default('desc'),
|
||||
search: z.string().optional(),
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from './index';
|
||||
|
||||
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
|
||||
updatedAtColumn: PgColumn;
|
||||
filters?: SQL[];
|
||||
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
|
||||
/**
|
||||
* Custom ORDER BY clauses, used INSTEAD of `sort`. For cases where
|
||||
* the natural ordering needs raw SQL (e.g. natural alphanumeric sort
|
||||
* on berth mooring numbers like A1, A2, A10, B1...). Deterministic
|
||||
* tail-sort on `updatedAt DESC, id DESC` is still appended.
|
||||
*/
|
||||
customOrderBy?: SQL[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
searchColumns?: PgColumn[];
|
||||
@@ -40,9 +37,7 @@ export interface ListResult<T> {
|
||||
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
|
||||
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
|
||||
*/
|
||||
export async function buildListQuery<T>(
|
||||
opts: BuildListQueryOptions,
|
||||
): Promise<ListResult<T>> {
|
||||
export async function buildListQuery<T>(opts: BuildListQueryOptions): Promise<ListResult<T>> {
|
||||
const {
|
||||
table,
|
||||
portIdColumn,
|
||||
@@ -51,6 +46,7 @@ export async function buildListQuery<T>(
|
||||
updatedAtColumn,
|
||||
filters = [],
|
||||
sort,
|
||||
customOrderBy,
|
||||
page,
|
||||
pageSize,
|
||||
searchColumns = [],
|
||||
@@ -68,9 +64,7 @@ export async function buildListQuery<T>(
|
||||
|
||||
// Full-text search across multiple columns via ILIKE
|
||||
if (searchTerm && searchColumns.length > 0) {
|
||||
const searchConditions = searchColumns.map((col) =>
|
||||
ilike(col, `%${searchTerm}%`),
|
||||
);
|
||||
const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
|
||||
conditions.push(or(...searchConditions)!);
|
||||
}
|
||||
|
||||
@@ -86,12 +80,13 @@ export async function buildListQuery<T>(
|
||||
.where(where);
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
|
||||
// Build order by: user sort + deterministic secondary sort
|
||||
// Build order by: customOrderBy (if provided) wins over the default
|
||||
// column-based sort. Deterministic secondary sort always trails.
|
||||
const orderClauses: SQL[] = [];
|
||||
if (sort) {
|
||||
orderClauses.push(
|
||||
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
|
||||
);
|
||||
if (customOrderBy && customOrderBy.length > 0) {
|
||||
orderClauses.push(...customOrderBy);
|
||||
} else if (sort) {
|
||||
orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
|
||||
}
|
||||
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export const interests = pgTable(
|
||||
outcomeReason: text('outcome_reason'),
|
||||
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
||||
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
||||
notes: text('notes'),
|
||||
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
|
||||
* on that axis, with a banner prompting the rep to add the missing dim. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
|
||||
@@ -143,10 +143,27 @@ export type RolePermissions = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-table column visibility — drives the `<ColumnPicker>` and the
|
||||
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
|
||||
* truth; an entry's absence means "show this column" (so newly-added
|
||||
* columns show by default for existing users without us having to
|
||||
* migrate stored preferences).
|
||||
*/
|
||||
export type TablePreferences = {
|
||||
hiddenColumns?: string[];
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
dark_mode?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
/** ISO-3166-1 alpha-2. Drives the default timezone when the rep
|
||||
* hasn't picked one explicitly, and lets the auto-detect banner
|
||||
* spot a mismatch when they're travelling. */
|
||||
country?: string;
|
||||
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
|
||||
tablePreferences?: Record<string, TablePreferences>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
|
||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||
displayName: text('display_name').notNull(),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
* without breaking the URL. The legacy `avatarUrl` column is
|
||||
* kept for any external photo sources but the file pointer wins
|
||||
* when both are set. */
|
||||
avatarFileId: text('avatar_file_id'),
|
||||
phone: text('phone'),
|
||||
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
@@ -261,6 +284,37 @@ export const portRoleOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pending email-change records for the verify-old-and-new flow.
|
||||
* The CRM's `/api/v1/me/email` endpoint creates a row here, emails
|
||||
* the OLD address with a cancel link and the NEW address with a
|
||||
* confirm link, and applies the change only when the new address
|
||||
* confirms (or auto-cancels at `expiresAt`).
|
||||
*
|
||||
* `confirmTokenHash` stores a sha256 of the random confirmation
|
||||
* token; the raw token is only present in the email body.
|
||||
*/
|
||||
export const userEmailChanges = pgTable(
|
||||
'user_email_changes',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').notNull(),
|
||||
oldEmail: text('old_email').notNull(),
|
||||
newEmail: text('new_email').notNull(),
|
||||
confirmTokenHash: text('confirm_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_uec_user').on(table.userId),
|
||||
index('idx_uec_token').on(table.confirmTokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPortRoles = pgTable(
|
||||
'user_port_roles',
|
||||
{
|
||||
|
||||
@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
|
||||
}
|
||||
|
||||
interface SyntheticClientSpec {
|
||||
/** Used as a name suffix so test selectors can target it deterministically. */
|
||||
/** Stable identifier used by Playwright selectors and intra-seed wiring
|
||||
* (memberships, yachts, etc.). Decoupled from the display name so the
|
||||
* rendered list looks like real client data, not test fixtures. */
|
||||
tag: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
/** Acquisition source — varied across the fixture set so the list view
|
||||
* looks like a real funnel rather than a wall of "Manual". */
|
||||
source?: 'website' | 'manual' | 'referral' | 'broker';
|
||||
/** How long ago (in days) this client record was created. Spreads the
|
||||
* "Created" column across realistic timestamps so list pages look like
|
||||
* a real CRM with months of history rather than 12 rows all stamped
|
||||
* with today's date. */
|
||||
createdDaysAgo?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,150 +125,184 @@ interface SyntheticClientSpec {
|
||||
* Berth indices map deterministically into the NocoDB snapshot which is
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
/**
|
||||
* Believable demo dataset — names, emails, phone numbers, addresses, and
|
||||
* acquisition sources read like a real marina's prospect list rather
|
||||
* than fixtures keyed on enum names. The `tag` field still carries the
|
||||
* stage/role identity for selectors and intra-seed wiring; nothing in
|
||||
* the rendered UI references it.
|
||||
*
|
||||
* Spread across acquisition sources, ages (3–280 days), and countries
|
||||
* so list / dashboard / kanban surfaces look populated and natural.
|
||||
*/
|
||||
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
{
|
||||
tag: 'open',
|
||||
fullName: 'Olivia Open — open',
|
||||
email: 'olivia.open@test.local',
|
||||
phone: '+1 555 010 0001',
|
||||
fullName: 'Olivia Sinclair',
|
||||
email: 'olivia.sinclair@gmail.com',
|
||||
phone: '+44 7700 900142',
|
||||
countryIso: 'GB',
|
||||
city: 'London',
|
||||
street: '1 Open Lane',
|
||||
postalCode: 'OP1 1OP',
|
||||
street: '14 Cheyne Walk',
|
||||
postalCode: 'SW3 5RA',
|
||||
stage: 'open',
|
||||
source: 'website',
|
||||
createdDaysAgo: 4,
|
||||
// Open stage: no berth link yet
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
fullName: 'Daniel Details — details_sent',
|
||||
email: 'daniel.details@test.local',
|
||||
phone: '+1 555 010 0002',
|
||||
fullName: 'Daniel Whitaker',
|
||||
email: 'daniel.whitaker@outlook.com',
|
||||
phone: '+1 305 555 0182',
|
||||
countryIso: 'US',
|
||||
city: 'Miami',
|
||||
street: '2 Brochure Way',
|
||||
postalCode: '33101',
|
||||
street: '880 Brickell Bay Drive',
|
||||
postalCode: '33131',
|
||||
stage: 'details_sent',
|
||||
berthIdx: 0,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 12,
|
||||
},
|
||||
{
|
||||
tag: 'comms',
|
||||
fullName: 'Carla Communicating — in_communication',
|
||||
email: 'carla.comms@test.local',
|
||||
phone: '+1 555 010 0003',
|
||||
fullName: 'Carla Mendoza',
|
||||
email: 'carla.mendoza@gmail.com',
|
||||
phone: '+34 971 555 028',
|
||||
countryIso: 'ES',
|
||||
city: 'Palma',
|
||||
street: '3 Reply Street',
|
||||
postalCode: '07012',
|
||||
city: 'Palma de Mallorca',
|
||||
street: 'Carrer de Sant Magí 23',
|
||||
postalCode: '07013',
|
||||
stage: 'in_communication',
|
||||
berthIdx: 5,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 28,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-sent',
|
||||
fullName: 'Eric EoiSent — eoi_sent',
|
||||
email: 'eric.eoisent@test.local',
|
||||
phone: '+1 555 010 0004',
|
||||
fullName: 'Marco Bianchi',
|
||||
email: 'marco.bianchi@libero.it',
|
||||
phone: '+39 010 8740 215',
|
||||
countryIso: 'IT',
|
||||
city: 'Genoa',
|
||||
street: '4 Envelope Plaza',
|
||||
postalCode: '16124',
|
||||
street: 'Via XX Settembre 47',
|
||||
postalCode: '16121',
|
||||
stage: 'eoi_sent',
|
||||
berthIdx: 6,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 45,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-signed',
|
||||
fullName: 'Sara EoiSigned — eoi_signed',
|
||||
email: 'sara.eoisigned@test.local',
|
||||
phone: '+1 555 010 0005',
|
||||
fullName: 'Sara Laurent',
|
||||
email: 'sara.laurent@orange.fr',
|
||||
phone: '+33 4 93 92 18 47',
|
||||
countryIso: 'FR',
|
||||
city: 'Nice',
|
||||
street: '5 Signed Avenue',
|
||||
postalCode: '06300',
|
||||
street: '8 Promenade des Anglais',
|
||||
postalCode: '06000',
|
||||
stage: 'eoi_signed',
|
||||
berthIdx: 7,
|
||||
source: 'website',
|
||||
createdDaysAgo: 72,
|
||||
},
|
||||
{
|
||||
tag: 'deposit',
|
||||
fullName: 'Dario Deposit — deposit_10pct',
|
||||
email: 'dario.deposit@test.local',
|
||||
phone: '+1 555 010 0006',
|
||||
fullName: 'Nikolas Papadakis',
|
||||
email: 'n.papadakis@gmail.com',
|
||||
phone: '+30 210 8945 612',
|
||||
countryIso: 'GR',
|
||||
city: 'Athens',
|
||||
street: '6 Deposit Quay',
|
||||
postalCode: '10558',
|
||||
street: 'Vouliagmenis Avenue 142',
|
||||
postalCode: '16674',
|
||||
stage: 'deposit_10pct',
|
||||
berthIdx: 8,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 95,
|
||||
},
|
||||
{
|
||||
tag: 'contract-sent',
|
||||
fullName: 'Connor ContractSent — contract_sent',
|
||||
email: 'connor.contract@test.local',
|
||||
phone: '+1 555 010 0007',
|
||||
fullName: 'Connor Murphy',
|
||||
email: 'connor.murphy@me.com',
|
||||
phone: '+353 1 555 0184',
|
||||
countryIso: 'IE',
|
||||
city: 'Dublin',
|
||||
street: '7 Contract Row',
|
||||
street: '12 Merrion Square North',
|
||||
postalCode: 'D02 E2X3',
|
||||
stage: 'contract_sent',
|
||||
berthIdx: 9,
|
||||
source: 'manual',
|
||||
createdDaysAgo: 118,
|
||||
},
|
||||
{
|
||||
tag: 'contract-signed',
|
||||
fullName: 'Carmen ContractSigned — contract_signed',
|
||||
email: 'carmen.signed@test.local',
|
||||
phone: '+1 555 010 0008',
|
||||
fullName: 'Carmen Costa',
|
||||
email: 'carmen.costa@sapo.pt',
|
||||
phone: '+351 21 386 4520',
|
||||
countryIso: 'PT',
|
||||
city: 'Lisbon',
|
||||
street: '8 Notary Square',
|
||||
postalCode: '1100-001',
|
||||
street: 'Rua Garrett 88',
|
||||
postalCode: '1200-205',
|
||||
stage: 'contract_signed',
|
||||
berthIdx: 4,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 156,
|
||||
},
|
||||
{
|
||||
tag: 'completed-won',
|
||||
fullName: 'Carlos Completed — completed (won)',
|
||||
email: 'carlos.complete@test.local',
|
||||
phone: '+1 555 010 0009',
|
||||
fullName: 'Carlos Vega',
|
||||
email: 'carlos.vega@gmail.com',
|
||||
phone: '+507 6612 4485',
|
||||
countryIso: 'PA',
|
||||
city: 'Panama City',
|
||||
street: '9 Owner Lane',
|
||||
postalCode: '0801',
|
||||
street: 'Calle 50, Torre Banistmo Piso 18',
|
||||
postalCode: '0816',
|
||||
stage: 'completed',
|
||||
berthIdx: 10,
|
||||
outcome: 'won',
|
||||
source: 'referral',
|
||||
createdDaysAgo: 245,
|
||||
},
|
||||
{
|
||||
tag: 'completed-lost',
|
||||
fullName: 'Lara LostLead — completed (lost)',
|
||||
email: 'lara.lost@test.local',
|
||||
phone: '+1 555 010 0010',
|
||||
fullName: 'Hannah Schmidt',
|
||||
email: 'hannah.schmidt@gmx.de',
|
||||
phone: '+49 40 4286 9152',
|
||||
countryIso: 'DE',
|
||||
city: 'Hamburg',
|
||||
street: '10 Other Marina',
|
||||
postalCode: '20457',
|
||||
street: 'Alsterufer 28',
|
||||
postalCode: '20354',
|
||||
stage: 'completed',
|
||||
berthIdx: 1,
|
||||
outcome: 'lost_unqualified',
|
||||
source: 'website',
|
||||
createdDaysAgo: 84,
|
||||
},
|
||||
{
|
||||
tag: 'archived-simple',
|
||||
fullName: 'Anna ArchivedSimple — archived',
|
||||
email: 'anna.archived@test.local',
|
||||
phone: '+1 555 010 0011',
|
||||
fullName: 'Anna de Jong',
|
||||
email: 'anna.dejong@kpn.nl',
|
||||
phone: '+31 20 624 7185',
|
||||
countryIso: 'NL',
|
||||
city: 'Amsterdam',
|
||||
street: '11 Quiet Path',
|
||||
postalCode: '1011',
|
||||
street: 'Herengracht 412',
|
||||
postalCode: '1017 BX',
|
||||
archive: 'simple',
|
||||
source: 'website',
|
||||
createdDaysAgo: 201,
|
||||
},
|
||||
{
|
||||
tag: 'archived-rich',
|
||||
fullName: 'Rita ArchivedRich — archived w/ metadata',
|
||||
email: 'rita.archivedrich@test.local',
|
||||
phone: '+1 555 010 0012',
|
||||
fullName: 'Rita Vermeulen',
|
||||
email: 'rita.vermeulen@telenet.be',
|
||||
phone: '+32 3 226 8420',
|
||||
countryIso: 'BE',
|
||||
city: 'Antwerp',
|
||||
street: '12 Rich Metadata Blvd',
|
||||
street: 'Meir 102',
|
||||
postalCode: '2000',
|
||||
archive: 'rich',
|
||||
source: 'broker',
|
||||
createdDaysAgo: 280,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
|
||||
const clientRows = await tx
|
||||
.insert(clients)
|
||||
.values(
|
||||
PIPELINE_CLIENTS.map((spec) => ({
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: 'manual' as const,
|
||||
})),
|
||||
PIPELINE_CLIENTS.map((spec) => {
|
||||
const created =
|
||||
spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
|
||||
return {
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: 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 });
|
||||
|
||||
|
||||
@@ -310,7 +310,6 @@ async function applyInterest(
|
||||
pipelineStage: planned.pipelineStage,
|
||||
leadCategory: planned.leadCategory,
|
||||
source: planned.source,
|
||||
notes: planned.notes,
|
||||
documensoId: planned.documensoId,
|
||||
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
|
||||
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte, lte, inArray } from 'drizzle-orm';
|
||||
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||
@@ -61,10 +61,22 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
filters.push(inArray(berths.id, matchingIds));
|
||||
}
|
||||
|
||||
// Default ordering is natural alphanumeric on mooring number
|
||||
// (A1, A2, A10, B1...) — Postgres' default lexicographic sort
|
||||
// would put A10 before A2, which is the wrong story for a marina
|
||||
// map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
|
||||
// splits are safe.
|
||||
const NATURAL_MOORING_SORT = [
|
||||
sql`regexp_replace(${berths.mooringNumber}, '\d+$', '') ASC`,
|
||||
sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
|
||||
];
|
||||
|
||||
const sortColumn = (() => {
|
||||
switch (query.sort) {
|
||||
case 'mooringNumber':
|
||||
return berths.mooringNumber;
|
||||
// Honoured via customOrderBy below — caller asked for mooring
|
||||
// sort explicitly, give them the natural order.
|
||||
return null;
|
||||
case 'area':
|
||||
return berths.area;
|
||||
case 'price':
|
||||
@@ -74,7 +86,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
case 'lengthM':
|
||||
return berths.lengthM;
|
||||
default:
|
||||
return berths.updatedAt;
|
||||
// No sort requested → natural mooring order is the friendliest
|
||||
// default for the berth grid (groups by pontoon letter).
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -85,7 +99,8 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
idColumn: berths.id,
|
||||
updatedAtColumn: berths.updatedAt,
|
||||
filters,
|
||||
sort: { column: sortColumn, direction: query.order },
|
||||
sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
|
||||
customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
|
||||
page: query.page,
|
||||
pageSize: query.limit,
|
||||
searchColumns: [berths.mooringNumber, berths.area],
|
||||
|
||||
@@ -84,8 +84,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
|
||||
const ids = result.data.map((r) => r.id);
|
||||
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
|
||||
[
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||
.from(yachts)
|
||||
@@ -148,22 +148,60 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
clientId: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
}>(sql`
|
||||
SELECT DISTINCT ON (client_id, channel)
|
||||
client_id AS "clientId",
|
||||
client_id AS "clientId",
|
||||
channel,
|
||||
value,
|
||||
is_primary AS "isPrimary",
|
||||
created_at AS "createdAt"
|
||||
value_e164 AS "valueE164",
|
||||
is_primary AS "isPrimary",
|
||||
created_at AS "createdAt"
|
||||
FROM client_contacts
|
||||
WHERE ${inArray(clientContacts.clientId, ids)}
|
||||
AND channel IN ('email', 'phone')
|
||||
ORDER BY client_id, channel, is_primary DESC, created_at DESC
|
||||
`),
|
||||
],
|
||||
);
|
||||
// Berths each client has interests in, with the (most-active)
|
||||
// interest's stage attached so the list-view chip can self-describe
|
||||
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
|
||||
// collapses (client, berth) when the client has had multiple
|
||||
// historical interests in the same berth — we keep the open-outcome
|
||||
// one if any, otherwise the most recently updated. Excludes archived
|
||||
// interests so closed deals don't crowd the chip row.
|
||||
db.execute<{
|
||||
clientId: string;
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
pipelineStage: string;
|
||||
outcome: string | null;
|
||||
}>(sql`
|
||||
SELECT DISTINCT ON (i.client_id, b.id)
|
||||
i.client_id AS "clientId",
|
||||
b.id AS "berthId",
|
||||
b.mooring_number AS "mooringNumber",
|
||||
i.id AS "interestId",
|
||||
i.pipeline_stage AS "pipelineStage",
|
||||
i.outcome
|
||||
FROM interests i
|
||||
JOIN interest_berths ib ON ib.interest_id = i.id
|
||||
JOIN berths b ON b.id = ib.berth_id
|
||||
WHERE i.port_id = ${portId}
|
||||
AND i.client_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.archived_at IS NULL
|
||||
ORDER BY
|
||||
i.client_id,
|
||||
b.id,
|
||||
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
|
||||
i.updated_at DESC
|
||||
`),
|
||||
]);
|
||||
|
||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||
@@ -182,12 +220,16 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
// Pick the per-client primary email + phone. The SQL DISTINCT ON
|
||||
// returns at most one row per (clientId, channel); the result is
|
||||
// already the picker's "is_primary desc, created_at desc" choice.
|
||||
// We also keep the E.164 form of the phone so the UI can build a
|
||||
// wa.me/<digits> link that doesn't need re-parsing.
|
||||
const primaryEmailMap = new Map<string, string>();
|
||||
const primaryPhoneMap = new Map<string, string>();
|
||||
const primaryPhoneE164Map = new Map<string, string>();
|
||||
type ContactRow = {
|
||||
clientId: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
|
||||
for (const c of contactRowList) {
|
||||
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
|
||||
else if (c.channel === 'phone') primaryPhoneMap.set(c.clientId, c.value);
|
||||
else if (c.channel === 'phone') {
|
||||
primaryPhoneMap.set(c.clientId, c.value);
|
||||
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate berths per client, sorted so the most-action-worthy
|
||||
// interest floats to the top of the chip row. Priority:
|
||||
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
||||
// 2. within open: most progressed stage first (contract_signed > … > open)
|
||||
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
||||
// The list-view UI shows the top 2 with full labels; the rest fall
|
||||
// through into a "+N more" popover.
|
||||
const stageRank: Record<string, number> = {
|
||||
contract_signed: 1,
|
||||
deposit_10pct: 2,
|
||||
contract_sent: 3,
|
||||
eoi_signed: 4,
|
||||
eoi_sent: 5,
|
||||
in_communication: 6,
|
||||
details_sent: 7,
|
||||
qualified: 8,
|
||||
open: 9,
|
||||
completed: 10,
|
||||
};
|
||||
type LinkedBerth = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
stage: string;
|
||||
outcome: string | null;
|
||||
};
|
||||
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
|
||||
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
|
||||
const linkedBerthList: LinkedBerthRow[] =
|
||||
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
|
||||
(linkedBerthRows as unknown as LinkedBerthRow[]);
|
||||
for (const r of linkedBerthList) {
|
||||
const list = linkedBerthsMap.get(r.clientId) ?? [];
|
||||
list.push({
|
||||
id: r.berthId,
|
||||
mooringNumber: r.mooringNumber,
|
||||
interestId: r.interestId,
|
||||
stage: r.pipelineStage,
|
||||
outcome: r.outcome,
|
||||
});
|
||||
linkedBerthsMap.set(r.clientId, list);
|
||||
}
|
||||
for (const list of linkedBerthsMap.values()) {
|
||||
list.sort((a, b) => {
|
||||
// Open before closed.
|
||||
const openA = a.outcome === null ? 0 : 1;
|
||||
const openB = b.outcome === null ? 0 : 1;
|
||||
if (openA !== openB) return openA - openB;
|
||||
// Within bucket, most-progressed stage first.
|
||||
const rankA = stageRank[a.stage] ?? 99;
|
||||
const rankB = stageRank[b.stage] ?? 99;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return a.mooringNumber.localeCompare(b.mooringNumber);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
interestCount: interestCountMap.get(row.id) ?? 0,
|
||||
primaryEmail: primaryEmailMap.get(row.id) ?? null,
|
||||
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
|
||||
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
|
||||
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
|
||||
latestInterest: latest
|
||||
? {
|
||||
stage: latest.stage,
|
||||
|
||||
@@ -366,7 +366,11 @@ export async function resolveTemplate(
|
||||
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
|
||||
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.notes}}'] = interest.notes ?? '';
|
||||
// `{{interest.notes}}` is now sourced from the threaded
|
||||
// interest_notes timeline via EoiContext.interest.notes; this
|
||||
// shallow-fallback path leaves the token blank if EoiContext
|
||||
// wasn't loaded for this template render.
|
||||
tokenMap['{{interest.notes}}'] = '';
|
||||
}
|
||||
// These are never populated by EoiContext - always fill them in.
|
||||
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { searchAuditLogs } from '@/lib/services/audit-search.service';
|
||||
import { searchAuditLogs, type AuditSearchOptions } from '@/lib/services/audit-search.service';
|
||||
|
||||
/**
|
||||
* Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs`
|
||||
@@ -40,3 +41,69 @@ export async function loadEntityActivity(args: {
|
||||
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated activity for a client — includes audit logs for the
|
||||
* client itself + every interest belonging to that client. Used by
|
||||
* the Client overview's Activity tab so the rep sees the whole
|
||||
* timeline without clicking into each interest individually.
|
||||
*
|
||||
* Two queries (one per entityType) merged + sorted in JS rather than
|
||||
* a UNION because the auditLogs.entityType field would need to match
|
||||
* different values in the same SELECT — cleaner to keep the search
|
||||
* helper's per-entity-type semantics intact and merge here.
|
||||
*/
|
||||
export async function loadClientActivityAggregated(args: {
|
||||
portId: string;
|
||||
clientId: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
const limit = args.limit ?? 50;
|
||||
|
||||
// Resolve interest ids upfront so we know what to fetch in parallel.
|
||||
const interestRows = await db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.clientId, args.clientId), eq(interests.portId, args.portId)));
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
|
||||
const baseOpts = (entityType: string, entityId?: string, entityIds?: string[]) =>
|
||||
({
|
||||
portId: args.portId,
|
||||
entityType,
|
||||
entityId,
|
||||
entityIds,
|
||||
// Fetch up to `limit` per slice; we'll resort + slice to limit
|
||||
// after merging. Slight over-fetch keeps the merged window honest
|
||||
// when the activity is unbalanced (e.g. mostly interest events).
|
||||
limit,
|
||||
}) satisfies AuditSearchOptions;
|
||||
|
||||
const [clientPage, interestPage] = await Promise.all([
|
||||
searchAuditLogs(baseOpts('client', args.clientId)),
|
||||
interestIds.length > 0
|
||||
? searchAuditLogs(baseOpts('interest', undefined, interestIds))
|
||||
: Promise.resolve({ rows: [], nextCursor: null }),
|
||||
]);
|
||||
|
||||
const merged = [...clientPage.rows, ...interestPage.rows]
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
// Resolve actor names in one round-trip across the merged set.
|
||||
const userIds = Array.from(
|
||||
new Set(merged.map((r) => r.userId).filter((u): u is string => Boolean(u))),
|
||||
);
|
||||
const userRows = userIds.length
|
||||
? await db
|
||||
.select({ id: user.id, email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, userIds))
|
||||
: [];
|
||||
const userMap = new Map(userRows.map((u) => [u.id, u]));
|
||||
|
||||
return merged.map((r) => ({
|
||||
...r,
|
||||
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
@@ -110,6 +110,18 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
const primaryBerth = await getPrimaryBerth(interest.id);
|
||||
const primaryBerthId = primaryBerth?.berthId ?? null;
|
||||
|
||||
// The legacy `interests.notes` blob was dropped in favour of the
|
||||
// threaded `interest_notes` timeline. Templates / merge fields still
|
||||
// expose `interest.notes`, so we surface the most-recent threaded
|
||||
// note's content here. Returns null when the interest has no notes.
|
||||
const [latestNote] = await db
|
||||
.select({ content: interestNotes.content })
|
||||
.from(interestNotes)
|
||||
.where(eq(interestNotes.interestId, interest.id))
|
||||
.orderBy(desc(interestNotes.createdAt))
|
||||
.limit(1);
|
||||
const interestNotesContent = latestNote?.content ?? null;
|
||||
|
||||
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
|
||||
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
|
||||
// Documenso template renders blank rather than "undefined".
|
||||
@@ -300,7 +312,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
stage: interest.pipelineStage,
|
||||
leadCategory: interest.leadCategory,
|
||||
dateFirstContact: interest.dateFirstContact,
|
||||
notes: interest.notes,
|
||||
notes: interestNotesContent,
|
||||
},
|
||||
port: {
|
||||
name: port.name,
|
||||
|
||||
229
src/lib/services/interest-contact-log.service.ts
Normal file
229
src/lib/services/interest-contact-log.service.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -131,6 +131,128 @@ async function resolveLeadCategory(
|
||||
return leadCategory ?? undefined;
|
||||
}
|
||||
|
||||
// ─── Board (kanban) ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft cap on board rows. The kanban legitimately needs every active
|
||||
* interest in one shot — paginating would split deals across pages and
|
||||
* break drag-drop semantics — but unbounded SELECTs are a footgun if a
|
||||
* port suddenly has tens of thousands of stale interests. At 5000 the
|
||||
* payload is still well under a megabyte (≈50 bytes per minimal row),
|
||||
* and any port near that ceiling needs virtualization in the kanban UI
|
||||
* anyway, so failing loud here is the right escalation.
|
||||
*/
|
||||
const BOARD_MAX_ROWS = 5000;
|
||||
|
||||
export interface BoardInterestRow {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
leadCategory: string | null;
|
||||
pipelineStage: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BoardFilters {
|
||||
/** Free-text search against client name. */
|
||||
search?: string;
|
||||
leadCategory?: string;
|
||||
source?: string;
|
||||
eoiStatus?: string;
|
||||
/** Tag IDs the interest must be tagged with (any-of). */
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal-projection list for the kanban board. Skips the validator's
|
||||
* `max(100)` page cap since the board renders the entire pipeline at
|
||||
* once. Returns only the fields PipelineCard renders — no tags-list, no
|
||||
* notes-count, no EOI status badges, no urgency joins. Always filters
|
||||
* out archived interests (the kanban is for active deals; the list view
|
||||
* has the includeArchived toggle for history).
|
||||
*
|
||||
* Filters are intentionally a SUBSET of listInterests — `pipelineStage`
|
||||
* is omitted because the columns ARE the stages, and `includeArchived`
|
||||
* is omitted because the kanban shouldn't surface archived deals.
|
||||
*
|
||||
* One round-trip for the interests + clientName join, one batched
|
||||
* round-trip via getPrimaryBerthsForInterests for the mooring numbers,
|
||||
* and one batched lookup for tag-id filtering when supplied.
|
||||
*/
|
||||
export async function listInterestsForBoard(
|
||||
portId: string,
|
||||
filters: BoardFilters = {},
|
||||
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
|
||||
const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
|
||||
|
||||
if (filters.leadCategory) {
|
||||
conditions.push(eq(interests.leadCategory, filters.leadCategory));
|
||||
}
|
||||
if (filters.source) {
|
||||
conditions.push(eq(interests.source, filters.source));
|
||||
}
|
||||
if (filters.eoiStatus) {
|
||||
conditions.push(eq(interests.eoiStatus, filters.eoiStatus));
|
||||
}
|
||||
|
||||
// Tag-id filter resolves through the join table first so the main
|
||||
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
|
||||
// with LEFT JOIN — keeps Postgres' planner happy at scale.
|
||||
if (filters.tagIds && filters.tagIds.length > 0) {
|
||||
const tagMatches = await db
|
||||
.selectDistinct({ interestId: interestTags.interestId })
|
||||
.from(interestTags)
|
||||
.where(inArray(interestTags.tagId, filters.tagIds));
|
||||
const matchingIds = tagMatches.map((r) => r.interestId);
|
||||
if (matchingIds.length === 0) {
|
||||
return { data: [], truncated: false, total: 0 };
|
||||
}
|
||||
conditions.push(inArray(interests.id, matchingIds));
|
||||
}
|
||||
|
||||
// Search hits client name via the LEFT JOIN. ILIKE is correct here —
|
||||
// the kanban list is small (≤5000 rows) so an index scan isn't
|
||||
// required, and pg_trgm would be overkill for the board surface.
|
||||
if (filters.search && filters.search.trim().length > 0) {
|
||||
const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`;
|
||||
conditions.push(sql`${clients.fullName} ILIKE ${term}`);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientName: clients.fullName,
|
||||
leadCategory: interests.leadCategory,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
updatedAt: interests.updatedAt,
|
||||
})
|
||||
.from(interests)
|
||||
.leftJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(interests.updatedAt))
|
||||
.limit(BOARD_MAX_ROWS + 1);
|
||||
|
||||
const truncated = rows.length > BOARD_MAX_ROWS;
|
||||
const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows;
|
||||
|
||||
// Primary-berth resolution stays in the junction-aware service so the
|
||||
// board sees the same "the berth for this deal" as every other surface.
|
||||
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
|
||||
|
||||
return {
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
clientName: r.clientName ?? null,
|
||||
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
|
||||
leadCategory: r.leadCategory ?? null,
|
||||
pipelineStage: r.pipelineStage,
|
||||
updatedAt: r.updatedAt,
|
||||
})),
|
||||
truncated,
|
||||
total: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
@@ -367,6 +489,15 @@ export async function getInterestById(id: string, portId: string) {
|
||||
const berthId = primaryBerth?.berthId ?? null;
|
||||
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Total linked-berth count powers the "Berth Interest" milestone on
|
||||
// the OverviewTab — first thing the rep needs to capture, especially
|
||||
// for general_interest leads. Resolved here (not from the join above)
|
||||
// so the count includes berths the rep added without marking primary.
|
||||
const [{ count: linkedBerthCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestBerths)
|
||||
.where(eq(interestBerths.interestId, id));
|
||||
|
||||
const tagRows = await db
|
||||
.select({ id: tags.id, name: tags.name, color: tags.color })
|
||||
.from(interestTags)
|
||||
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
clientHasAddress: !!addressRow,
|
||||
berthId,
|
||||
berthMooringNumber,
|
||||
linkedBerthCount,
|
||||
tags: tagRows,
|
||||
notesCount,
|
||||
recentNote: recentNote ?? null,
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientNotes, clients } from '@/lib/db/schema/clients';
|
||||
import { interestNotes, interests } from '@/lib/db/schema/interests';
|
||||
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyNotes, companies } from '@/lib/db/schema/companies';
|
||||
import {
|
||||
residentialClients,
|
||||
residentialClientNotes,
|
||||
residentialInterests,
|
||||
residentialInterestNotes,
|
||||
} from '@/lib/db/schema/residential';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
|
||||
|
||||
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
|
||||
type EntityType =
|
||||
| 'clients'
|
||||
| 'interests'
|
||||
| 'yachts'
|
||||
| 'companies'
|
||||
| 'residential_clients'
|
||||
| 'residential_interests';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
|
||||
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Yacht');
|
||||
} else {
|
||||
} else if (entityType === 'companies') {
|
||||
const r = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Company');
|
||||
} else if (entityType === 'residential_clients') {
|
||||
const r = await db
|
||||
.select({ id: residentialClients.id })
|
||||
.from(residentialClients)
|
||||
.where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Residential client');
|
||||
} else {
|
||||
const r = await db
|
||||
.select({ id: residentialInterests.id })
|
||||
.from(residentialInterests)
|
||||
.where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Residential interest');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to centralise the per-entity table dispatch — keeps the CRUD
|
||||
// branches below from each having their own switch.
|
||||
function tableForEntity(entityType: EntityType) {
|
||||
switch (entityType) {
|
||||
case 'clients':
|
||||
return { table: clientNotes, fk: 'clientId' as const };
|
||||
case 'interests':
|
||||
return { table: interestNotes, fk: 'interestId' as const };
|
||||
case 'yachts':
|
||||
return { table: yachtNotes, fk: 'yachtId' as const };
|
||||
case 'companies':
|
||||
return { table: companyNotes, fk: 'companyId' as const };
|
||||
case 'residential_clients':
|
||||
return { table: residentialClientNotes, fk: 'residentialClientId' as const };
|
||||
case 'residential_interests':
|
||||
return { table: residentialInterestNotes, fk: 'residentialInterestId' as const };
|
||||
}
|
||||
}
|
||||
void tableForEntity;
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregated note timeline for a client. Unions client-level notes
|
||||
* with notes attached to ANY of the client's interests + directly-
|
||||
* owned yachts (polymorphic ownership: `owner_type='client' AND
|
||||
* owner_id=clientId`). Each row carries source metadata so the UI
|
||||
* can show "from interest E17" or "from yacht Sea Breeze" badges
|
||||
* and offer a "Group by source" view alongside chronological.
|
||||
*
|
||||
* Company-owned yachts the client is a member of are excluded —
|
||||
* those are properly the company's notes, not the client's.
|
||||
*/
|
||||
export interface AggregatedClientNote {
|
||||
id: string;
|
||||
content: string;
|
||||
mentions: string[] | null;
|
||||
isLocked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
source: 'client' | 'interest' | 'yacht';
|
||||
/** Origin entity id — interest_id / yacht_id / client_id. */
|
||||
sourceId: string;
|
||||
/** Human label for the source (interest's berth mooring, yacht
|
||||
* name, or "Client" for client-level). */
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
export async function listForClientAggregated(
|
||||
portId: string,
|
||||
clientId: string,
|
||||
): Promise<AggregatedClientNote[]> {
|
||||
await verifyParentBelongsToPort('clients', clientId, portId);
|
||||
|
||||
// Collect interest + yacht ids upfront so the note-table queries
|
||||
// can be IN-list filtered.
|
||||
const [interestRows, yachtRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
||||
db
|
||||
.select({ id: yachts.id, name: yachts.name })
|
||||
.from(yachts)
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, clientId),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
const yachtIds = yachtRows.map((r) => r.id);
|
||||
const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
|
||||
|
||||
// Resolve each interest's primary-berth mooring for the source
|
||||
// label. Cheap single round-trip via the existing junction helper.
|
||||
const primaryBerthMap =
|
||||
interestIds.length > 0
|
||||
? await (
|
||||
await import('@/lib/services/interest-berths.service')
|
||||
).getPrimaryBerthsForInterests(interestIds)
|
||||
: new Map<string, { mooringNumber: string }>();
|
||||
|
||||
// Three parallel reads against the per-entity note tables; merged
|
||||
// in JS rather than via UNION because each table has a different
|
||||
// FK column name and Drizzle's UNION syntax forces matching shapes.
|
||||
const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: clientNotes.id,
|
||||
content: clientNotes.content,
|
||||
mentions: clientNotes.mentions,
|
||||
isLocked: clientNotes.isLocked,
|
||||
createdAt: clientNotes.createdAt,
|
||||
updatedAt: clientNotes.updatedAt,
|
||||
authorId: clientNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: clientNotes.clientId,
|
||||
})
|
||||
.from(clientNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
|
||||
.where(eq(clientNotes.clientId, clientId)),
|
||||
interestIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: interestNotes.id,
|
||||
content: interestNotes.content,
|
||||
mentions: interestNotes.mentions,
|
||||
isLocked: interestNotes.isLocked,
|
||||
createdAt: interestNotes.createdAt,
|
||||
updatedAt: interestNotes.updatedAt,
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: interestNotes.interestId,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
.where(inArray(interestNotes.interestId, interestIds))
|
||||
: Promise.resolve([] as never[]),
|
||||
yachtIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: yachtNotes.id,
|
||||
content: yachtNotes.content,
|
||||
mentions: yachtNotes.mentions,
|
||||
isLocked: yachtNotes.isLocked,
|
||||
createdAt: yachtNotes.createdAt,
|
||||
updatedAt: yachtNotes.updatedAt,
|
||||
authorId: yachtNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: yachtNotes.yachtId,
|
||||
})
|
||||
.from(yachtNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
|
||||
.where(inArray(yachtNotes.yachtId, yachtIds))
|
||||
: Promise.resolve([] as never[]),
|
||||
]);
|
||||
|
||||
const merged: AggregatedClientNote[] = [
|
||||
...clientLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'client' as const,
|
||||
sourceLabel: 'Client',
|
||||
})),
|
||||
...interestLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'interest' as const,
|
||||
sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
|
||||
})),
|
||||
...yachtLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'yacht' as const,
|
||||
sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
|
||||
})),
|
||||
];
|
||||
|
||||
merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
|
||||
await verifyParentBelongsToPort(entityType, entityId, portId);
|
||||
|
||||
@@ -107,7 +295,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
|
||||
.where(eq(yachtNotes.yachtId, entityId))
|
||||
.orderBy(desc(yachtNotes.createdAt));
|
||||
} else {
|
||||
} else if (entityType === 'companies') {
|
||||
return db
|
||||
.select({
|
||||
id: companyNotes.id,
|
||||
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
|
||||
.where(eq(companyNotes.companyId, entityId))
|
||||
.orderBy(desc(companyNotes.createdAt));
|
||||
} else if (entityType === 'residential_clients') {
|
||||
return db
|
||||
.select({
|
||||
id: residentialClientNotes.id,
|
||||
residentialClientId: residentialClientNotes.residentialClientId,
|
||||
authorId: residentialClientNotes.authorId,
|
||||
content: residentialClientNotes.content,
|
||||
mentions: residentialClientNotes.mentions,
|
||||
isLocked: residentialClientNotes.isLocked,
|
||||
createdAt: residentialClientNotes.createdAt,
|
||||
updatedAt: residentialClientNotes.updatedAt,
|
||||
authorName: userProfiles.displayName,
|
||||
})
|
||||
.from(residentialClientNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
|
||||
.where(eq(residentialClientNotes.residentialClientId, entityId))
|
||||
.orderBy(desc(residentialClientNotes.createdAt));
|
||||
} else {
|
||||
return db
|
||||
.select({
|
||||
id: residentialInterestNotes.id,
|
||||
residentialInterestId: residentialInterestNotes.residentialInterestId,
|
||||
authorId: residentialInterestNotes.authorId,
|
||||
content: residentialInterestNotes.content,
|
||||
mentions: residentialInterestNotes.mentions,
|
||||
isLocked: residentialInterestNotes.isLocked,
|
||||
createdAt: residentialInterestNotes.createdAt,
|
||||
updatedAt: residentialInterestNotes.updatedAt,
|
||||
authorName: userProfiles.displayName,
|
||||
})
|
||||
.from(residentialInterestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
|
||||
.where(eq(residentialInterestNotes.residentialInterestId, entityId))
|
||||
.orderBy(desc(residentialInterestNotes.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +429,8 @@ export async function create(
|
||||
}
|
||||
|
||||
return { ...note, authorName };
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'interests') {
|
||||
const [note] = await db
|
||||
.insert(interestNotes)
|
||||
.values({ interestId: entityId, authorId, content: data.content })
|
||||
@@ -247,6 +470,38 @@ export async function create(
|
||||
|
||||
return { ...note, authorName };
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [note] = await db
|
||||
.insert(residentialClientNotes)
|
||||
.values({ residentialClientId: entityId, authorId, content: data.content })
|
||||
.returning();
|
||||
if (!note)
|
||||
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
||||
internalMessage: 'Residential client note insert returned no row',
|
||||
});
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, authorId))
|
||||
.limit(1);
|
||||
return { ...note, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [note] = await db
|
||||
.insert(residentialInterestNotes)
|
||||
.values({ residentialInterestId: entityId, authorId, content: data.content })
|
||||
.returning();
|
||||
if (!note)
|
||||
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
||||
internalMessage: 'Residential interest note insert returned no row',
|
||||
});
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, authorId))
|
||||
.limit(1);
|
||||
return { ...note, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
throw new CodedError('INTERNAL', {
|
||||
internalMessage: `Unsupported entityType: ${entityType as string}`,
|
||||
});
|
||||
@@ -338,7 +593,65 @@ export async function update(
|
||||
.limit(1);
|
||||
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialClientNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(residentialClientNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialClientNotes.id, noteId))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, updated.authorId))
|
||||
.limit(1);
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialInterestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(residentialInterestNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialInterestNotes.id, noteId))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, updated.authorId))
|
||||
.limit(1);
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
// Default: interests (the marina-side, not residential)
|
||||
{
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(interestNotes)
|
||||
@@ -416,7 +729,45 @@ export async function deleteNote(
|
||||
|
||||
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
|
||||
return existing;
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialClientNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialInterestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
|
||||
return existing;
|
||||
}
|
||||
// Default: interests
|
||||
{
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(interestNotes)
|
||||
|
||||
@@ -9,7 +9,13 @@ import { emitToRoom } from '@/lib/socket/server';
|
||||
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
|
||||
|
||||
export async function listUsers(portId: string) {
|
||||
const rows = await db
|
||||
// Two passes:
|
||||
// 1. Users with an explicit user_port_roles row for this port
|
||||
// 2. All super-admins (they have global access via the
|
||||
// userProfiles.isSuperAdmin flag, no per-port row required —
|
||||
// previous query missed them and the admin list looked empty
|
||||
// to the only super-admin viewing it)
|
||||
const portRoleRows = await db
|
||||
.select({
|
||||
userId: userPortRoles.userId,
|
||||
displayName: userProfiles.displayName,
|
||||
@@ -26,20 +32,58 @@ export async function listUsers(portId: string) {
|
||||
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
|
||||
.innerJoin(user, eq(userPortRoles.userId, user.id))
|
||||
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
|
||||
.where(eq(userPortRoles.portId, portId))
|
||||
.orderBy(userProfiles.displayName);
|
||||
.where(eq(userPortRoles.portId, portId));
|
||||
|
||||
return rows.map((row) => ({
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
role: { id: row.roleId, name: row.roleName },
|
||||
assignedAt: row.assignedAt,
|
||||
}));
|
||||
const superAdminRows = await db
|
||||
.select({
|
||||
userId: userProfiles.userId,
|
||||
displayName: userProfiles.displayName,
|
||||
email: user.email,
|
||||
phone: userProfiles.phone,
|
||||
isActive: userProfiles.isActive,
|
||||
isSuperAdmin: userProfiles.isSuperAdmin,
|
||||
lastLoginAt: userProfiles.lastLoginAt,
|
||||
assignedAt: userProfiles.createdAt,
|
||||
})
|
||||
.from(userProfiles)
|
||||
.innerJoin(user, eq(userProfiles.userId, user.id))
|
||||
.where(eq(userProfiles.isSuperAdmin, true));
|
||||
|
||||
// Dedup: a super-admin who ALSO has an explicit per-port role
|
||||
// appears once with their port-role displayed (more specific).
|
||||
const seen = new Set(portRoleRows.map((r) => r.userId));
|
||||
const merged = [
|
||||
...portRoleRows.map((row) => ({
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
role: { id: row.roleId, name: row.roleName },
|
||||
assignedAt: row.assignedAt,
|
||||
})),
|
||||
...superAdminRows
|
||||
.filter((row) => !seen.has(row.userId))
|
||||
.map((row) => ({
|
||||
userId: row.userId,
|
||||
displayName: row.displayName,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
isActive: row.isActive,
|
||||
isSuperAdmin: row.isSuperAdmin,
|
||||
lastLoginAt: row.lastLoginAt,
|
||||
// Synthetic role label — super admins don't have a per-port
|
||||
// role row, but the UI expects a `role` object. The list
|
||||
// already shows the "Super Admin" badge separately.
|
||||
role: { id: 'super_admin', name: 'super_admin' },
|
||||
assignedAt: row.assignedAt,
|
||||
})),
|
||||
];
|
||||
|
||||
merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? ''));
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getUser(userId: string, portId: string) {
|
||||
|
||||
28
src/lib/validators/interest-contact-log.ts
Normal file
28
src/lib/validators/interest-contact-log.ts
Normal 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>;
|
||||
@@ -33,7 +33,6 @@ export const createInterestSchema = z.object({
|
||||
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
|
||||
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional().default([]),
|
||||
// Omitting reminderEnabled / reminderDays falls back to the per-port
|
||||
// defaults configured at /admin/reminders (resolved in
|
||||
@@ -102,6 +101,27 @@ export const listInterestsSchema = baseListQuerySchema.extend({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─── Board (kanban) ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filters accepted by GET /api/v1/interests/board. Strict subset of
|
||||
* listInterestsSchema — `pipelineStage` and `includeArchived` are
|
||||
* intentionally omitted (the columns ARE the stages, archived deals
|
||||
* never belong on the board). No pagination params either.
|
||||
*/
|
||||
export const boardFiltersSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
|
||||
source: z.string().optional(),
|
||||
eoiStatus: z.string().optional(),
|
||||
tagIds: z
|
||||
.string()
|
||||
.transform((v) => v.split(',').filter(Boolean))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type BoardFiltersInput = z.infer<typeof boardFiltersSchema>;
|
||||
|
||||
// ─── Waiting List ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const waitingListAddSchema = z.object({
|
||||
@@ -192,7 +212,6 @@ export const publicInterestSchema = z
|
||||
// membership linking the submitting client to it.
|
||||
company: publicCompanySchema.optional(),
|
||||
source: z.literal('website').default('website'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
address: addressSchema.optional(),
|
||||
})
|
||||
.refine((data) => data.fullName || (data.firstName && data.lastName), {
|
||||
|
||||
42
src/stores/breadcrumb-store.ts
Normal file
42
src/stores/breadcrumb-store.ts
Normal 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 };
|
||||
}),
|
||||
}));
|
||||
@@ -3,31 +3,22 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
interface PipelineStore {
|
||||
viewMode: 'board' | 'table';
|
||||
boardFilters: {
|
||||
leadCategory?: string;
|
||||
search?: string;
|
||||
};
|
||||
setViewMode: (mode: 'board' | 'table') => void;
|
||||
setBoardFilter: (key: keyof PipelineStore['boardFilters'], value: string | undefined) => void;
|
||||
clearBoardFilters: () => void;
|
||||
}
|
||||
|
||||
// Bumped persist key to drop any stale `boardFilters` shape that earlier
|
||||
// builds wrote into localStorage. The board no longer has a per-stage
|
||||
// filter UI; reading a leftover `leadCategory: 'old_value'` would silently
|
||||
// hide every card in the kanban view.
|
||||
export const usePipelineStore = create<PipelineStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: 'table',
|
||||
boardFilters: {},
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setBoardFilter: (key, value) =>
|
||||
set((s) => ({ boardFilters: { ...s.boardFilters, [key]: value } })),
|
||||
clearBoardFilters: () => set({ boardFilters: {} }),
|
||||
}),
|
||||
{
|
||||
name: 'pn-crm-pipeline',
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
boardFilters: state.boardFilters,
|
||||
}),
|
||||
name: 'pn-crm-pipeline-v2',
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -245,11 +245,11 @@ describe('CRUD Audit — Interests', () => {
|
||||
|
||||
const interest = await createInterest(
|
||||
portId,
|
||||
{ ...makeCreateInterestInput({ clientId }), notes: 'initial' },
|
||||
{ ...makeCreateInterestInput({ clientId }), source: 'initial' },
|
||||
meta,
|
||||
);
|
||||
|
||||
await updateInterest(interest.id, portId, { notes: 'updated notes' }, meta);
|
||||
await updateInterest(interest.id, portId, { source: 'updated' }, meta);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/
|
||||
import {
|
||||
interests as interestsTable,
|
||||
interestBerths as interestBerthsTable,
|
||||
interestNotes as interestNotesTable,
|
||||
} from '@/lib/db/schema/interests';
|
||||
import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates';
|
||||
|
||||
@@ -39,7 +40,6 @@ async function insertInterest(args: {
|
||||
berthId?: string | null;
|
||||
pipelineStage?: string;
|
||||
leadCategory?: string;
|
||||
notes?: string;
|
||||
}) {
|
||||
const [row] = await db
|
||||
.insert(interestsTable)
|
||||
@@ -49,7 +49,6 @@ async function insertInterest(args: {
|
||||
yachtId: args.yachtId ?? null,
|
||||
pipelineStage: args.pipelineStage ?? 'open',
|
||||
leadCategory: args.leadCategory ?? null,
|
||||
notes: args.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
// Plan §3.4: legacy interest.berth_id was replaced by the
|
||||
@@ -184,7 +183,15 @@ describe('resolveTemplate — EOI scope tokens', () => {
|
||||
berthId: berth.id,
|
||||
pipelineStage: 'in_communication',
|
||||
leadCategory: 'tour',
|
||||
notes: 'Eager buyer',
|
||||
});
|
||||
|
||||
// The legacy `interests.notes` column was dropped; `{{interest.notes}}`
|
||||
// now resolves to the most-recent threaded note. Seed one so the
|
||||
// tokens-test below sees the expected "Eager buyer" content.
|
||||
await db.insert(interestNotesTable).values({
|
||||
interestId: interest.id,
|
||||
authorId: 'system',
|
||||
content: 'Eager buyer',
|
||||
});
|
||||
|
||||
const tmpl = await insertTemplate({
|
||||
|
||||
Reference in New Issue
Block a user