Compare commits
19 Commits
868b1f40c0
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80fc5932be | ||
|
|
b26b87b2fa | ||
|
|
88f76b6b04 | ||
|
|
a32f41b91d | ||
|
|
cf1c8b66db | ||
|
|
596476280d | ||
|
|
e9359fc431 | ||
|
|
4767caec01 | ||
|
|
49d92234dd | ||
|
|
e2398099c4 | ||
|
|
d364b09885 | ||
|
|
57a099acc4 | ||
|
|
a391934b73 | ||
|
|
e3e0e69c04 | ||
|
|
6af2ac9680 | ||
|
|
a767652d74 | ||
|
|
c824b2df12 | ||
|
|
d197f8b321 | ||
|
|
76a7387dcc |
@@ -1 +0,0 @@
|
||||
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -28,9 +28,19 @@ docker-compose.override.yml
|
||||
|
||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||
/*.png
|
||||
/*.jpg
|
||||
|
||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||
/client-portal/
|
||||
|
||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
||||
/website/
|
||||
|
||||
# Mobile audit screenshots — generated locally, regenerable
|
||||
/.audit/
|
||||
/.audit-screenshots/
|
||||
|
||||
# Tool caches / runtime state
|
||||
/.claude/
|
||||
/.serena/
|
||||
/ruvector.db
|
||||
|
||||
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
36
src/app/(dashboard)/[portSlug]/admin/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
|
||||
/**
|
||||
* Guard: only super-admins (isSuperAdmin === true in user_profiles) may access
|
||||
* any page under /[portSlug]/admin. Everyone else is redirected to their dashboard.
|
||||
*/
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, session.user.id),
|
||||
});
|
||||
|
||||
if (!profile?.isSuperAdmin) {
|
||||
redirect(`/${portSlug}/dashboard`);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
|
||||
|
||||
export default function BerthReservationsPage() {
|
||||
return <BerthReservationsList />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
|
||||
/**
|
||||
* Route-level loading UI for the client detail page. Renders while the
|
||||
* server component resolves the session and the client component bootstraps
|
||||
* its initial query — replaces the previous empty-header flash on direct
|
||||
* URL visits.
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header strip — title, badges, action buttons */}
|
||||
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-56" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-9 w-20 rounded-md" />
|
||||
<Skeleton className="h-9 w-20 rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
<Skeleton className="h-9 w-32 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex gap-2 border-b border-border pb-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-column overview */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,28 @@ export default function NewInvoicePage() {
|
||||
const watchedValues = watch();
|
||||
const isDepositInvoice = watchedValues.kind === 'deposit';
|
||||
|
||||
// Resolve the selected billing entity to a human name so the review step
|
||||
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
|
||||
const billingEntityRef = watchedValues.billingEntity ?? null;
|
||||
const { data: billingEntityName } = useQuery<{ name: string }>({
|
||||
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
|
||||
queryFn: async () => {
|
||||
if (!billingEntityRef) return { name: '' };
|
||||
const path =
|
||||
billingEntityRef.type === 'company'
|
||||
? `/api/v1/companies/${billingEntityRef.id}`
|
||||
: `/api/v1/clients/${billingEntityRef.id}`;
|
||||
const res = await apiFetch<{
|
||||
data: { fullName?: string; name?: string };
|
||||
}>(path);
|
||||
return {
|
||||
name: res?.data?.fullName ?? res?.data?.name ?? '',
|
||||
};
|
||||
},
|
||||
enabled: !!billingEntityRef?.id,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Pre-fill the billing entity from the linked interest's client on launch.
|
||||
useEffect(() => {
|
||||
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
||||
@@ -356,9 +378,13 @@ export default function NewInvoicePage() {
|
||||
<p className="font-medium mt-0.5">
|
||||
{watchedValues.billingEntity ? (
|
||||
<>
|
||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
||||
<span className="text-xs opacity-60">
|
||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
||||
{billingEntityName?.name ? (
|
||||
<span>{billingEntityName.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Loading…</span>
|
||||
)}{' '}
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
({watchedValues.billingEntity.type})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PermissionsProvider } from '@/providers/permissions-provider';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Topbar } from '@/components/layout/topbar';
|
||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -38,6 +39,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<PortProvider ports={ports} defaultPortId={ports[0]?.id ?? null}>
|
||||
<PermissionsProvider>
|
||||
<SocketProvider>
|
||||
<RealtimeToasts />
|
||||
{/* Desktop shell — hidden by CSS on mobile */}
|
||||
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
|
||||
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
35
src/app/api/v1/berth-reservations/handlers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listReservations } from '@/lib/services/berth-reservations.service';
|
||||
import { listReservationsSchema } from '@/lib/validators/reservations';
|
||||
|
||||
/**
|
||||
* Port-scoped global list of reservations across all berths. Inner handler
|
||||
* lives here so it can be invoked directly from integration tests without
|
||||
* the `withAuth(withPermission(...))` wrappers (matches the convention
|
||||
* used throughout `src/app/api/v1/*`).
|
||||
*/
|
||||
export async function listHandler(req: Request, ctx: AuthContext): Promise<NextResponse> {
|
||||
try {
|
||||
const query = parseQuery(req as never, listReservationsSchema);
|
||||
const result = await listReservations(ctx.portId, query);
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
4
src/app/api/v1/berth-reservations/route.ts
Normal file
4
src/app/api/v1/berth-reservations/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { listHandler } from './handlers';
|
||||
|
||||
export const GET = withAuth(withPermission('reservations', 'view', listHandler));
|
||||
@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||
import { user } from '@/lib/db/schema/users';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
|
||||
const OUTCOME_LABELS: Record<string, string> = {
|
||||
@@ -33,6 +34,10 @@ interface TimelineEvent {
|
||||
action: string;
|
||||
description: string;
|
||||
userId: string | null;
|
||||
/** Resolved display name for `userId`. `'system'` for auto-events; null when
|
||||
* the user has been deleted or the event has no actor. Falls back to
|
||||
* email-localpart if the user has no display name. */
|
||||
userName: string | null;
|
||||
createdAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
@@ -81,6 +86,27 @@ export const GET = withAuth(
|
||||
|
||||
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
||||
|
||||
// Resolve display names for any `userId` that is a real user row (the
|
||||
// sentinel value 'system' is used for auto-events and isn't joined).
|
||||
const realUserIds = Array.from(
|
||||
new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')),
|
||||
);
|
||||
const userRows =
|
||||
realUserIds.length > 0
|
||||
? await db
|
||||
.select({ id: user.id, name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(inArray(user.id, realUserIds))
|
||||
: [];
|
||||
const userNameById = new Map<string, string>(
|
||||
userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']),
|
||||
);
|
||||
const resolveUserName = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
if (userId === 'system') return 'system';
|
||||
return userNameById.get(userId) ?? null;
|
||||
};
|
||||
|
||||
// Union and sort
|
||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -93,6 +119,7 @@ export const GET = withAuth(
|
||||
row.userId,
|
||||
),
|
||||
userId: row.userId,
|
||||
userName: resolveUserName(row.userId),
|
||||
createdAt: row.createdAt,
|
||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||
}));
|
||||
@@ -106,12 +133,35 @@ export const GET = withAuth(
|
||||
action: row.eventType,
|
||||
description: `Document "${title}" ${action}`,
|
||||
userId: null,
|
||||
userName: null,
|
||||
createdAt: row.createdAt,
|
||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
const allEvents = [...auditEvents, ...docEvents];
|
||||
|
||||
// Fallback: when no audit-log entries exist for this interest (typical
|
||||
// for seed/imported data inserted directly into the table without going
|
||||
// through the service), synthesize a "Created at <stage>" event so the
|
||||
// tab isn't empty when the interest is clearly past `open`.
|
||||
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
||||
if (!hasCreateAudit) {
|
||||
const stage = stageLabel(interest.pipelineStage);
|
||||
const created = interest.createdAt ?? new Date();
|
||||
allEvents.push({
|
||||
id: `synth-${interest.id}-create`,
|
||||
type: 'audit',
|
||||
action: 'create',
|
||||
description:
|
||||
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
||||
userId: null,
|
||||
userName: null,
|
||||
createdAt: created,
|
||||
metadata: { synthetic: true },
|
||||
});
|
||||
}
|
||||
|
||||
allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return NextResponse.json({ data: allEvents.slice(0, 50) });
|
||||
|
||||
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
68
src/app/api/v1/saved-views/[id]/handlers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import type { AuthContext } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { savedViews } from '@/lib/db/schema';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||
|
||||
/**
|
||||
* Resolves the view and enforces ownership before mutating.
|
||||
*
|
||||
* Returns a 404 when the view does not exist (or lives in a different port)
|
||||
* and a 403 when it belongs to a different user. The 404-before-403 split
|
||||
* matches the rest of the API and avoids leaking the existence of another
|
||||
* user's saved view via timing or status code.
|
||||
*/
|
||||
async function assertViewOwner(
|
||||
id: string,
|
||||
portId: string,
|
||||
userId: string,
|
||||
): Promise<NextResponse | null> {
|
||||
const view = await db.query.savedViews.findFirst({
|
||||
where: and(eq(savedViews.id, id), eq(savedViews.portId, portId)),
|
||||
});
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
if (view.userId !== userId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function patchHandler(
|
||||
req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
const body = await parseBody(req as never, updateSavedViewSchema);
|
||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||
return NextResponse.json({ data: view });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHandler(
|
||||
_req: Request,
|
||||
ctx: AuthContext,
|
||||
params: { id?: string },
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const denied = await assertViewOwner(id, ctx.portId, ctx.userId);
|
||||
if (denied) return denied;
|
||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||
return NextResponse.json({ data: null }, { status: 200 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { updateSavedViewSchema } from '@/lib/validators/saved-views';
|
||||
import { patchHandler, deleteHandler } from './handlers';
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
const body = await parseBody(req, updateSavedViewSchema);
|
||||
const view = await savedViewsService.update(ctx.portId, ctx.userId, id, body);
|
||||
return NextResponse.json({ data: view });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
export const DELETE = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
const id = params.id ?? '';
|
||||
await savedViewsService.delete(ctx.portId, ctx.userId, id);
|
||||
return NextResponse.json({ data: null }, { status: 200 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
export const PATCH = withAuth(patchHandler);
|
||||
export const DELETE = withAuth(deleteHandler);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
|
||||
import {
|
||||
handleRecipientSigned,
|
||||
handleDocumentCompleted,
|
||||
handleDocumentExpired,
|
||||
handleDocumentOpened,
|
||||
handleDocumentRejected,
|
||||
handleDocumentCancelled,
|
||||
@@ -139,6 +140,10 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
await handleDocumentCancelled({ documentId: documensoId, signatureHash });
|
||||
break;
|
||||
|
||||
case 'DOCUMENT_EXPIRED':
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.info({ event }, 'Unhandled Documenso webhook event type');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||
import { Toaster } from 'sonner';
|
||||
import { classifyFormFactor } from '@/lib/form-factor';
|
||||
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({
|
||||
@@ -66,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,11 @@ export function AlertRail() {
|
||||
<section
|
||||
data-testid="alert-rail"
|
||||
aria-label="Active alerts"
|
||||
className="flex h-full flex-col gap-3"
|
||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
||||
// gives this rail a sibling column whose height it should match. On
|
||||
// mobile (single-column stack) there's no fixed-height context, so
|
||||
// forcing 100% height makes the section overflow / look stretched.
|
||||
className="flex flex-col gap-3 xl:h-full"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
@@ -8,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { BerthDetailHeader } from './berth-detail-header';
|
||||
import { BerthForm } from './berth-form';
|
||||
import { buildBerthTabs } from './berth-tabs';
|
||||
|
||||
interface BerthDetailProps {
|
||||
@@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
// Auto-open edit sheet when ?edit=true is present in the URL
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('edit') === 'true') {
|
||||
setEditOpen(true);
|
||||
// Strip the param without adding a history entry
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('edit');
|
||||
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||
// typedRoutes can't statically validate this dynamic path; cast is safe
|
||||
// because we're always replacing within the same route segment.
|
||||
router.replace(newUrl as never);
|
||||
}
|
||||
// Only run once on mount / when searchParams changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const berth = data as any;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailLayout
|
||||
isLoading={isLoading}
|
||||
header={berth ? <BerthDetailHeader berth={berth} /> : null}
|
||||
tabs={berth ? buildBerthTabs(berth) : []}
|
||||
defaultTab="overview"
|
||||
/>
|
||||
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
166
src/components/berths/berth-interest-pulse.tsx
Normal file
166
src/components/berths/berth-interest-pulse.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRight, Users } from 'lucide-react';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InterestsResponse {
|
||||
data: InterestRow[];
|
||||
}
|
||||
|
||||
const PREVIEW_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Top-of-overview pulse for the berth detail page. Lists the active
|
||||
* interested parties with their stage + last activity, so the rep can do
|
||||
* berth-level triage ("who's on this slip and how warm are they?")
|
||||
* without clicking into the Interests tab.
|
||||
*
|
||||
* Borrows from the old Nuxt CRM's BerthDetailsModal "Interested Parties"
|
||||
* pattern but uses the new at-a-glance signals (urgency badges, last
|
||||
* activity).
|
||||
*/
|
||||
export function BerthInterestPulse({ berthId }: { berthId: string }) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<InterestsResponse>({
|
||||
queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }],
|
||||
queryFn: () =>
|
||||
apiFetch<InterestsResponse>(
|
||||
`/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`,
|
||||
),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const all = data?.data ?? [];
|
||||
const active = all.filter((i) => !i.archivedAt && !i.outcome);
|
||||
const preview = active.slice(0, PREVIEW_LIMIT);
|
||||
const more = active.length - preview.length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Interested parties</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-10 animate-pulse rounded-md bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (active.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Users className="size-3.5" />
|
||||
Interested parties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm text-muted-foreground">No active interests on this berth.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3 space-y-0">
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Users className="size-3.5" />
|
||||
Interested parties
|
||||
<span className="ml-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{active.length}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ul className="divide-y divide-border">
|
||||
{preview.map((i) => {
|
||||
const lastIso = i.dateLastContact ?? i.updatedAt ?? null;
|
||||
const lastActivity = lastIso
|
||||
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||
: null;
|
||||
const urgency = computeUrgencyBadges(i);
|
||||
const initials = (i.clientName ?? '?')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]!.toUpperCase())
|
||||
.join('');
|
||||
return (
|
||||
<li key={i.id}>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/${i.id}`}
|
||||
className="group flex items-center gap-3 px-1 py-2.5 transition-colors hover:bg-foreground/5 rounded-md -mx-1"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700">
|
||||
{initials || '?'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{i.clientName ?? 'Unknown'}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
stageBadgeClass(i.pipelineStage),
|
||||
)}
|
||||
>
|
||||
{stageLabel(i.pipelineStage)}
|
||||
</span>
|
||||
{urgency.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
b.className,
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{lastActivity ? (
|
||||
<p className="text-[11px] tabular-nums text-muted-foreground">
|
||||
Last activity {lastActivity}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{more > 0 ? (
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${berthId}?tab=interests`}
|
||||
className="mt-2 inline-flex text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View all {active.length} interests →
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { BerthReservationsTab } from './berth-reservations-tab';
|
||||
import { BerthInterestsTab } from './berth-interests-tab';
|
||||
import { BerthInterestPulse } from './berth-interest-pulse';
|
||||
|
||||
type BerthData = {
|
||||
id: string;
|
||||
@@ -72,6 +73,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sales pulse — top-of-page so reps doing berth-level triage can see
|
||||
who's interested + how warm without clicking into the Interests tab. */}
|
||||
<BerthInterestPulse berthId={berth.id} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Specifications */}
|
||||
<Card>
|
||||
@@ -161,6 +167,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ClientRow {
|
||||
createdAt: string;
|
||||
yachtCount?: number;
|
||||
companyCount?: number;
|
||||
interestCount?: number;
|
||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||
import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -12,31 +13,28 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
|
||||
interface ClientDetailHeaderProps {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
nationalityIso?: string | null;
|
||||
archivedAt?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||
createdAt?: string;
|
||||
contacts?: Array<{
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164?: string | null;
|
||||
isPrimary: boolean;
|
||||
label?: string | null;
|
||||
}>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
clientPortalEnabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
@@ -62,19 +60,34 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
});
|
||||
|
||||
const primaryEmail =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'email');
|
||||
const primaryPhone =
|
||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
||||
const primaryPhoneContact =
|
||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||
client.contacts?.find((c) => c.channel === 'phone');
|
||||
const primaryPhone = primaryPhoneContact?.value;
|
||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
||||
// display value as a best-effort fallback.
|
||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
||||
: primaryPhoneContact?.value
|
||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const addedLabel = client.createdAt
|
||||
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
|
||||
: null;
|
||||
const meta = [country, addedLabel].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailHeaderStrip>
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
|
||||
{client.fullName}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
@@ -84,31 +97,71 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||
{client.source && (
|
||||
<span>
|
||||
Source:{' '}
|
||||
<span className="text-foreground">
|
||||
{SOURCE_LABELS[client.source] ?? client.source}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{primaryEmail && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{primaryEmail.value}
|
||||
</span>
|
||||
)}
|
||||
{primaryPhone && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{primaryPhone.value}
|
||||
</span>
|
||||
)}
|
||||
{meta.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{primaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{primaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled !== false ? (
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
defaultEmail={primaryEmail}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hidden sm:inline-flex">
|
||||
<GdprExportButton clientId={client.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{client.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
@@ -116,34 +169,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!isArchived && client.clientPortalEnabled !== false && (
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
clientName={client.fullName}
|
||||
defaultEmail={primaryEmail?.value}
|
||||
/>
|
||||
)}
|
||||
<GdprExportButton clientId={client.id} />
|
||||
<Button
|
||||
variant={isArchived ? 'outline' : 'outline'}
|
||||
size="sm"
|
||||
{/* Top-right: archive/restore as a small icon button — destructive
|
||||
action sits out of the primary action flow. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</>
|
||||
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
>
|
||||
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</DetailHeaderStrip>
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ interface ClientData {
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
valueCountry: string | null;
|
||||
label: string | null;
|
||||
isPrimary: boolean;
|
||||
notes: string | null;
|
||||
|
||||
@@ -339,10 +339,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preferred Language</Label>
|
||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Timezone</Label>
|
||||
<TimezoneCombobox
|
||||
|
||||
460
src/components/clients/client-interests-tab.tsx
Normal file
460
src/components/clients/client-interests-tab.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
||||
import {
|
||||
StageStepper,
|
||||
useClientInterests,
|
||||
type ClientInterestRow,
|
||||
} from '@/components/clients/client-pipeline-summary';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
|
||||
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General interest',
|
||||
specific_qualified: 'Specific qualified',
|
||||
hot_lead: 'Hot lead',
|
||||
};
|
||||
|
||||
function InterestRowItem({
|
||||
interest,
|
||||
onOpen,
|
||||
}: {
|
||||
interest: ClientInterestRow;
|
||||
onOpen: (i: ClientInterestRow) => void;
|
||||
}) {
|
||||
const stage = safeStage(interest.pipelineStage);
|
||||
|
||||
const berthLabel = interest.berthMooringNumber
|
||||
? `Berth ${interest.berthMooringNumber}`
|
||||
: 'General interest';
|
||||
|
||||
const yachtLabel = interest.yachtName ?? null;
|
||||
|
||||
return (
|
||||
// Tap opens a bottom-sheet preview drawer rather than navigating to the
|
||||
// full interest page. The drawer covers ~80% of mobile interactions
|
||||
// ("what stage is this at, when did we last touch it"). For deeper
|
||||
// edits the drawer has an "Open full page" CTA.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(interest)}
|
||||
className={cn(
|
||||
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
|
||||
'hover:border-border/70 hover:shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{berthLabel}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
</div>
|
||||
{yachtLabel ? (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<StageStepper current={stage} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function lastActivityFor(interest: ClientInterestRow): string | null {
|
||||
const candidates = [interest.dateLastContact, interest.updatedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime())
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
if (candidates.length === 0) return null;
|
||||
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
|
||||
}
|
||||
|
||||
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
|
||||
* the drawer actually reads are typed here; the API returns more. */
|
||||
interface InterestDetail {
|
||||
id: string;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
}
|
||||
|
||||
function useInterestDetail(id: string | null) {
|
||||
return useQuery<{ data: InterestDetail }>({
|
||||
queryKey: ['interest-detail-drawer', id],
|
||||
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
|
||||
enabled: id !== null,
|
||||
// Detail rarely changes during a single drawer-open session; stale-time
|
||||
// keeps re-opens snappy without preventing background refetch.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
|
||||
* empty input so callers can render an "empty" state. */
|
||||
function formatDate(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
/** A single milestone row inside the drawer's milestone summary. Filled
|
||||
* circle when the step is done, hollow when pending. Trailing meta line
|
||||
* shows the date stamp or a "pending" hint. */
|
||||
function MilestoneRow({
|
||||
label,
|
||||
done,
|
||||
date,
|
||||
hint = 'pending',
|
||||
}: {
|
||||
label: string;
|
||||
done: boolean;
|
||||
date: string | null;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-2 py-1">
|
||||
{done ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
||||
) : (
|
||||
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
|
||||
)}
|
||||
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-sheet preview of a single interest. Designed for the mobile
|
||||
* "tap an interest → see what's happening without leaving the client
|
||||
* page" flow. Shows the pipeline progress, a compact milestone summary
|
||||
* (EOI / Deposit / Contract), lead context, last contact, and a notes
|
||||
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
|
||||
* away via "Open full page →".
|
||||
*/
|
||||
function InterestPreviewDrawer({
|
||||
interest,
|
||||
portSlug,
|
||||
onClose,
|
||||
}: {
|
||||
interest: ClientInterestRow | null;
|
||||
portSlug: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Pin the most recently selected interest so the drawer stays populated
|
||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
||||
// after `open=false`). Conditional setState is safe here — the guard
|
||||
// ensures it only fires when the prop actually changes to a new row.
|
||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
||||
if (interest && interest !== pinned) setPinned(interest);
|
||||
const showing = pinned;
|
||||
|
||||
const detail = useInterestDetail(showing?.id ?? null);
|
||||
const fullDetail = detail.data?.data ?? null;
|
||||
|
||||
const open = interest !== null;
|
||||
const stage = showing ? safeStage(showing.pipelineStage) : null;
|
||||
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
|
||||
const reached = (target: PipelineStage) =>
|
||||
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
||||
|
||||
const berthLabel = showing
|
||||
? showing.berthMooringNumber
|
||||
? `Berth ${showing.berthMooringNumber}`
|
||||
: 'General interest'
|
||||
: '';
|
||||
const yachtLabel = showing?.yachtName ?? null;
|
||||
const activity = showing ? lastActivityFor(showing) : null;
|
||||
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
|
||||
|
||||
const leadLabel = fullDetail?.leadCategory
|
||||
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
|
||||
: null;
|
||||
const sourceLabel = fullDetail?.source
|
||||
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
: null;
|
||||
const lastContactDate = formatDate(fullDetail?.dateLastContact);
|
||||
const notesPreview = fullDetail?.notes?.trim() || null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
|
||||
{yachtLabel ? (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{stage ? (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
||||
row card, so the at-a-glance progress hint is consistent
|
||||
across surfaces. */}
|
||||
{stage ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pipeline progress
|
||||
</p>
|
||||
<StageStepper current={stage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Milestones — three sections matching the full interest detail
|
||||
page (EOI / Deposit / Contract). Done-state is derived from
|
||||
the pipeline stage so seed data without per-step dates still
|
||||
renders correctly. The full milestone columns + per-step
|
||||
actions live behind "Open full page". */}
|
||||
<section>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Milestones
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">EOI</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="EOI sent"
|
||||
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
|
||||
date={formatDate(fullDetail?.dateEoiSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="EOI signed"
|
||||
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
|
||||
date={formatDate(fullDetail?.dateEoiSigned)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">Deposit</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Deposit received"
|
||||
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
|
||||
date={formatDate(fullDetail?.dateDepositReceived)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<p className="mb-1 text-sm font-semibold">Contract</p>
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Contract sent"
|
||||
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
|
||||
date={formatDate(fullDetail?.dateContractSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="Contract signed"
|
||||
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
|
||||
date={formatDate(fullDetail?.dateContractSigned)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Compact key/value pairs — lead category, source, last contact,
|
||||
activity. Each row collapses cleanly when its value is
|
||||
missing so the drawer scales from sparse seed data to full
|
||||
records without empty placeholders. */}
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
{leadLabel ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Lead</dt>
|
||||
<dd className="text-right font-medium">{leadLabel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{sourceLabel ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Source</dt>
|
||||
<dd className="text-right font-medium">{sourceLabel}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{lastContactDate ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Last contact</dt>
|
||||
<dd className="text-right font-medium">{lastContactDate}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{activity ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Last activity</dt>
|
||||
<dd className="text-right font-medium">{activity}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{notesPreview ? (
|
||||
<section>
|
||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Notes
|
||||
</p>
|
||||
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
|
||||
{notesPreview}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Button asChild className="w-full" size="lg">
|
||||
<Link href={fullHref}>
|
||||
Open full page
|
||||
<ArrowRight className="ml-1.5 size-4" aria-hidden />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function InterestSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-24" />
|
||||
<Skeleton className="mt-3 h-2 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientInterestsTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useClientInterests(clientId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<InterestSkeleton />
|
||||
<InterestSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
|
||||
}
|
||||
|
||||
const interests = data?.data ?? [];
|
||||
|
||||
if (interests.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<EmptyState
|
||||
title="No interests yet"
|
||||
description="When this client expresses interest in a berth, the sales process will appear here."
|
||||
action={{
|
||||
label: 'Add interest',
|
||||
onClick: () => setCreateOpen(true),
|
||||
}}
|
||||
/>
|
||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const active = interests.filter((i) => !i.archivedAt);
|
||||
const archived = interests.filter((i) => i.archivedAt);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
Add interest
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{active.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{active.map((i) => (
|
||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{archived.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Archived
|
||||
</h4>
|
||||
<div className="space-y-3 opacity-60">
|
||||
{archived.map((i) => (
|
||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<InterestPreviewDrawer
|
||||
interest={previewInterest}
|
||||
portSlug={portSlug}
|
||||
onClose={() => setPreviewInterest(null)}
|
||||
/>
|
||||
|
||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
src/components/clients/client-pipeline-summary.tsx
Normal file
311
src/components/clients/client-pipeline-summary.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PIPELINE_STAGES,
|
||||
STAGE_BADGE,
|
||||
STAGE_DOT,
|
||||
STAGE_LABELS,
|
||||
safeStage,
|
||||
type PipelineStage,
|
||||
} from '@/components/clients/pipeline-constants';
|
||||
|
||||
export interface ClientInterestRow {
|
||||
id: string;
|
||||
pipelineStage: string;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
dateLastContact: string | null;
|
||||
berthMooringNumber?: string | null;
|
||||
yachtName?: string | null;
|
||||
}
|
||||
|
||||
interface InterestsResponse {
|
||||
data: ClientInterestRow[];
|
||||
}
|
||||
|
||||
export function useClientInterests(clientId: string) {
|
||||
return useQuery<InterestsResponse>({
|
||||
queryKey: ['interests', { clientId }],
|
||||
queryFn: () => apiFetch<InterestsResponse>(`/api/v1/interests?clientId=${clientId}&limit=50`),
|
||||
});
|
||||
}
|
||||
|
||||
export function StageStepper({
|
||||
current,
|
||||
size = 'sm',
|
||||
}: {
|
||||
current: PipelineStage;
|
||||
size?: 'xs' | 'sm';
|
||||
}) {
|
||||
const idx = PIPELINE_STAGES.indexOf(current);
|
||||
// Segmented progress bar: each stage is a slice of equal width that
|
||||
// lights up once the interest has reached it. Reads at-a-glance, scales
|
||||
// to any container width, and works with 9 stages without becoming
|
||||
// micro-dots that vanish under cramped layouts.
|
||||
const height = size === 'xs' ? 'h-1' : 'h-1.5';
|
||||
return (
|
||||
<div
|
||||
className={cn('flex w-full overflow-hidden rounded-full bg-muted', height)}
|
||||
role="progressbar"
|
||||
aria-label="Pipeline progress"
|
||||
aria-valuenow={idx + 1}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={PIPELINE_STAGES.length}
|
||||
>
|
||||
{PIPELINE_STAGES.map((stage, i) => {
|
||||
const isReached = i <= idx;
|
||||
const isCurrent = i === idx;
|
||||
return (
|
||||
<div
|
||||
key={stage}
|
||||
title={`${STAGE_LABELS[stage]}${isCurrent ? ' (current)' : ''}`}
|
||||
className={cn(
|
||||
'flex-1 transition-colors',
|
||||
isReached ? STAGE_DOT[stage] : 'bg-transparent',
|
||||
i > 0 ? 'border-l border-card' : '',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickHighest(interests: ClientInterestRow[]): ClientInterestRow | null {
|
||||
const active = interests.filter((i) => !i.archivedAt);
|
||||
if (active.length === 0) return null;
|
||||
return [...active].sort((a, b) => {
|
||||
const ai = PIPELINE_STAGES.indexOf(safeStage(a.pipelineStage));
|
||||
const bi = PIPELINE_STAGES.indexOf(safeStage(b.pipelineStage));
|
||||
if (ai !== bi) return bi - ai;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
})[0]!;
|
||||
}
|
||||
|
||||
function lastActivityLabel(interests: ClientInterestRow[]): string | null {
|
||||
const candidates = interests
|
||||
.flatMap((i) => [i.dateLastContact, i.updatedAt])
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime())
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
if (candidates.length === 0) return null;
|
||||
const latest = new Date(Math.max(...candidates));
|
||||
return `${formatDistanceToNowStrict(latest)} ago`;
|
||||
}
|
||||
|
||||
interface PipelineSummaryProps {
|
||||
clientId: string;
|
||||
/**
|
||||
* `hero` — single-line pulse for the detail header (highest active stage only).
|
||||
* `panel` — compact list of every active interest, for the Overview tab.
|
||||
*/
|
||||
variant?: 'hero' | 'panel';
|
||||
}
|
||||
|
||||
function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||
const pathname = usePathname();
|
||||
const { data, isLoading } = useClientInterests(clientId);
|
||||
const interests = data?.data ?? [];
|
||||
const top = pickHighest(interests);
|
||||
const activeCount = interests.filter((i) => !i.archivedAt).length;
|
||||
const activity = lastActivityLabel(interests);
|
||||
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-2 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!top) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start one to begin tracking the sales process.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/new` as Route}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Start interest <ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stage = safeStage(top.pipelineStage);
|
||||
const berthLabel = top.berthMooringNumber
|
||||
? `Berth ${top.berthMooringNumber}`
|
||||
: 'General interest';
|
||||
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Sales pipeline
|
||||
</span>
|
||||
{activeCount > 1 ? (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
· {activeCount} active
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={detailsHref}
|
||||
className="group -m-1 block rounded-lg p-1 transition-colors hover:bg-foreground/5"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{berthLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<StageStepper current={stage} size="xs" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{activity ? `Last activity ${activity}` : 'No activity recorded'}</span>
|
||||
{activeCount > 1 ? (
|
||||
<Link
|
||||
href={interestsTabHref}
|
||||
className="font-medium text-primary hover:underline"
|
||||
scroll={false}
|
||||
>
|
||||
View all {activeCount}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||
const pathname = usePathname();
|
||||
const { data, isLoading } = useClientInterests(clientId);
|
||||
const interests = (data?.data ?? []).filter((i) => !i.archivedAt);
|
||||
const interestsTabHref = `${pathname}?tab=interests` as Route;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-2 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (interests.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">No active interests</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start one to begin tracking the sales process.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/${portSlug}/interests/new` as Route}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Start interest <ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...interests].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Sales pipeline · {interests.length} active
|
||||
</span>
|
||||
<Link
|
||||
href={interestsTabHref}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
scroll={false}
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{sorted.map((i) => {
|
||||
const stage = safeStage(i.pipelineStage);
|
||||
const berthLabel = i.berthMooringNumber
|
||||
? `Berth ${i.berthMooringNumber}`
|
||||
: 'General interest';
|
||||
const href = `/${portSlug}/interests/${i.id}` as Route;
|
||||
return (
|
||||
<li key={i.id}>
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex items-center gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-foreground/5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{berthLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
STAGE_BADGE[stage],
|
||||
)}
|
||||
>
|
||||
{STAGE_LABELS[stage]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<StageStepper current={stage} size="xs" />
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientPipelineSummary({ clientId, variant = 'panel' }: PipelineSummaryProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
|
||||
return variant === 'hero' ? (
|
||||
<HeroVariant clientId={clientId} portSlug={portSlug} />
|
||||
) : (
|
||||
<PanelVariant clientId={clientId} portSlug={portSlug} />
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||
@@ -131,6 +133,11 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
@@ -148,12 +155,6 @@ function OverviewTab({
|
||||
data-testid="client-nationality-inline"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Preferred Language">
|
||||
<InlineEditableField
|
||||
value={client.preferredLanguage}
|
||||
onSave={save('preferredLanguage')}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Timezone">
|
||||
<InlineTimezoneField
|
||||
value={client.timezone}
|
||||
@@ -209,6 +210,7 @@ function OverviewTab({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +221,11 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
label: 'Overview',
|
||||
content: <OverviewTab clientId={clientId} client={client} />,
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: <ClientInterestsTab clientId={clientId} />,
|
||||
},
|
||||
{
|
||||
id: 'yachts',
|
||||
label: 'Yachts',
|
||||
@@ -251,15 +258,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Interests will appear here once created.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
|
||||
@@ -155,6 +155,7 @@ function ContactRow({
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
||||
const [phoneEditing, setPhoneEditing] = useState(false);
|
||||
|
||||
async function togglePrimary() {
|
||||
try {
|
||||
@@ -174,17 +175,31 @@ function ContactRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
{/* Left: channel + value */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
data-editing={phoneEditing ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group rounded-lg border text-sm transition-all duration-150',
|
||||
// Active-edit dilation: lift the row out of the muted baseline with a
|
||||
// soft primary ring + slightly brighter surface. Single visual signal
|
||||
// replaces the need for any "now editing" label.
|
||||
phoneEditing
|
||||
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
|
||||
: 'bg-muted/30 p-2 gap-2',
|
||||
// Stack value editor / action cluster on mobile; single row on sm+.
|
||||
'flex flex-col sm:flex-row sm:items-center',
|
||||
)}
|
||||
>
|
||||
{/* Top / left: channel + value */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</ChannelPicker>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||
<InlinePhoneField
|
||||
e164={contact.valueE164 ?? null}
|
||||
country={contact.valueCountry ?? null}
|
||||
onEditingChange={setPhoneEditing}
|
||||
onSave={async ({ e164, country }) => {
|
||||
if (!e164) {
|
||||
toast.error('Phone number is required');
|
||||
@@ -208,9 +223,11 @@ function ContactRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: tag + actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-28 text-xs text-muted-foreground text-right">
|
||||
{/* Bottom / right: tag + actions. Hidden while the phone editor is active
|
||||
to keep focus on the form — no chips fighting for space, no noise. */}
|
||||
{!phoneEditing ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<div className="w-28 text-right text-xs text-muted-foreground">
|
||||
<InlineEditableField
|
||||
value={
|
||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||
@@ -228,7 +245,7 @@ function ContactRow({
|
||||
onClick={togglePrimary}
|
||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-background/60 transition-colors',
|
||||
'rounded p-1 transition-colors hover:bg-background/60',
|
||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
@@ -239,11 +256,13 @@ function ContactRow({
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Remove"
|
||||
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
||||
// Trash is opacity-0 on desktop hover-only; on touch, always show.
|
||||
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -330,7 +349,9 @@ function NewContactForm({
|
||||
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||
// Single row on sm+; wraps onto multiple lines below 640px so the channel
|
||||
// picker, value field, label, and buttons each get their own usable width.
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
|
||||
<Select
|
||||
value={channel}
|
||||
onValueChange={(next) => {
|
||||
@@ -353,7 +374,7 @@ function NewContactForm({
|
||||
</Select>
|
||||
|
||||
{isPhoneChannel ? (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
||||
<PhoneInput
|
||||
value={phoneValue}
|
||||
onChange={(v) => setPhoneValue(v)}
|
||||
@@ -365,7 +386,7 @@ function NewContactForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
||||
className="h-7 text-sm flex-1 min-w-0"
|
||||
className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto"
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
@@ -382,7 +403,7 @@ function NewContactForm({
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="tag (optional)"
|
||||
className="h-7 text-xs w-28"
|
||||
className="h-7 w-28 text-xs"
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -393,6 +414,7 @@ function NewContactForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
@@ -400,5 +422,6 @@ function NewContactForm({
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,10 @@ function ActivityFeedInner() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recent activity yet — your team's actions (interests created, stages changed,
|
||||
invoices sent) will appear here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
@@ -49,7 +50,7 @@ export function DashboardShell() {
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-4">
|
||||
<KpiCardsWithBoundary />
|
||||
</div>
|
||||
|
||||
@@ -68,7 +69,10 @@ export function DashboardShell() {
|
||||
<LeadSourceChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
</div>
|
||||
<aside className="min-w-0">
|
||||
<aside className="min-w-0 space-y-4">
|
||||
<WidgetErrorBoundary>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<AlertRail />
|
||||
</WidgetErrorBoundary>
|
||||
|
||||
@@ -28,11 +28,10 @@ function formatPercent(value: number): string {
|
||||
|
||||
function KpiTileSkeleton() {
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="mt-3 h-7 w-32" />
|
||||
<Skeleton className="mt-2 h-3 w-12" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,18 +54,24 @@ export function LeadSourceChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !slices.length ? (
|
||||
<EmptyState title="No interests in range" />
|
||||
<EmptyState
|
||||
title="No interests in range"
|
||||
description="Lights up once new interests are created — tracks where each came from (website, referral, broker)."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
// Percentage radii + center-anchored chart so the pie scales with
|
||||
// the container instead of being clipped to a constant 90px ring at
|
||||
// narrow widths. Legend is reserved a fixed footer height.
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={90}
|
||||
innerRadius={50}
|
||||
cy="45%"
|
||||
outerRadius="70%"
|
||||
innerRadius="40%"
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((_, i) => (
|
||||
@@ -80,7 +86,11 @@ export function LeadSourceChart({ range }: Props) {
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={40}
|
||||
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
156
src/components/dashboard/my-reminders-rail.tsx
Normal file
156
src/components/dashboard/my-reminders-rail.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { formatDistanceToNowStrict, isAfter, isBefore } from 'date-fns';
|
||||
import { AlarmClock, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ReminderRow {
|
||||
id: string;
|
||||
title: string;
|
||||
dueAt: string;
|
||||
status: string;
|
||||
priority?: string | null;
|
||||
interestId?: string | null;
|
||||
clientId?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
}
|
||||
|
||||
interface MyRemindersResponse {
|
||||
data: ReminderRow[];
|
||||
}
|
||||
|
||||
const PRIORITY_BADGE: Record<string, string> = {
|
||||
high: 'bg-rose-100 text-rose-700',
|
||||
medium: 'bg-amber-100 text-amber-700',
|
||||
low: 'bg-slate-100 text-slate-700',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||
* to the current user (overdue first, then upcoming). Each item links to its
|
||||
* subject — interest preferred, then client, then the generic entity ref.
|
||||
*
|
||||
* Limited to 6 items; "View all" routes to /reminders.
|
||||
*/
|
||||
export function MyRemindersRail() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<MyRemindersResponse>({
|
||||
queryKey: ['reminders', 'my'],
|
||||
queryFn: () => apiFetch<MyRemindersResponse>('/api/v1/reminders/my'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const items = data?.data ?? [];
|
||||
const now = new Date();
|
||||
// Overdue first, then upcoming, capped at 6 for the rail.
|
||||
const sorted = [...items]
|
||||
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
|
||||
.slice(0, 6);
|
||||
const overdueCount = items.filter((r) => isBefore(new Date(r.dueAt), now)).length;
|
||||
|
||||
function hrefFor(r: ReminderRow): string {
|
||||
if (r.interestId) return `/${portSlug}/interests/${r.interestId}`;
|
||||
if (r.clientId) return `/${portSlug}/clients/${r.clientId}`;
|
||||
if (r.entityType === 'client' && r.entityId) return `/${portSlug}/clients/${r.entityId}`;
|
||||
if (r.entityType === 'interest' && r.entityId) return `/${portSlug}/interests/${r.entityId}`;
|
||||
if (r.entityType === 'berth' && r.entityId) return `/${portSlug}/berths/${r.entityId}`;
|
||||
return `/${portSlug}/reminders`;
|
||||
}
|
||||
|
||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
||||
// tall empty card.
|
||||
return (
|
||||
<Card className="xl:h-full">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="space-y-0.5">
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
<AlarmClock className="size-4" />
|
||||
Reminders
|
||||
</CardTitle>
|
||||
{overdueCount > 0 ? (
|
||||
<p className="text-xs text-rose-700">{overdueCount} overdue</p>
|
||||
) : items.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{items.length} pending</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${portSlug}/reminders` as never}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-9 animate-pulse rounded-md bg-muted/40" />
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||
All caught up — no reminders.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{sorted.map((r) => {
|
||||
const due = new Date(r.dueAt);
|
||||
const isOverdue = isBefore(due, now);
|
||||
const isUpcoming = isAfter(due, now);
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<Link
|
||||
href={hrefFor(r) as never}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
|
||||
'hover:bg-foreground/5',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
isOverdue ? 'bg-rose-500' : 'bg-amber-400',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{r.title}</span>
|
||||
{r.priority && r.priority !== 'low' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'border-transparent text-[10px]',
|
||||
PRIORITY_BADGE[r.priority] ?? 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{r.priority}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{isOverdue
|
||||
? formatDistanceToNowStrict(due) + ' overdue'
|
||||
: isUpcoming
|
||||
? 'in ' + formatDistanceToNowStrict(due)
|
||||
: 'now'}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
|
||||
interface PipelineRow {
|
||||
@@ -15,6 +16,7 @@ interface PipelineRow {
|
||||
}
|
||||
|
||||
function PipelineChartInner() {
|
||||
const isMobile = useIsMobile();
|
||||
const { data, isLoading } = useQuery<PipelineRow[]>({
|
||||
queryKey: ['dashboard', 'pipeline'],
|
||||
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
|
||||
@@ -27,7 +29,7 @@ function PipelineChartInner() {
|
||||
}
|
||||
|
||||
const chartData = (data ?? []).map((row) => ({
|
||||
stage: stageLabel(row.stage),
|
||||
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(row.stage)] : stageLabel(row.stage),
|
||||
count: row.count,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxi
|
||||
|
||||
import { CardSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useFunnel } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
@@ -15,10 +16,12 @@ interface Props {
|
||||
|
||||
export function PipelineFunnelChart({ range }: Props) {
|
||||
const { data, isLoading } = useFunnel(range);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const stages = data?.stages ?? [];
|
||||
// Use short labels on mobile so the rotated axis isn't a wall of overlap.
|
||||
const chartData = stages.map((s) => ({
|
||||
stage: stageLabel(s.stage),
|
||||
stage: isMobile ? STAGE_SHORT_LABELS[safeStage(s.stage)] : stageLabel(s.stage),
|
||||
count: s.count,
|
||||
conversionPct: s.conversionPct,
|
||||
}));
|
||||
@@ -41,7 +44,10 @@ export function PipelineFunnelChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : allZero ? (
|
||||
<EmptyState title="No interests in range" description="Try a longer date range." />
|
||||
<EmptyState
|
||||
title="No interests in range"
|
||||
description="Conversion through Open → EOI → Deposit → Contract appears here. Try a longer date range, or add an interest to see it."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
|
||||
|
||||
@@ -47,7 +47,10 @@ export function RevenueBreakdownChart({ range }: Props) {
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !bars.length ? (
|
||||
<EmptyState title="No invoices in range" description="Invoices appear here once issued." />
|
||||
<EmptyState
|
||||
title="No invoices in range"
|
||||
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||
|
||||
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
104
src/components/dev/react-grab-viewport-sync.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type Edge = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
interface ToolbarState {
|
||||
edge: Edge;
|
||||
ratio: number;
|
||||
collapsed: boolean;
|
||||
enabled: boolean;
|
||||
defaultAction?: string;
|
||||
}
|
||||
|
||||
interface ReactGrabAPI {
|
||||
setToolbarState: (state: Partial<ToolbarState>) => void;
|
||||
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__REACT_GRAB__?: ReactGrabAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
|
||||
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
|
||||
|
||||
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
|
||||
edge: 'bottom',
|
||||
ratio: 0.5,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
const MOBILE_DEFAULT: Partial<ToolbarState> = {
|
||||
edge: 'right',
|
||||
ratio: 0.5,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
export function ReactGrabViewportSync() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'development') return;
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
let pollId: number | undefined;
|
||||
|
||||
const wireUp = (api: ReactGrabAPI) => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
|
||||
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
|
||||
|
||||
let suppressNextWrite = false;
|
||||
const apply = () => {
|
||||
const stored = localStorage.getItem(keyFor());
|
||||
suppressNextWrite = true;
|
||||
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
const unsubscribe = api.onToolbarStateChange((state) => {
|
||||
if (suppressNextWrite) {
|
||||
suppressNextWrite = false;
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(keyFor(), JSON.stringify(state));
|
||||
});
|
||||
|
||||
mql.addEventListener('change', apply);
|
||||
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
|
||||
};
|
||||
|
||||
const tryWire = () => {
|
||||
const api = window.__REACT_GRAB__;
|
||||
if (!api) return false;
|
||||
wireUp(api);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryWire()) {
|
||||
pollId = window.setInterval(() => {
|
||||
if (tryWire() && pollId !== undefined) {
|
||||
window.clearInterval(pollId);
|
||||
pollId = undefined;
|
||||
}
|
||||
}, 100);
|
||||
window.setTimeout(() => {
|
||||
if (pollId !== undefined) {
|
||||
window.clearInterval(pollId);
|
||||
pollId = undefined;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollId !== undefined) window.clearInterval(pollId);
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -25,6 +25,9 @@ interface DocumentRow {
|
||||
interface DocumentListProps {
|
||||
interestId?: string;
|
||||
clientId?: string;
|
||||
/** Override the default empty state ("No documents yet.") with a contextual
|
||||
* CTA — e.g. on the interest Documents tab we render a Generate EOI prompt. */
|
||||
emptyState?: React.ReactNode;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
@@ -44,7 +47,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
||||
export function DocumentList({ interestId, clientId, emptyState }: DocumentListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
@@ -83,10 +86,13 @@ export function DocumentList({ interestId, clientId }: DocumentListProps) {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>;
|
||||
return (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (emptyState) return <>{emptyState}</>;
|
||||
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
||||
rejected: 'rejected',
|
||||
};
|
||||
|
||||
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
sent: 'Sent',
|
||||
signed: 'Signed',
|
||||
declined: 'Declined',
|
||||
expired: 'Expired',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
interface DocumentsHubProps {
|
||||
portSlug: string;
|
||||
initialTab?: DocumentsHubTab;
|
||||
@@ -187,7 +196,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||
</div>
|
||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||
{signer.status}
|
||||
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||
</StatusPill>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -22,8 +22,14 @@ import {
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
/** Required for the EOI's top paragraph (Section 2) — without these the
|
||||
* document is unsignable, so generation is blocked. Yacht and berth fields
|
||||
* belong to Section 3 and may be left blank. */
|
||||
interface EoiPrerequisites {
|
||||
hasName: boolean;
|
||||
hasEmail: boolean;
|
||||
hasAddress: boolean;
|
||||
/** Optional — info-only checks. Generation proceeds without them. */
|
||||
hasYacht: boolean;
|
||||
hasBerth: boolean;
|
||||
}
|
||||
@@ -35,10 +41,15 @@ interface EoiGenerateDialogProps {
|
||||
prerequisites: EoiPrerequisites;
|
||||
}
|
||||
|
||||
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||
{ key: 'hasName', label: 'Client has full name' },
|
||||
{ key: 'hasYacht', label: 'Yacht linked to interest' },
|
||||
{ key: 'hasBerth', label: 'Berth linked to interest' },
|
||||
const REQUIRED_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||
{ key: 'hasName', label: 'Client name' },
|
||||
{ key: 'hasAddress', label: 'Client address' },
|
||||
{ key: 'hasEmail', label: 'Client email' },
|
||||
];
|
||||
|
||||
const OPTIONAL_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
|
||||
{ key: 'hasYacht', label: 'Yacht linked (name + dimensions)' },
|
||||
{ key: 'hasBerth', label: 'Berth linked (mooring number)' },
|
||||
];
|
||||
|
||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||
@@ -65,7 +76,7 @@ export function EoiGenerateDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
|
||||
|
||||
const allMet = Object.values(prerequisites).every(Boolean);
|
||||
const requiredMet = REQUIRED_LABELS.every(({ key }) => prerequisites[key]);
|
||||
|
||||
// Load in-app EOI templates so the operator can pick one as an alternative
|
||||
// to the Documenso external-signing flow.
|
||||
@@ -79,7 +90,7 @@ export function EoiGenerateDialog({
|
||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!allMet) return;
|
||||
if (!requiredMet) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
@@ -98,7 +109,13 @@ export function EoiGenerateDialog({
|
||||
},
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
|
||||
// Invalidate all document list queries (hub counts + per-interest lists).
|
||||
// The DocumentList component uses ['documents', { interestId, clientId }]
|
||||
// and the hub uses ['documents', 'hub', ...] / ['documents', 'hub-counts'].
|
||||
// Using a predicate avoids key-shape drift between callers.
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => q.queryKey[0] === 'documents',
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||
@@ -138,10 +155,13 @@ export function EoiGenerateDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Prerequisites</p>
|
||||
{PREREQUISITE_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Required (Section 2 of the EOI)
|
||||
</p>
|
||||
{REQUIRED_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||
prerequisites[key] ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
@@ -149,12 +169,46 @@ export function EoiGenerateDialog({
|
||||
>
|
||||
{prerequisites[key] ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
|
||||
<span
|
||||
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Optional (Section 3 — left blank if absent)
|
||||
</p>
|
||||
{OPTIONAL_LABELS.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center gap-3 text-sm">
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
|
||||
prerequisites[key]
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{prerequisites[key] ? '✓' : '–'}
|
||||
</span>
|
||||
<span
|
||||
className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!requiredMet ? (
|
||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Add the missing required details on the client's record before generating the
|
||||
EOI.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
@@ -163,7 +217,7 @@ export function EoiGenerateDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
|
||||
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating}>
|
||||
{isGenerating ? 'Generating…' : 'Generate EOI'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
163
src/components/interests/inline-stage-picker.tsx
Normal file
163
src/components/interests/inline-stage-picker.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Check, ChevronDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PIPELINE_STAGES,
|
||||
STAGE_BADGE,
|
||||
STAGE_DOT,
|
||||
STAGE_LABELS,
|
||||
safeStage,
|
||||
type PipelineStage,
|
||||
} from '@/components/clients/pipeline-constants';
|
||||
|
||||
interface InlineStagePickerProps {
|
||||
interestId: string;
|
||||
currentStage: string;
|
||||
/** Whether to render the chevron after the stage label. Default true. */
|
||||
showChevron?: boolean;
|
||||
/** Stop the parent's click propagation when used inside a clickable card. */
|
||||
stopPropagation?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker
|
||||
* for inline editing — user clicks the chip, picks a new stage from the
|
||||
* popover (with optional reason), commits in one click. The popover stays
|
||||
* compact: a small reason field above the stage list, and clicking any stage
|
||||
* fires the mutation immediately.
|
||||
*/
|
||||
export function InlineStagePicker({
|
||||
interestId,
|
||||
currentStage,
|
||||
showChevron = true,
|
||||
stopPropagation = false,
|
||||
className,
|
||||
}: InlineStagePickerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [pendingStage, setPendingStage] = useState<string | null>(null);
|
||||
|
||||
const stage = safeStage(currentStage);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: PipelineStage) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: next, reason: reason.trim() || undefined },
|
||||
}),
|
||||
onSuccess: (_data, next) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setOpen(false);
|
||||
setReason('');
|
||||
setPendingStage(null);
|
||||
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
setPendingStage(null);
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to change stage');
|
||||
},
|
||||
});
|
||||
|
||||
function pick(next: PipelineStage) {
|
||||
if (next === stage) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingStage(next);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!mutation.isPending) setOpen(o);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-medium',
|
||||
'transition-colors hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
STAGE_BADGE[stage],
|
||||
className,
|
||||
)}
|
||||
aria-label={`Pipeline stage: ${STAGE_LABELS[stage]}. Click to change.`}
|
||||
>
|
||||
<span>{STAGE_LABELS[stage]}</span>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : showChevron ? (
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Anchor, Archive, Compass, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { Anchor, Archive, Compass, MessageSquare, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from '@/components/shared/list-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import type { InterestRow } from './interest-columns';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -48,9 +50,15 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||
const tags = interest.tags ?? [];
|
||||
const notesCount = interest.notesCount ?? 0;
|
||||
const urgencyBadges = computeUrgencyBadges(interest);
|
||||
|
||||
const clientName = interest.clientName ?? 'Unknown client';
|
||||
const berthLabel = interest.berthMooringNumber;
|
||||
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||
const lastActivity = lastIso
|
||||
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
@@ -86,11 +94,22 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar initials={deriveInitials(clientName)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title row: name + spacer for the absolutely-positioned actions menu */}
|
||||
{/* Title row: name + comment-icon when notes exist + spacer for actions */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{clientName}
|
||||
</h3>
|
||||
{notesCount > 0 ? (
|
||||
<span
|
||||
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
className="inline-flex shrink-0 items-center text-muted-foreground"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
@@ -135,6 +154,23 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{urgencyBadges.length > 0 ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{urgencyBadges.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium',
|
||||
b.className,
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
@@ -147,6 +183,12 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastActivity ? (
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||
Last activity {lastActivity}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive, MessageSquare } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||
|
||||
export interface InterestRow {
|
||||
id: string;
|
||||
@@ -27,6 +28,15 @@ export interface InterestRow {
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
/** Surfaced by listInterests for the row-level sales-triage signals
|
||||
* (last-activity relative time, comment-icon, urgency badges). */
|
||||
updatedAt?: string;
|
||||
dateLastContact?: string | null;
|
||||
dateEoiSent?: string | null;
|
||||
dateDepositReceived?: string | null;
|
||||
eoiStatus?: string | null;
|
||||
outcome?: string | null;
|
||||
notesCount?: number;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
@@ -59,15 +69,29 @@ export function getInterestColumns({
|
||||
id: 'clientName',
|
||||
accessorKey: 'clientName',
|
||||
header: 'Client',
|
||||
cell: ({ row }) => (
|
||||
cell: ({ row }) => {
|
||||
const notesCount = row.original.notesCount ?? 0;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
className="truncate font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.clientName ?? '—'}
|
||||
</Link>
|
||||
),
|
||||
{notesCount > 0 ? (
|
||||
<span
|
||||
title={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
aria-label={`${notesCount} note${notesCount === 1 ? '' : 's'}`}
|
||||
className="inline-flex items-center text-muted-foreground"
|
||||
>
|
||||
<MessageSquare className="size-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'berthMooringNumber',
|
||||
@@ -92,14 +116,31 @@ export function getInterestColumns({
|
||||
id: 'pipelineStage',
|
||||
accessorKey: 'pipelineStage',
|
||||
header: 'Stage',
|
||||
cell: ({ getValue }) => {
|
||||
const stage = getValue() as string;
|
||||
cell: ({ row }) => {
|
||||
const stage = row.original.pipelineStage;
|
||||
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
|
||||
>
|
||||
{stageLabel(stage)}
|
||||
</span>
|
||||
{badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.id}
|
||||
title={b.detail}
|
||||
aria-label={b.detail}
|
||||
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium ${b.className}`}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -153,14 +194,24 @@ export function getInterestColumns({
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
||||
// Sales-triage default: prefer the explicit dateLastContact, fall back
|
||||
// to updatedAt. Sortable on dateLastContact server-side; the column
|
||||
// header label ("Last activity") makes the fallback semantics clear.
|
||||
id: 'dateLastContact',
|
||||
accessorKey: 'dateLastContact',
|
||||
header: 'Last activity',
|
||||
cell: ({ row }) => {
|
||||
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
|
||||
if (!lastIso) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
const d = new Date(lastIso);
|
||||
return (
|
||||
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||
</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react';
|
||||
import {
|
||||
Pencil,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Trophy,
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
@@ -24,6 +36,20 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||
};
|
||||
|
||||
// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still
|
||||
// renders as a closed-state badge instead of falling back to the open-state
|
||||
// stage picker. Lost-* gets a rose tint; everything else gets neutral slate.
|
||||
function resolveOutcomeBadge(outcome: string | null | undefined) {
|
||||
if (!outcome) return null;
|
||||
const known = OUTCOME_BADGE[outcome];
|
||||
if (known) return known;
|
||||
const isLoss = outcome.startsWith('lost');
|
||||
return {
|
||||
label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||
className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700',
|
||||
};
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
@@ -36,6 +62,16 @@ interface InterestDetailHeaderProps {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Primary contact channels resolved from the linked client. The header
|
||||
* uses these to render Email / Call / WhatsApp buttons so the rep
|
||||
* doesn't have to navigate to the client page just to reach out. */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientPrimaryPhone?: string | null;
|
||||
clientPrimaryPhoneE164?: string | null;
|
||||
/** Pending/snoozed reminders attached to this interest. Drives the
|
||||
* alarm-bell badge on the header — surfaces follow-ups so the rep
|
||||
* doesn't have to remember to check /reminders. */
|
||||
activeReminderCount?: number;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
@@ -47,10 +83,20 @@ interface InterestDetailHeaderProps {
|
||||
archivedAt: string | null;
|
||||
outcome?: string | null;
|
||||
outcomeReason?: string | null;
|
||||
dateLastContact?: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function formatLastContactAge(iso: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
if (days <= 0) return 'today';
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 30) return `${days}d ago`;
|
||||
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
||||
return `${Math.floor(days / 365)}y ago`;
|
||||
}
|
||||
|
||||
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -58,9 +104,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null;
|
||||
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||
const isClosed = !!interest.outcome;
|
||||
|
||||
// Contact deep-links — resolved from the linked client's primary channels.
|
||||
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
|
||||
// stripping non-digits from the display value when the canonical form is
|
||||
// missing.
|
||||
const whatsappNumber = interest.clientPrimaryPhoneE164
|
||||
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
|
||||
: interest.clientPrimaryPhone
|
||||
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
|
||||
: null;
|
||||
|
||||
const reopenMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
|
||||
@@ -114,6 +170,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
node: <span className="capitalize">{interest.source}</span>,
|
||||
});
|
||||
}
|
||||
if (interest.dateLastContact) {
|
||||
meta.push({
|
||||
key: 'last',
|
||||
node: (
|
||||
<span className="text-foreground/70">
|
||||
Last contact {formatLastContactAge(interest.dateLastContact)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -156,6 +222,17 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
/>
|
||||
</PermissionGate>
|
||||
)}
|
||||
{(interest.activeReminderCount ?? 0) > 0 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
title={`${interest.activeReminderCount} pending reminder${
|
||||
interest.activeReminderCount === 1 ? '' : 's'
|
||||
}`}
|
||||
>
|
||||
<AlarmClock className="size-3" />
|
||||
{interest.activeReminderCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{meta.length > 0 ? (
|
||||
@@ -180,25 +257,85 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact deep-links — let the rep email / call / WhatsApp the
|
||||
client without leaving the interest workspace. Resolved from
|
||||
the linked client's primary contact channels (server-side
|
||||
fetch in getInterestById). */}
|
||||
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{interest.clientPrimaryEmail ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`mailto:${interest.clientPrimaryEmail}`}
|
||||
aria-label={`Email ${interest.clientPrimaryEmail}`}
|
||||
>
|
||||
<Mail />
|
||||
Email
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{interest.clientPrimaryPhone ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`tel:${interest.clientPrimaryPhone}`}
|
||||
aria-label={`Call ${interest.clientPrimaryPhone}`}
|
||||
>
|
||||
<Phone />
|
||||
Call
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{whatsappNumber ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
||||
>
|
||||
<a
|
||||
href={`https://wa.me/${whatsappNumber}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Top-right icon-only actions — no stacking, no labels eating room. */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{/* Top-right actions. Won/Lost are sales-critical and read as text
|
||||
buttons on desktop; Edit/Archive stay icon-only. On mobile,
|
||||
Won/Lost shrink to icon buttons to keep the cluster from
|
||||
wrapping. */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<PermissionGate resource="interests" action="change_stage">
|
||||
{isClosed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reopenMutation.mutate()}
|
||||
disabled={reopenMutation.isPending}
|
||||
aria-label="Reopen interest"
|
||||
title="Reopen interest"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-foreground/5 hover:text-foreground',
|
||||
'disabled:opacity-50',
|
||||
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
|
||||
'hover:bg-foreground/5 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
<RefreshCcw className="size-3.5" />
|
||||
Reopen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -206,25 +343,27 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('won')}
|
||||
aria-label="Mark as won"
|
||||
title="Mark as won"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-emerald-50 hover:text-emerald-700',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'border border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
'hover:bg-emerald-100',
|
||||
)}
|
||||
>
|
||||
<Trophy className="size-4" />
|
||||
<Trophy className="size-3.5" />
|
||||
<span className="hidden sm:inline">Mark won</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOutcomeDialog('lost')}
|
||||
aria-label="Close as lost"
|
||||
title="Close as lost"
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
||||
'hover:bg-rose-50 hover:text-rose-700',
|
||||
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'border border-rose-200 text-rose-700',
|
||||
'hover:bg-rose-50',
|
||||
)}
|
||||
>
|
||||
<XCircle className="size-4" />
|
||||
<XCircle className="size-3.5" />
|
||||
<span className="hidden sm:inline">Close as lost</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
@@ -14,6 +16,29 @@ interface InterestData {
|
||||
portId: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
/** Linked client's primary email (display value). Powers the header
|
||||
* "Email" button and the EOI prereq checklist. */
|
||||
clientPrimaryEmail: string | null;
|
||||
/** Linked client's primary phone (display value). Powers the header
|
||||
* "Call" button. */
|
||||
clientPrimaryPhone: string | null;
|
||||
/** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used
|
||||
* by wa.me to assemble the WhatsApp deep-link. */
|
||||
clientPrimaryPhoneE164: string | null;
|
||||
/** True when the linked client has any primary address row. Used by
|
||||
* the EOI prereq checklist on the Documents tab. */
|
||||
clientHasAddress: boolean;
|
||||
/** Surfaced for the bell badge on the detail header (pending/snoozed
|
||||
* reminders linked to this interest). */
|
||||
activeReminderCount?: number;
|
||||
/** Surfaced for the most-recent-note teaser on the Overview tab. */
|
||||
notesCount?: number;
|
||||
recentNote?: {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
@@ -37,6 +62,8 @@ interface InterestData {
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
outcome?: string | null;
|
||||
outcomeReason?: string | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
@@ -52,9 +79,7 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
const { data, isLoading } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
|
||||
(r) => r.data,
|
||||
),
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
@@ -65,17 +90,18 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
||||
'interest:berthUnlinked': [['interests', interestId]],
|
||||
});
|
||||
|
||||
const tabs = data
|
||||
? getInterestTabs({ interestId, currentUserId, interest: data })
|
||||
: [];
|
||||
const { setChrome } = useMobileChrome();
|
||||
const titleForChrome: string | null = data?.clientName ?? null;
|
||||
useEffect(() => {
|
||||
setChrome({ title: titleForChrome, showBackButton: true });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
}, [titleForChrome, setChrome]);
|
||||
|
||||
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
header={
|
||||
data ? (
|
||||
<InterestDetailHeader portSlug={portSlug} interest={data} />
|
||||
) : null
|
||||
}
|
||||
header={data ? <InterestDetailHeader portSlug={portSlug} interest={data} /> : null}
|
||||
tabs={tabs}
|
||||
defaultTab="overview"
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileSignature } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentList } from '@/components/documents/document-list';
|
||||
@@ -17,20 +18,29 @@ interface InterestData {
|
||||
yachtId?: string | null;
|
||||
berthId?: string | null;
|
||||
clientName?: string | null;
|
||||
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
|
||||
clientPrimaryEmail?: string | null;
|
||||
clientHasAddress?: boolean;
|
||||
}
|
||||
|
||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||
|
||||
const { data: interestRes } = useQuery({
|
||||
// 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}`),
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
const interest = interestRes?.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),
|
||||
};
|
||||
@@ -39,12 +49,30 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
|
||||
<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" onClick={() => setEoiDialogOpen(true)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentList interestId={interestId} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Plus, LayoutList, Kanban } from 'lucide-react';
|
||||
import { Plus, LayoutList, Kanban, Archive } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -69,6 +69,18 @@ export function InterestList() {
|
||||
},
|
||||
});
|
||||
|
||||
const bulkArchiveMutation = useMutation({
|
||||
mutationFn: async (ids: string[]) => {
|
||||
// Concurrent fan-out — small batches in practice (page size cap = 100).
|
||||
// If a single delete fails the others still run; the rejected one
|
||||
// surfaces a toast via the standard apiFetch error path.
|
||||
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
},
|
||||
});
|
||||
|
||||
const columns = getInterestColumns({
|
||||
portSlug,
|
||||
onEdit: (interest) => setEditInterest(interest),
|
||||
@@ -146,6 +158,24 @@ export function InterestList() {
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
bulkActions={[
|
||||
{
|
||||
label: 'Archive',
|
||||
icon: Archive,
|
||||
variant: 'destructive',
|
||||
onClick: (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}? This can be undone from the archived list.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
bulkArchiveMutation.mutate(ids);
|
||||
},
|
||||
},
|
||||
]}
|
||||
cardRender={(row) => (
|
||||
<InterestCard
|
||||
interest={row.original}
|
||||
@@ -164,6 +194,20 @@ export function InterestList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB — primary "New interest" affordance for the bottom-tab UX.
|
||||
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
|
||||
gap). Hidden on lg+ where the header button already does the job. */}
|
||||
<PermissionGate resource="interests" action="create">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="New interest"
|
||||
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
|
||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
{editInterest && (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { RecommendationList } from '@/components/interests/recommendation-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 } from '@/lib/constants';
|
||||
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -26,10 +26,17 @@ const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||
label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()),
|
||||
}));
|
||||
|
||||
// Convert raw enum values like `waiting_for_signatures` → `Waiting For Signatures`.
|
||||
function humanizeStatus(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
}
|
||||
|
||||
interface InterestTabsOptions {
|
||||
interestId: string;
|
||||
currentUserId?: string;
|
||||
interest: {
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
eoiStatus: string | null;
|
||||
@@ -47,6 +54,15 @@ interface InterestTabsOptions {
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
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. */
|
||||
notesCount?: number;
|
||||
recentNote?: {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
@@ -120,10 +136,23 @@ interface MilestoneSectionProps {
|
||||
advanceStage?: string;
|
||||
/** Optional override for the action label. */
|
||||
actionLabel?: string;
|
||||
/** Suppress the inline "Mark as…" button for this step. Use when the
|
||||
* parent supplies a richer CTA via `footer` (e.g. Deposit, where we
|
||||
* want the invoice flow to be the primary path). */
|
||||
hideAutoButton?: boolean;
|
||||
}>;
|
||||
status: string | null;
|
||||
onAdvance: (stage: string) => void;
|
||||
isPending: boolean;
|
||||
/** Current pipelineStage. Used to mark steps as done when the pipeline has
|
||||
* moved past their advanceStage even if the date stamp is missing — e.g.
|
||||
* a seed-data interest that started already at eoi_signed will show both
|
||||
* EOI sub-steps as done. Stage truth > date truth. */
|
||||
currentStage: string;
|
||||
/** When true, this milestone is the next one the user should act on:
|
||||
* card gets a brand-accent ring and the next-step CTA becomes a primary
|
||||
* button. Computed by the parent based on currentStage. */
|
||||
isActive?: boolean;
|
||||
/** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
@@ -143,27 +172,51 @@ function MilestoneSection({
|
||||
status,
|
||||
onAdvance,
|
||||
isPending,
|
||||
currentStage,
|
||||
isActive,
|
||||
footer,
|
||||
}: MilestoneSectionProps) {
|
||||
const firstUnsetIdx = steps.findIndex((s) => !s.date);
|
||||
const currentStageIdx = PIPELINE_STAGES.indexOf(currentStage as PipelineStage);
|
||||
// A step counts as done if either:
|
||||
// (a) its `advanceStage` is at or behind the current pipeline stage, OR
|
||||
// (b) it has an explicit date stamp (from a manual mark or webhook).
|
||||
// (a) handles seeded/imported interests that arrived at a later stage
|
||||
// without per-step dates.
|
||||
const doneFlags = steps.map((step) => {
|
||||
if (step.date) return true;
|
||||
if (!step.advanceStage) return false;
|
||||
const stepIdx = PIPELINE_STAGES.indexOf(step.advanceStage as PipelineStage);
|
||||
return stepIdx !== -1 && currentStageIdx !== -1 && currentStageIdx >= stepIdx;
|
||||
});
|
||||
const firstUnsetIdx = doneFlags.findIndex((d) => !d);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-xl border bg-card p-4 shadow-sm transition-colors',
|
||||
isActive ? 'border-brand-300 bg-brand-50/40 ring-1 ring-brand-200' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<header className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
{isActive ? (
|
||||
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
|
||||
Next
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{status ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{status.replace(/_/g, ' ')}
|
||||
{humanizeStatus(status)}
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => {
|
||||
const done = !!step.date;
|
||||
const done = doneFlags[i] ?? false;
|
||||
const isNext = !done && i === firstUnsetIdx;
|
||||
return (
|
||||
<li key={step.label} className="flex items-start gap-2 text-sm">
|
||||
@@ -197,10 +250,10 @@ function MilestoneSection({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isNext && step.advanceStage ? (
|
||||
{isNext && step.advanceStage && !step.hideAutoButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => onAdvance(step.advanceStage!)}
|
||||
@@ -236,6 +289,23 @@ function OverviewTab({
|
||||
const advance = (stage: string) =>
|
||||
stageMutation.mutate({ stage, reason: 'Marked from overview' });
|
||||
|
||||
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
|
||||
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
|
||||
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';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sales-process milestones — the heart of the system. Each section is a
|
||||
@@ -250,6 +320,8 @@ function OverviewTab({
|
||||
status={interest.eoiStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'eoi'}
|
||||
steps={[
|
||||
{
|
||||
label: 'EOI sent',
|
||||
@@ -271,23 +343,36 @@ function OverviewTab({
|
||||
status={interest.depositStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'deposit'}
|
||||
steps={[
|
||||
{
|
||||
label: 'Deposit received',
|
||||
date: interest.dateDepositReceived,
|
||||
advanceStage: 'deposit_10pct',
|
||||
actionLabel: 'Mark deposit received',
|
||||
// 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 ? (
|
||||
<Link
|
||||
href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}
|
||||
className="inline-flex items-center gap-1.5 text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
@@ -297,6 +382,8 @@ function OverviewTab({
|
||||
status={interest.contractStatus}
|
||||
isPending={stageMutation.isPending}
|
||||
onAdvance={advance}
|
||||
currentStage={interest.pipelineStage}
|
||||
isActive={activeMilestone === 'contract'}
|
||||
steps={[
|
||||
{
|
||||
label: 'Contract sent',
|
||||
@@ -359,6 +446,37 @@ function OverviewTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<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}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
{interest.recentNote.authorId
|
||||
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
|
||||
: ''}
|
||||
</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>
|
||||
|
||||
@@ -23,6 +23,8 @@ interface TimelineEvent {
|
||||
action: string;
|
||||
description: string;
|
||||
userId: string | null;
|
||||
/** Resolved display name (server-side join). Falls back to userId when null. */
|
||||
userName?: string | null;
|
||||
createdAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
@@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) {
|
||||
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function actorLabel(userId: string | null): string | null {
|
||||
if (!userId) return null;
|
||||
if (userId === 'system') return 'system';
|
||||
return userId;
|
||||
function actorLabel(event: TimelineEvent): string | null {
|
||||
if (event.userName) return event.userName;
|
||||
if (!event.userId) return null;
|
||||
if (event.userId === 'system') return 'system';
|
||||
// Last-resort fallback when the user row was deleted: show a short token
|
||||
// instead of a 36-char UUID. The server-side join is authoritative; this
|
||||
// path should be rare in practice.
|
||||
return 'a teammate';
|
||||
}
|
||||
|
||||
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
@@ -100,7 +106,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||
|
||||
{events.map((event) => {
|
||||
const actor = actorLabel(event.userId);
|
||||
const actor = actorLabel(event);
|
||||
const isAuto = event.userId === 'system';
|
||||
return (
|
||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||
|
||||
91
src/components/interests/urgency.ts
Normal file
91
src/components/interests/urgency.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Sales-triage urgency badges for interest list rows + cards.
|
||||
*
|
||||
* Derived purely from the dates we already return on the row, so this is a
|
||||
* pure function — no DB hits, no extra fetch. Mirrors the logic the
|
||||
* server-side alert-rules engine uses, but for at-a-glance rendering on
|
||||
* the list itself.
|
||||
*/
|
||||
|
||||
const SILENT_DAYS_THRESHOLD = 7;
|
||||
const EOI_AWAITING_DAYS_THRESHOLD = 14;
|
||||
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
|
||||
|
||||
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
|
||||
|
||||
export interface InterestUrgencyInput {
|
||||
pipelineStage: string;
|
||||
outcome?: string | null;
|
||||
archivedAt?: string | null;
|
||||
dateLastContact?: string | null;
|
||||
updatedAt?: string;
|
||||
dateEoiSent?: string | null;
|
||||
eoiStatus?: string | null;
|
||||
dateDepositReceived?: string | null;
|
||||
}
|
||||
|
||||
export interface UrgencyBadge {
|
||||
/** Stable id for keying. */
|
||||
id: 'silent' | 'eoi_awaiting' | 'deposit_pending';
|
||||
label: string;
|
||||
/** Long form for tooltip / aria-label. */
|
||||
detail: string;
|
||||
/** Tailwind classes for the pill. */
|
||||
className: string;
|
||||
}
|
||||
|
||||
function daysSince(iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor((Date.now() - t) / 86_400_000);
|
||||
}
|
||||
|
||||
export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[] {
|
||||
// Closed / archived interests don't need triage signals.
|
||||
if (row.archivedAt || row.outcome) return [];
|
||||
|
||||
const badges: UrgencyBadge[] = [];
|
||||
|
||||
// Silent in mid-funnel stages — most actionable.
|
||||
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
|
||||
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
|
||||
const days = daysSince(lastTouchIso);
|
||||
if (days !== null && days >= SILENT_DAYS_THRESHOLD) {
|
||||
badges.push({
|
||||
id: 'silent',
|
||||
label: `Silent ${days}d`,
|
||||
detail: `No contact in ${days} days`,
|
||||
className: 'bg-amber-100 text-amber-800 border border-amber-200',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EOI sent but not signed for too long.
|
||||
if (row.eoiStatus === 'waiting_for_signatures') {
|
||||
const days = daysSince(row.dateEoiSent);
|
||||
if (days !== null && days >= EOI_AWAITING_DAYS_THRESHOLD) {
|
||||
badges.push({
|
||||
id: 'eoi_awaiting',
|
||||
label: `EOI ${days}d`,
|
||||
detail: `EOI awaiting signature for ${days} days`,
|
||||
className: 'bg-orange-100 text-orange-800 border border-orange-200',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// EOI signed but deposit not received.
|
||||
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
|
||||
const days = daysSince(row.dateEoiSent);
|
||||
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
|
||||
badges.push({
|
||||
id: 'deposit_pending',
|
||||
label: `Deposit ${days}d`,
|
||||
detail: `Awaiting deposit for ${days} days since EOI sent`,
|
||||
className: 'bg-rose-100 text-rose-800 border border-rose-200',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
@@ -6,12 +6,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Send, CreditCard } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { InvoicePdfPreview } from './invoice-pdf-preview';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -26,6 +34,40 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
};
|
||||
|
||||
// Display labels for snake_case enum values stored in the DB.
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = {
|
||||
bank_transfer: 'Bank transfer',
|
||||
credit_card: 'Credit card',
|
||||
cash: 'Cash',
|
||||
cheque: 'Cheque',
|
||||
check: 'Check',
|
||||
wire: 'Wire',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'bank_transfer', label: 'Bank transfer' },
|
||||
{ value: 'credit_card', label: 'Credit card' },
|
||||
{ value: 'cash', label: 'Cash' },
|
||||
{ value: 'cheque', label: 'Cheque' },
|
||||
{ value: 'wire', label: 'Wire' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
function formatPaymentMethod(method: string | null | undefined): string {
|
||||
if (!method) return '—';
|
||||
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatDateOnly(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift.
|
||||
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
|
||||
const d = new Date(isoDate);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return format(d, 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
interface InvoiceDetailProps {
|
||||
invoiceId: string;
|
||||
}
|
||||
@@ -155,7 +197,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">{invoice.dueDate}</p>
|
||||
<p className="text-sm">{formatDateOnly(invoice.dueDate)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Payment Date</span>
|
||||
<p className="mt-0.5">{invoice.paymentDate ?? '—'}</p>
|
||||
<p className="mt-0.5">{formatDateOnly(invoice.paymentDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Method</span>
|
||||
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
|
||||
<p className="mt-0.5">{formatPaymentMethod(invoice.paymentMethod)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Reference</span>
|
||||
@@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||
<Input
|
||||
id="paymentMethod"
|
||||
placeholder="e.g. bank_transfer, credit_card"
|
||||
{...paymentForm.register('paymentMethod')}
|
||||
/>
|
||||
<Select
|
||||
value={paymentForm.watch('paymentMethod') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
paymentForm.setValue('paymentMethod', v, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="paymentMethod">
|
||||
<SelectValue placeholder="Select a method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react';
|
||||
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,11 +12,27 @@ type TabSpec = {
|
||||
segment: string; // route segment after /[portSlug]/
|
||||
};
|
||||
|
||||
// Bottom nav ordering, left → right:
|
||||
// Dashboard — daily overview
|
||||
// Berths — marina inventory grid (touches sales + ops both)
|
||||
// Clients — the address book / dedup surface (centered: it's the
|
||||
// primary mental anchor for "find this person", with
|
||||
// interests living as a tab on the client detail rather
|
||||
// than a peer in the bottom nav)
|
||||
// Documents — signature tracking (chase signers, EOI queue)
|
||||
// More — overflow drawer (Interests, Yachts, Companies, …)
|
||||
//
|
||||
// Interests is intentionally NOT in the bottom row — having both Clients
|
||||
// and Interests as peer tabs created a Clients-vs-Interests confusion
|
||||
// for sales reps, and the per-client interests tab + the new bottom-sheet
|
||||
// drawer cover the day-to-day deal review without needing a dedicated tab.
|
||||
// Yachts stays out for the same reason as before: it's an asset record
|
||||
// most often reached from inside an interest or client, not browsed.
|
||||
const TABS: TabSpec[] = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
];
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Building2,
|
||||
Bookmark,
|
||||
Receipt,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Mail,
|
||||
Bell,
|
||||
ShieldAlert,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileText,
|
||||
Mail,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -30,12 +30,17 @@ type MoreItem = {
|
||||
segment: string;
|
||||
};
|
||||
|
||||
// Order: most-likely overflow targets first. Interests is here (rather
|
||||
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
|
||||
// reps reach the active deals via the Interests tab on a client detail
|
||||
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
|
||||
// best reached contextually from inside an interest or client.
|
||||
const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ 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: 'Documents', icon: FolderOpen, segment: 'documents' },
|
||||
{ label: 'Email', icon: Mail, segment: 'email' },
|
||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
|
||||
54
src/components/reservations/berth-reservations-list.tsx
Normal file
54
src/components/reservations/berth-reservations-list.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ReservationsApiResponse {
|
||||
data: ReservationRow[];
|
||||
pagination: { total: number; page: number; pageSize: number };
|
||||
}
|
||||
|
||||
export function BerthReservationsList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<ReservationsApiResponse>({
|
||||
queryKey: ['berth-reservations', 'list'],
|
||||
queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
eyebrow="Marina"
|
||||
title="Berth Reservations"
|
||||
description="All reservations across all berths"
|
||||
actions={
|
||||
<Link
|
||||
href={`/${portSlug}/berths`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View berths
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<ReservationList
|
||||
reservations={data?.data ?? []}
|
||||
showBerth
|
||||
portSlug={portSlug}
|
||||
emptyMessage="No reservations found."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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 { PageHeader } from '@/components/shared/page-header';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
|
||||
|
||||
interface ReservationDoc {
|
||||
id: string;
|
||||
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
|
||||
cancelled: 'cancelled',
|
||||
};
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
interface EndReservationDialogProps {
|
||||
reservationId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
|
||||
const qc = useQueryClient();
|
||||
const [endDate, setEndDate] = useState(todayIso);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/berth-reservations/${reservationId}`, {
|
||||
method: 'PATCH',
|
||||
body: { action: 'end', endDate },
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
|
||||
toast.success('Reservation ended');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>End reservation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="end-date">End date</Label>
|
||||
<Input
|
||||
id="end-date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={submitting}>
|
||||
{submitting ? 'Ending…' : 'End reservation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReservationDetailProps {
|
||||
reservationId: string;
|
||||
portSlug: string;
|
||||
}
|
||||
|
||||
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
|
||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||
const reservation = useQuery<{ data: ReservationData }>({
|
||||
queryKey: ['reservation', reservationId],
|
||||
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
|
||||
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{res.status === 'active' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
||||
<StopCircle className="mr-1.5 h-4 w-4" />
|
||||
End reservation
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/berths`}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Berth</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${res.berthId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.berthId.slice(0, 8)}…
|
||||
</Link>
|
||||
<dd className="font-medium">
|
||||
<BerthLink berthId={res.berthId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Yacht</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.yachtId.slice(0, 8)}…
|
||||
</Link>
|
||||
<dd className="font-medium">
|
||||
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Client</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${res.clientId}` as Route}
|
||||
className="font-medium text-brand hover:underline"
|
||||
>
|
||||
{res.clientId.slice(0, 8)}…
|
||||
</Link>
|
||||
<dd className="font-medium">
|
||||
<ClientLink clientId={res.clientId} portSlug={portSlug} />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndReservationDialog
|
||||
reservationId={reservationId}
|
||||
open={endDialogOpen}
|
||||
onOpenChange={setEndDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ReservationListProps {
|
||||
* Renders a client's name as a link by fetching the client record.
|
||||
* Uses TanStack Query cache for memoization of repeated clientId queries.
|
||||
*/
|
||||
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||
export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ fullName: string }>({
|
||||
queryKey: ['clients', clientId, 'name-only'],
|
||||
queryFn: () =>
|
||||
@@ -62,7 +62,7 @@ function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string
|
||||
/**
|
||||
* Renders a yacht's name as a link by fetching the yacht record.
|
||||
*/
|
||||
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||
export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ name: string }>({
|
||||
queryKey: ['yachts', yachtId, 'name-only'],
|
||||
queryFn: () =>
|
||||
@@ -83,7 +83,7 @@ function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string })
|
||||
/**
|
||||
* Renders a berth's mooring number as a link by fetching the berth record.
|
||||
*/
|
||||
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||
export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
|
||||
const { data } = useQuery<{ mooringNumber: string }>({
|
||||
queryKey: ['berths', berthId, 'name-only'],
|
||||
queryFn: () =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -225,6 +225,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
|
||||
function flagEmoji(code: string | null | undefined): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const A = 0x1f1e6;
|
||||
const a = 'A'.charCodeAt(0);
|
||||
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
|
||||
}
|
||||
|
||||
function CountryFieldInline({
|
||||
value,
|
||||
onSave,
|
||||
@@ -233,20 +241,34 @@ function CountryFieldInline({
|
||||
onSave: (iso: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Tracks whether a value was picked this edit cycle so the open-change
|
||||
// handler doesn't double-exit while commit is still in flight.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<CountryCombobox
|
||||
value={value ?? null}
|
||||
onChange={async (iso) => {
|
||||
pickedRef.current = true;
|
||||
setEditing(false);
|
||||
await onSave(iso ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
// Drop the user straight into the picker — no extra click on the
|
||||
// trigger required.
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// Auto-exit edit mode when the popover closes without a pick so
|
||||
// the user isn't stuck staring at a "Select country…" trigger.
|
||||
if (!open && !pickedRef.current) setEditing(false);
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const display = value ? getCountryName(value, 'en') : null;
|
||||
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -268,17 +290,25 @@ function SubdivisionFieldInline({
|
||||
onSave: (code: string | null) => Promise<void>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<SubdivisionCombobox
|
||||
value={value ?? null}
|
||||
country={country}
|
||||
onChange={async (code) => {
|
||||
pickedRef.current = true;
|
||||
setEditing(false);
|
||||
await onSave(code ?? null);
|
||||
}}
|
||||
clearable
|
||||
className="w-full"
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pickedRef.current) setEditing(false);
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ interface CountryComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers so the
|
||||
* user lands directly in the picker after clicking the edit affordance. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,8 +64,14 @@ export function CountryCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: CountryComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
|
||||
// Pre-build the options list once per locale change so the cmdk filter
|
||||
@@ -75,7 +87,7 @@ export function CountryCombobox({
|
||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function DetailHeaderStrip({ children, className }: DetailHeaderStripProp
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-gradient-brand-soft px-5 py-4 shadow-xs',
|
||||
'rounded-xl border border-border bg-gradient-brand-soft px-5 py-3 shadow-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -31,8 +31,12 @@ export function InlineCountryField({
|
||||
}: InlineCountryFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Set true when the user picks a value from the dropdown, so the
|
||||
// popover-close handler knows commit() will exit edit mode itself.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
async function commit(next: CountryCode | null) {
|
||||
pickedRef.current = true;
|
||||
if (next === (value ?? null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
@@ -51,7 +55,23 @@ export function InlineCountryField({
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} />
|
||||
<CountryCombobox
|
||||
value={value}
|
||||
onChange={(iso) => void commit(iso)}
|
||||
data-testid={testId}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// When the dropdown closes without a selection, leave edit mode
|
||||
// so the user isn't stuck staring at the trigger button. If a
|
||||
// pick happened, commit() handles the exit (and may need to keep
|
||||
// edit mode briefly to show the saving spinner).
|
||||
if (!open && !pickedRef.current) {
|
||||
setEditing(false);
|
||||
}
|
||||
// Reset for the next open cycle.
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -262,7 +262,9 @@ function ReadButton({
|
||||
{!disabled && (
|
||||
<Pencil
|
||||
className={cn(
|
||||
'h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50',
|
||||
// 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',
|
||||
multiline && 'mt-1 shrink-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,12 @@ interface InlinePhoneFieldProps {
|
||||
/** Falls back to this country if `country` isn't set. */
|
||||
defaultCountry?: CountryCode;
|
||||
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
|
||||
/**
|
||||
* Notifies the parent when the field enters/exits edit mode. Lets the row
|
||||
* dim or hide noise (tag chips, action buttons) while the user is focused
|
||||
* on the editor.
|
||||
*/
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -28,12 +34,13 @@ export function InlinePhoneField({
|
||||
country,
|
||||
defaultCountry,
|
||||
onSave,
|
||||
onEditingChange,
|
||||
emptyText = '—',
|
||||
disabled,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
}: InlinePhoneFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editing, setEditingRaw] = useState(false);
|
||||
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
|
||||
if (!e164 && !country) return null;
|
||||
return {
|
||||
@@ -43,6 +50,11 @@ export function InlinePhoneField({
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function setEditing(next: boolean) {
|
||||
setEditingRaw(next);
|
||||
onEditingChange?.(next);
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
|
||||
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
|
||||
@@ -62,21 +74,15 @@ export function InlinePhoneField({
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
// Two clean lines: country picker + number on top, action pair below.
|
||||
<div className={cn('flex w-full flex-col gap-2.5', className)}>
|
||||
<PhoneInput
|
||||
value={draft}
|
||||
onChange={(v) => setDraft(v)}
|
||||
defaultCountry={defaultCountry}
|
||||
data-testid={testId}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving}
|
||||
className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -91,10 +97,27 @@ export function InlinePhoneField({
|
||||
setEditing(false);
|
||||
}}
|
||||
disabled={saving}
|
||||
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
|
||||
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
||||
'disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commit()}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
|
||||
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
|
||||
'transition-colors hover:bg-primary/90 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Loader2, Pencil } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -31,8 +31,12 @@ export function InlineTimezoneField({
|
||||
}: InlineTimezoneFieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Set true when the user picks a value from the dropdown, so the
|
||||
// popover-close handler knows commit() will exit edit mode itself.
|
||||
const pickedRef = useRef(false);
|
||||
|
||||
async function commit(next: string | null) {
|
||||
pickedRef.current = true;
|
||||
if (next === (value ?? null)) {
|
||||
setEditing(false);
|
||||
return;
|
||||
@@ -56,6 +60,16 @@ export function InlineTimezoneField({
|
||||
onChange={(tz) => void commit(tz)}
|
||||
countryHint={countryHint ?? undefined}
|
||||
data-testid={testId}
|
||||
defaultOpen
|
||||
onOpenChange={(open) => {
|
||||
// Auto-exit edit mode when the dropdown closes without a pick,
|
||||
// so the user isn't stuck looking at the trigger. commit() owns
|
||||
// the exit when a value was selected.
|
||||
if (!open && !pickedRef.current) {
|
||||
setEditing(false);
|
||||
}
|
||||
if (open) pickedRef.current = false;
|
||||
}}
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
84
src/components/shared/realtime-toasts.tsx
Normal file
84
src/components/shared/realtime-toasts.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useSocket } from '@/providers/socket-provider';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
|
||||
/**
|
||||
* App-wide subscriber that surfaces high-signal sales events as sonner
|
||||
* toasts. Mounted once inside SocketProvider so reps see "EOI signed",
|
||||
* "Deposit recorded", "Stage advanced" without having to refresh.
|
||||
*
|
||||
* Render-only — no children. Intentionally narrow in scope: only toast on
|
||||
* events that are noteworthy *to a user staring at any page*. Per-page
|
||||
* cache invalidations stay in `useRealtimeInvalidation`.
|
||||
*/
|
||||
export function RealtimeToasts() {
|
||||
const socket = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
function onStageChanged(payload: {
|
||||
newStage?: string;
|
||||
oldStage?: string | null;
|
||||
clientName?: string;
|
||||
}) {
|
||||
if (!payload?.newStage) return;
|
||||
const who = payload.clientName?.trim() || 'an interest';
|
||||
toast.success(`${who} → ${stageLabel(payload.newStage)}`, {
|
||||
description: payload.oldStage
|
||||
? `Advanced from ${stageLabel(payload.oldStage)}.`
|
||||
: 'Pipeline stage advanced.',
|
||||
});
|
||||
}
|
||||
|
||||
function onDocumentCompleted(payload: { type?: string }) {
|
||||
// Kick a generic "fully signed" — the type-specific message is
|
||||
// friendlier when we can identify it as an EOI.
|
||||
if (payload?.type === 'eoi') {
|
||||
toast.success('EOI fully signed', {
|
||||
description: 'All required signatures received.',
|
||||
});
|
||||
} else {
|
||||
toast.success('Document fully signed');
|
||||
}
|
||||
}
|
||||
|
||||
function onSignerSigned(payload: { signerName?: string; documentTitle?: string }) {
|
||||
const who = payload?.signerName?.trim();
|
||||
const title = payload?.documentTitle?.trim();
|
||||
if (who && title) {
|
||||
toast.message(`${who} signed "${title}"`);
|
||||
} else if (who) {
|
||||
toast.message(`${who} signed a document`);
|
||||
} else {
|
||||
toast.message('Signer added a signature');
|
||||
}
|
||||
}
|
||||
|
||||
function onOutcomeSet(payload: { outcome?: string }) {
|
||||
if (!payload?.outcome) return;
|
||||
const isWon = payload.outcome === 'won';
|
||||
const label = payload.outcome.replace(/_/g, ' ');
|
||||
const fn = isWon ? toast.success : toast.message;
|
||||
fn(`Interest closed — ${label}`);
|
||||
}
|
||||
|
||||
socket.on('interest:stageChanged', onStageChanged);
|
||||
socket.on('document:completed', onDocumentCompleted);
|
||||
socket.on('document:signer:signed', onSignerSigned);
|
||||
socket.on('interest:outcomeSet', onOutcomeSet);
|
||||
|
||||
return () => {
|
||||
socket.off('interest:stageChanged', onStageChanged);
|
||||
socket.off('document:completed', onDocumentCompleted);
|
||||
socket.off('document:signer:signed', onSignerSigned);
|
||||
socket.off('interest:outcomeSet', onOutcomeSet);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +32,11 @@ interface SubdivisionComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SubdivisionCombobox({
|
||||
@@ -44,8 +49,14 @@ export function SubdivisionCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: SubdivisionComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (!country) return [];
|
||||
@@ -64,7 +75,7 @@ export function SubdivisionCombobox({
|
||||
else triggerLabel = placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -29,6 +29,11 @@ interface TimezoneComboboxProps {
|
||||
clearable?: boolean;
|
||||
id?: string;
|
||||
'data-testid'?: string;
|
||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
||||
defaultOpen?: boolean;
|
||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TimezoneCombobox({
|
||||
@@ -41,8 +46,14 @@ export function TimezoneCombobox({
|
||||
clearable = true,
|
||||
id,
|
||||
'data-testid': testId,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: TimezoneComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
return listAllTimezones().map((tz) => ({
|
||||
@@ -66,7 +77,7 @@ export function TimezoneCombobox({
|
||||
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -38,7 +38,15 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:top-[50%] sm:inset-auto sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
|
||||
// Mobile: full-screen sheet anchored to all four sides via individual
|
||||
// top/right/bottom/left utilities. Desktop (sm+): override each side
|
||||
// individually so tailwind-merge doesn't collapse our centering classes.
|
||||
// (Don't use `inset-0` + `sm:inset-auto` here — twMerge sees that as a
|
||||
// conflict and silently strips `sm:left-[50%]` / `sm:top-[50%]`.)
|
||||
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||
'sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function KPITile({
|
||||
<div
|
||||
data-testid="kpi-tile"
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
|
||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-3 shadow-sm transition-all duration-base ease-smooth hover:shadow-md sm:p-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -53,10 +53,12 @@ export function KPITile({
|
||||
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
|
||||
{value}
|
||||
</div>
|
||||
{typeof delta === 'number' ? (
|
||||
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
||||
{deltaPrefix}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
||||
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
@@ -206,6 +207,70 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
||||
);
|
||||
}
|
||||
|
||||
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
||||
const { data, isLoading } = useQuery<{
|
||||
data: Array<{
|
||||
id: string;
|
||||
pipelineStage: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}>({
|
||||
queryKey: ['interests', 'by-yacht', yachtId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
|
||||
});
|
||||
|
||||
const interests = data?.data ?? [];
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
if (interests.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{interests.map((i) => (
|
||||
<li
|
||||
key={i.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
|
||||
>
|
||||
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
|
||||
{i.pipelineStage.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{i.clientName ?? '—'}</span>
|
||||
{i.berthMooringNumber && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
Berth {i.berthMooringNumber}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = routeParams?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
|
||||
queryKey: ['berth-reservations', 'by-yacht', yachtId],
|
||||
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
|
||||
return (
|
||||
<ReservationList
|
||||
reservations={data?.data ?? []}
|
||||
showBerth
|
||||
portSlug={portSlug}
|
||||
emptyMessage="No reservations for this yacht."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
@@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: <EmptyState title="Interests" description="Coming soon" />,
|
||||
content: <YachtInterestsTab yachtId={yachtId} />,
|
||||
},
|
||||
{
|
||||
id: 'reservations',
|
||||
label: 'Reservations',
|
||||
content: <EmptyState title="Reservations" description="Coming soon" />,
|
||||
content: <YachtReservationsTab yachtId={yachtId} />,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
|
||||
52
src/hooks/realtime-invalidation-core.ts
Normal file
52
src/hooks/realtime-invalidation-core.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
|
||||
/** Minimum surface of socket.io's client we use here. Kept loose so the
|
||||
* helper can be unit-tested with a stub object without dragging the full
|
||||
* socket.io dependency into the test runtime. */
|
||||
export interface SocketLike {
|
||||
on(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||
off(event: string, handler: (...args: unknown[]) => void): unknown;
|
||||
}
|
||||
|
||||
export type EventMap = Record<string, QueryKey[]>;
|
||||
|
||||
/**
|
||||
* Pure subscription logic for `useRealtimeInvalidation`. Registers one
|
||||
* handler per event key. Each handler reads the latest eventMap from the
|
||||
* supplied getter so callers can pass a fresh object literal on every render
|
||||
* without re-subscribing.
|
||||
*
|
||||
* Returns a cleanup function that removes the registered handlers.
|
||||
*
|
||||
* Lives in its own JSX-free file so it can be unit-tested under vitest's
|
||||
* node environment without dragging the React provider into the bundle.
|
||||
*/
|
||||
export function subscribeRealtimeInvalidations(
|
||||
socket: SocketLike,
|
||||
eventKeys: string[],
|
||||
queryClient: Pick<QueryClient, 'invalidateQueries'>,
|
||||
getEventMap: () => EventMap,
|
||||
): () => void {
|
||||
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
|
||||
|
||||
for (const event of eventKeys) {
|
||||
const handler = () => {
|
||||
// Read the LATEST map at fire-time — not at subscription time — so
|
||||
// callers passing inline `{ 'client:created': [...] }` literals don't
|
||||
// bind a stale snapshot if they re-render.
|
||||
const queryKeys = getEventMap()[event];
|
||||
if (!queryKeys) return;
|
||||
for (const key of queryKeys) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
socket.on(event, handler);
|
||||
handlers.push({ event, handler });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const { event, handler } of handlers) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useSocket } from '@/providers/socket-provider';
|
||||
import { subscribeRealtimeInvalidations, type EventMap } from '@/hooks/realtime-invalidation-core';
|
||||
|
||||
// Re-export for convenience so callers don't need to know about the split.
|
||||
export type { EventMap, SocketLike } from '@/hooks/realtime-invalidation-core';
|
||||
|
||||
/**
|
||||
* Subscribes to socket events and invalidates React Query caches.
|
||||
*
|
||||
* @param eventMap - Maps socket event names to arrays of query keys to invalidate.
|
||||
* Safe to call with an inline-literal `eventMap` — the hook only re-subscribes
|
||||
* when the SET of event keys actually changes (not when the object identity
|
||||
* changes). The latest query-key list is read at event fire-time via a ref.
|
||||
*
|
||||
* @example
|
||||
* useRealtimeInvalidation({
|
||||
@@ -17,31 +23,29 @@ import { useSocket } from '@/providers/socket-provider';
|
||||
* 'client:archived': [['clients']],
|
||||
* });
|
||||
*/
|
||||
export function useRealtimeInvalidation(
|
||||
eventMap: Record<string, QueryKey[]>,
|
||||
) {
|
||||
export function useRealtimeInvalidation(eventMap: EventMap) {
|
||||
const socket = useSocket();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Stash the latest map in a ref so handlers always see fresh queryKeys
|
||||
// without re-subscribing.
|
||||
const eventMapRef = useRef(eventMap);
|
||||
eventMapRef.current = eventMap;
|
||||
|
||||
// Re-subscribe ONLY when the set of event names changes. Object identity
|
||||
// of `eventMap` flips on every caller render; the joined key signature
|
||||
// doesn't.
|
||||
const eventKeysSig = Object.keys(eventMap).sort().join('|');
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
|
||||
|
||||
for (const [event, queryKeys] of Object.entries(eventMap)) {
|
||||
const handler = () => {
|
||||
for (const key of queryKeys) {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
socket.on(event, handler);
|
||||
handlers.push({ event, handler });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const { event, handler } of handlers) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
};
|
||||
}, [socket, queryClient, eventMap]);
|
||||
// eventMapRef is intentionally not in deps — it's a ref; we only want to
|
||||
// re-run when the socket, queryClient, or the event-key SET changes.
|
||||
return subscribeRealtimeInvalidations(
|
||||
socket,
|
||||
eventKeysSig.length > 0 ? eventKeysSig.split('|') : [],
|
||||
queryClient,
|
||||
() => eventMapRef.current,
|
||||
);
|
||||
}, [socket, queryClient, eventKeysSig]);
|
||||
}
|
||||
|
||||
@@ -181,10 +181,15 @@ export function withAuth(
|
||||
}
|
||||
} else if (profile.isSuperAdmin && portId) {
|
||||
// Super admin still needs portSlug for response context.
|
||||
// We also validate the portId actually exists — a super-admin session
|
||||
// must not be able to operate against a fabricated portId.
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
});
|
||||
portSlug = port?.slug ?? '';
|
||||
if (!port) {
|
||||
return NextResponse.json({ error: 'Port not found' }, { status: 404 });
|
||||
}
|
||||
portSlug = port.slug;
|
||||
}
|
||||
|
||||
const ctx: AuthContext = {
|
||||
|
||||
@@ -26,6 +26,19 @@ export const STAGE_LABELS: Record<PipelineStage, string> = {
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
// Compact labels for cramped contexts (mobile chart axes, dense tables).
|
||||
export const STAGE_SHORT_LABELS: Record<PipelineStage, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details',
|
||||
in_communication: 'Comms',
|
||||
eoi_sent: 'EOI →',
|
||||
eoi_signed: 'EOI ✓',
|
||||
deposit_10pct: 'Dep.',
|
||||
contract_sent: 'Ctr →',
|
||||
contract_signed: 'Ctr ✓',
|
||||
completed: 'Done',
|
||||
};
|
||||
|
||||
export const STAGE_BADGE: Record<PipelineStage, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
|
||||
@@ -80,11 +80,13 @@ export async function fillEoiFormFields(
|
||||
setText(form, 'Name', context.client.fullName);
|
||||
setText(form, 'Email', context.client.primaryEmail ?? '');
|
||||
setText(form, 'Address', formatAddress(context.client.address));
|
||||
setText(form, 'Yacht Name', context.yacht.name);
|
||||
setText(form, 'Length', context.yacht.lengthFt ?? '');
|
||||
setText(form, 'Width', context.yacht.widthFt ?? '');
|
||||
setText(form, 'Draft', context.yacht.draftFt ?? '');
|
||||
setText(form, 'Berth Number', context.berth.mooringNumber);
|
||||
// Yacht + berth (EOI Section 3) are optional — leave the AcroForm fields
|
||||
// blank when the interest hasn't been linked to either.
|
||||
setText(form, 'Yacht Name', context.yacht?.name ?? '');
|
||||
setText(form, 'Length', context.yacht?.lengthFt ?? '');
|
||||
setText(form, 'Width', context.yacht?.widthFt ?? '');
|
||||
setText(form, 'Draft', context.yacht?.draftFt ?? '');
|
||||
setText(form, 'Berth Number', context.berth?.mooringNumber ?? '');
|
||||
|
||||
setCheckbox(form, 'Purchase', true);
|
||||
setCheckbox(form, 'Lease_10', false);
|
||||
|
||||
@@ -58,10 +58,11 @@ export function buildPipelineInputs(
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
];
|
||||
|
||||
@@ -73,9 +74,7 @@ export function buildPipelineInputs(
|
||||
});
|
||||
|
||||
// Include stages not in standard order
|
||||
const unknownStages = Object.keys(data.stageCounts).filter(
|
||||
(s) => !stageOrder.includes(s),
|
||||
);
|
||||
const unknownStages = Object.keys(data.stageCounts).filter((s) => !stageOrder.includes(s));
|
||||
for (const stage of unknownStages) {
|
||||
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
|
||||
}
|
||||
|
||||
@@ -50,18 +50,16 @@ export const revenueReportTemplate: Template = {
|
||||
],
|
||||
};
|
||||
|
||||
export function buildRevenueInputs(
|
||||
data: RevenueData,
|
||||
portName?: string,
|
||||
): Record<string, string>[] {
|
||||
export function buildRevenueInputs(data: RevenueData, portName?: string): Record<string, string>[] {
|
||||
const stageOrder = [
|
||||
'open',
|
||||
'details_sent',
|
||||
'in_communication',
|
||||
'visited',
|
||||
'signed_eoi_nda',
|
||||
'eoi_sent',
|
||||
'eoi_signed',
|
||||
'deposit_10pct',
|
||||
'contract',
|
||||
'contract_sent',
|
||||
'contract_signed',
|
||||
'completed',
|
||||
];
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import { cookies } from 'next/headers';
|
||||
const PORTAL_SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
||||
export const PORTAL_COOKIE = 'portal_session';
|
||||
|
||||
// BREAKING CHANGE (intentional): tokens issued before this change lack aud/iss
|
||||
// and will be rejected by verifyPortalToken. Portal tokens are 24h-lived so
|
||||
// existing sessions will be invalidated on deploy. Users simply re-login.
|
||||
const PORTAL_AUD = 'portal';
|
||||
const PORTAL_ISS = 'pn-crm';
|
||||
|
||||
export interface PortalSession {
|
||||
clientId: string;
|
||||
portId: string;
|
||||
@@ -13,6 +19,8 @@ export interface PortalSession {
|
||||
export async function createPortalToken(session: PortalSession): Promise<string> {
|
||||
return new SignJWT(session as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience(PORTAL_AUD)
|
||||
.setIssuer(PORTAL_ISS)
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(PORTAL_SECRET);
|
||||
@@ -20,7 +28,10 @@ export async function createPortalToken(session: PortalSession): Promise<string>
|
||||
|
||||
export async function verifyPortalToken(token: string): Promise<PortalSession | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, PORTAL_SECRET);
|
||||
const { payload } = await jwtVerify(token, PORTAL_SECRET, {
|
||||
audience: PORTAL_AUD,
|
||||
issuer: PORTAL_ISS,
|
||||
});
|
||||
return payload as unknown as PortalSession;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
@@ -81,7 +83,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
|
||||
const ids = result.data.map((r) => r.id);
|
||||
|
||||
const [yachtCounts, companyCounts] = await Promise.all([
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
|
||||
db
|
||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||
.from(yachts)
|
||||
@@ -99,18 +101,67 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
.from(companyMemberships)
|
||||
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
||||
.groupBy(companyMemberships.clientId),
|
||||
db
|
||||
.select({
|
||||
clientId: interests.clientId,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
updatedAt: interests.updatedAt,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
})
|
||||
.from(interests)
|
||||
.leftJoin(berths, eq(berths.id, interests.berthId))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
inArray(interests.clientId, ids),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(interests.updatedAt)),
|
||||
db
|
||||
.select({ clientId: interests.clientId, count: count() })
|
||||
.from(interests)
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
inArray(interests.clientId, ids),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
)
|
||||
.groupBy(interests.clientId),
|
||||
]);
|
||||
|
||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
|
||||
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
|
||||
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
|
||||
for (const row of interestRows) {
|
||||
if (!latestInterestMap.has(row.clientId)) {
|
||||
latestInterestMap.set(row.clientId, {
|
||||
stage: row.pipelineStage,
|
||||
mooringNumber: row.mooringNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((row) => ({
|
||||
data: result.data.map((row) => {
|
||||
const latest = latestInterestMap.get(row.id);
|
||||
return {
|
||||
...row,
|
||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||
companyCount: companyCountMap.get(row.id) ?? 0,
|
||||
})),
|
||||
interestCount: interestCountMap.get(row.id) ?? 0,
|
||||
latestInterest: latest
|
||||
? {
|
||||
stage: latest.stage,
|
||||
mooringNumber: latest.mooringNumber,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ export async function getKpis(portId: string) {
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
|
||||
|
||||
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId
|
||||
// Pipeline value: SUM each berth's price ONCE regardless of how many active
|
||||
// interests reference it. A berth with multiple interests would otherwise be
|
||||
// counted multiple times, inflating the total.
|
||||
const pipelineRows = await db
|
||||
.select({ price: berths.price })
|
||||
.selectDistinct({ berthId: interests.berthId, price: berths.price })
|
||||
.from(interests)
|
||||
.innerJoin(berths, eq(interests.berthId, berths.id))
|
||||
.where(
|
||||
|
||||
@@ -128,11 +128,13 @@ export function buildDocumensoPayload(
|
||||
Name: context.client.fullName,
|
||||
Email: context.client.primaryEmail ?? '',
|
||||
Address: formatAddress(context.client.address),
|
||||
'Yacht Name': context.yacht.name,
|
||||
Length: context.yacht.lengthFt ?? '',
|
||||
Width: context.yacht.widthFt ?? '',
|
||||
Draft: context.yacht.draftFt ?? '',
|
||||
'Berth Number': context.berth.mooringNumber,
|
||||
// Yacht + berth are optional EOI fields; when not linked, render as
|
||||
// empty strings so the corresponding template inputs stay blank.
|
||||
'Yacht Name': context.yacht?.name ?? '',
|
||||
Length: context.yacht?.lengthFt ?? '',
|
||||
Width: context.yacht?.widthFt ?? '',
|
||||
Draft: context.yacht?.draftFt ?? '',
|
||||
'Berth Number': context.berth?.mooringNumber ?? '',
|
||||
Lease_10: false,
|
||||
Purchase: true,
|
||||
},
|
||||
|
||||
@@ -237,18 +237,20 @@ export async function resolveTemplate(
|
||||
tokenMap['{{client.phone}}'] = eoi.client.primaryPhone ?? '';
|
||||
tokenMap['{{client.nationality}}'] = eoi.client.nationality ?? '';
|
||||
|
||||
// Yacht tokens
|
||||
tokenMap['{{yacht.name}}'] = eoi.yacht.name;
|
||||
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht.hullNumber ?? '';
|
||||
tokenMap['{{yacht.flag}}'] = eoi.yacht.flag ?? '';
|
||||
// Yacht tokens — `eoi.yacht` is null when no yacht is linked
|
||||
// (Section 3 of the EOI is optional). Tokens render as empty strings
|
||||
// in that case so the template still produces output.
|
||||
tokenMap['{{yacht.name}}'] = eoi.yacht?.name ?? '';
|
||||
tokenMap['{{yacht.hullNumber}}'] = eoi.yacht?.hullNumber ?? '';
|
||||
tokenMap['{{yacht.flag}}'] = eoi.yacht?.flag ?? '';
|
||||
tokenMap['{{yacht.yearBuilt}}'] =
|
||||
eoi.yacht.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
|
||||
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht.lengthFt ?? '';
|
||||
tokenMap['{{yacht.widthFt}}'] = eoi.yacht.widthFt ?? '';
|
||||
tokenMap['{{yacht.draftFt}}'] = eoi.yacht.draftFt ?? '';
|
||||
tokenMap['{{yacht.lengthM}}'] = eoi.yacht.lengthM ?? '';
|
||||
tokenMap['{{yacht.widthM}}'] = eoi.yacht.widthM ?? '';
|
||||
tokenMap['{{yacht.draftM}}'] = eoi.yacht.draftM ?? '';
|
||||
eoi.yacht?.yearBuilt != null ? String(eoi.yacht.yearBuilt) : '';
|
||||
tokenMap['{{yacht.lengthFt}}'] = eoi.yacht?.lengthFt ?? '';
|
||||
tokenMap['{{yacht.widthFt}}'] = eoi.yacht?.widthFt ?? '';
|
||||
tokenMap['{{yacht.draftFt}}'] = eoi.yacht?.draftFt ?? '';
|
||||
tokenMap['{{yacht.lengthM}}'] = eoi.yacht?.lengthM ?? '';
|
||||
tokenMap['{{yacht.widthM}}'] = eoi.yacht?.widthM ?? '';
|
||||
tokenMap['{{yacht.draftM}}'] = eoi.yacht?.draftM ?? '';
|
||||
|
||||
// EoiContext doesn't expose the yacht.registration column — look it up
|
||||
// separately (cheap, indexed fetch) so the token resolves when present.
|
||||
@@ -281,29 +283,31 @@ export async function resolveTemplate(
|
||||
tokenMap['{{owner.name}}'] = eoi.owner.name;
|
||||
tokenMap['{{owner.legalName}}'] = eoi.owner.legalName ?? '';
|
||||
|
||||
// Berth tokens (from EoiContext)
|
||||
tokenMap['{{berth.mooringNumber}}'] = eoi.berth.mooringNumber;
|
||||
tokenMap['{{berth.area}}'] = eoi.berth.area ?? '';
|
||||
tokenMap['{{berth.lengthFt}}'] = eoi.berth.lengthFt ?? '';
|
||||
tokenMap['{{berth.price}}'] = eoi.berth.price ?? '';
|
||||
tokenMap['{{berth.priceCurrency}}'] = eoi.berth.priceCurrency;
|
||||
tokenMap['{{berth.tenureType}}'] = eoi.berth.tenureType;
|
||||
// Berth tokens — also optional. Render empty when no berth is linked.
|
||||
tokenMap['{{berth.mooringNumber}}'] = eoi.berth?.mooringNumber ?? '';
|
||||
tokenMap['{{berth.area}}'] = eoi.berth?.area ?? '';
|
||||
tokenMap['{{berth.lengthFt}}'] = eoi.berth?.lengthFt ?? '';
|
||||
tokenMap['{{berth.price}}'] = eoi.berth?.price ?? '';
|
||||
tokenMap['{{berth.priceCurrency}}'] = eoi.berth?.priceCurrency ?? '';
|
||||
tokenMap['{{berth.tenureType}}'] = eoi.berth?.tenureType ?? '';
|
||||
|
||||
// Interest tokens
|
||||
tokenMap['{{interest.stage}}'] = eoi.interest.stage;
|
||||
tokenMap['{{interest.leadCategory}}'] = eoi.interest.leadCategory ?? '';
|
||||
tokenMap['{{interest.berthNumber}}'] = eoi.berth.mooringNumber;
|
||||
tokenMap['{{interest.berthNumber}}'] = eoi.berth?.mooringNumber ?? '';
|
||||
tokenMap['{{interest.dateFirstContact}}'] = eoi.interest.dateFirstContact
|
||||
? eoi.interest.dateFirstContact.toLocaleDateString('en-GB')
|
||||
: '';
|
||||
tokenMap['{{interest.notes}}'] = eoi.interest.notes ?? '';
|
||||
} catch (err) {
|
||||
// buildEoiContext throws ValidationError when the interest has no yacht
|
||||
// or berth; non-EOI templates don't need those. Fall through to the
|
||||
// legacy resolution path below. Re-throw anything else.
|
||||
// buildEoiContext throws ValidationError when the EOI's required client
|
||||
// fields (name/email/address — Section 2) are missing. For non-EOI
|
||||
// templates (correspondence, welcome letters, etc.) those gates don't
|
||||
// apply — fall through to the legacy resolution path below. Re-throw
|
||||
// anything else.
|
||||
if (
|
||||
!(err instanceof ValidationError) ||
|
||||
!/interest has no (yacht|berth)/i.test(err.message)
|
||||
!/missing required client details|interest has no (yacht|berth)/i.test(err.message)
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { minioClient, buildStoragePath } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import { advanceStageIfBehind } from '@/lib/services/interests.service';
|
||||
import {
|
||||
createDocument as documensoCreate,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
downloadSignedPdf,
|
||||
voidDocument as documensoVoid,
|
||||
} from '@/lib/services/documenso-client';
|
||||
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||
import type {
|
||||
CreateDocumentInput,
|
||||
UpdateDocumentInput,
|
||||
@@ -506,6 +508,12 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
|
||||
// Resolve port-configured signer emails from system settings; fall back to
|
||||
// legacy defaults only if the setting is absent. Fabricated slug-based
|
||||
// addresses (developer@{slug}.com) are no longer used here because they
|
||||
// never match real port users and cause silent no-ops in handleRecipientSigned.
|
||||
const eoiSigners = await getPortEoiSigners(portId);
|
||||
|
||||
// BR-021: Create 3 signers — client (1), developer (2), sales/approver (3)
|
||||
const signerRecords = await db
|
||||
.insert(documentSigners)
|
||||
@@ -520,16 +528,16 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
},
|
||||
{
|
||||
documentId,
|
||||
signerName: port.name,
|
||||
signerEmail: `developer@${port.slug}.com`,
|
||||
signerName: eoiSigners.developer.name,
|
||||
signerEmail: eoiSigners.developer.email,
|
||||
signerRole: 'developer',
|
||||
signingOrder: 2,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
documentId,
|
||||
signerName: `${port.name} Sales`,
|
||||
signerEmail: `sales@${port.slug}.com`,
|
||||
signerName: eoiSigners.approver.name,
|
||||
signerEmail: eoiSigners.approver.email,
|
||||
signerRole: 'approver',
|
||||
signingOrder: 3,
|
||||
status: 'pending',
|
||||
@@ -552,10 +560,15 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
||||
// Create document in Documenso + send
|
||||
const documensoDoc = await documensoCreate(doc.title, pdfBase64, [
|
||||
{ name: client.fullName, email: emailContact.value, role: 'SIGNER', signingOrder: 1 },
|
||||
{ name: port.name, email: `developer@${port.slug}.com`, role: 'SIGNER', signingOrder: 2 },
|
||||
{
|
||||
name: `${port.name} Sales`,
|
||||
email: `sales@${port.slug}.com`,
|
||||
name: eoiSigners.developer.name,
|
||||
email: eoiSigners.developer.email,
|
||||
role: 'SIGNER',
|
||||
signingOrder: 2,
|
||||
},
|
||||
{
|
||||
name: eoiSigners.approver.name,
|
||||
email: eoiSigners.approver.email,
|
||||
role: 'SIGNER',
|
||||
signingOrder: 3,
|
||||
},
|
||||
@@ -788,6 +801,22 @@ export async function handleRecipientSigned(eventData: {
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!signer) {
|
||||
// Email mismatch: the address Documenso has on the recipient doesn't match
|
||||
// any row in documentSigners. This happens when the local signers were
|
||||
// created with fabricated / stale addresses. Log a warning so operators can
|
||||
// investigate and fix the port's eoi_signers system setting.
|
||||
logger.warn(
|
||||
{
|
||||
documensoId: eventData.documentId,
|
||||
documentId: doc.id,
|
||||
recipientEmail: eventData.recipientEmail,
|
||||
},
|
||||
'handleRecipientSigned: no matching signer row for recipient email — ' +
|
||||
'check eoi_signers system setting for this port',
|
||||
);
|
||||
}
|
||||
|
||||
// Update document to partially_signed if eoi type
|
||||
if (doc.documentType === 'eoi' && doc.status === 'sent') {
|
||||
await db
|
||||
@@ -896,7 +925,19 @@ export async function handleDocumentCompleted(eventData: { documentId: string })
|
||||
ipAddress: '0.0.0.0',
|
||||
userAgent: 'webhook',
|
||||
};
|
||||
|
||||
// Guard against double-fire: DOCUMENT_COMPLETED may arrive multiple times
|
||||
// (webhook retries) or follow a DOCUMENT_SIGNED that already advanced the
|
||||
// stage. advanceStageIfBehind handles the pipeline guard internally, but
|
||||
// evaluateRule has no idempotency — skip it if the interest is already at
|
||||
// eoi_signed or beyond to prevent duplicate berth-rule side effects.
|
||||
const currentStageIdx = PIPELINE_STAGES.indexOf(
|
||||
interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
|
||||
);
|
||||
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||
if (currentStageIdx < eoiSignedIdx) {
|
||||
void evaluateRule('eoi_signed', doc.interestId, doc.portId, systemMeta);
|
||||
}
|
||||
|
||||
// Advance to eoi_signed (no-op if interest already past it).
|
||||
void advanceStageIfBehind(
|
||||
|
||||
@@ -20,6 +20,7 @@ export type EoiContext = {
|
||||
primaryPhone: string | null;
|
||||
address: { street: string; city: string; country: string } | null;
|
||||
};
|
||||
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
|
||||
yacht: {
|
||||
name: string;
|
||||
lengthFt: string | null;
|
||||
@@ -31,18 +32,22 @@ export type EoiContext = {
|
||||
hullNumber: string | null;
|
||||
flag: string | null;
|
||||
yearBuilt: number | null;
|
||||
};
|
||||
} | null;
|
||||
company: {
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
billingAddress: string | null;
|
||||
} | null;
|
||||
/** Inferred from the yacht's polymorphic owner. Falls back to the interest's
|
||||
* client when no yacht is linked (so the EOI's signing party is still
|
||||
* resolvable). */
|
||||
owner: {
|
||||
type: 'client' | 'company';
|
||||
name: string;
|
||||
legalName?: string;
|
||||
};
|
||||
/** Optional. The EOI's Section 3 berth-number is left blank when null. */
|
||||
berth: {
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
@@ -50,7 +55,7 @@ export type EoiContext = {
|
||||
price: string | null;
|
||||
priceCurrency: string;
|
||||
tenureType: string;
|
||||
};
|
||||
} | null;
|
||||
interest: {
|
||||
stage: string;
|
||||
leadCategory: string | null;
|
||||
@@ -77,8 +82,10 @@ export type EoiContext = {
|
||||
* Pure read-only: no audit logs, no socket emits, no mutations.
|
||||
*
|
||||
* Tenant-scoped: every fetch is gated by `portId`, and missing rows surface
|
||||
* as NotFoundError. Missing yacht/berth references on the interest surface as
|
||||
* ValidationError, because EOI flows cannot proceed without them.
|
||||
* as NotFoundError. The hard gate matches the EOI document's top paragraph
|
||||
* (Section 2 — name, address, email): without those the EOI is unsignable
|
||||
* and we throw. Yacht and berth (Section 3) are optional — the rendered PDF
|
||||
* leaves those fields blank when not set.
|
||||
*/
|
||||
export async function buildEoiContext(interestId: string, portId: string): Promise<EoiContext> {
|
||||
// 1. Interest (tenant-scoped)
|
||||
@@ -89,24 +96,19 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
throw new NotFoundError('Interest');
|
||||
}
|
||||
|
||||
// 2. Yacht reference must exist on the interest
|
||||
if (!interest.yachtId) {
|
||||
throw new ValidationError('interest has no yacht');
|
||||
}
|
||||
|
||||
// 3. Berth reference must exist on the interest
|
||||
if (!interest.berthId) {
|
||||
throw new ValidationError('interest has no berth');
|
||||
}
|
||||
|
||||
// 2 + 3 + 4 + 9: parallelise independent reads.
|
||||
// Parallelise independent reads. Yacht and berth are both nullable —
|
||||
// the EOI's Section 3 stays blank when they're absent.
|
||||
const [yacht, berth, client, port] = await Promise.all([
|
||||
db.query.yachts.findFirst({
|
||||
interest.yachtId
|
||||
? db.query.yachts.findFirst({
|
||||
where: and(eq(yachts.id, interest.yachtId), eq(yachts.portId, portId)),
|
||||
}),
|
||||
db.query.berths.findFirst({
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
interest.berthId
|
||||
? db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, interest.berthId), eq(berths.portId, portId)),
|
||||
}),
|
||||
})
|
||||
: Promise.resolve(undefined),
|
||||
db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, interest.clientId), eq(clients.portId, portId)),
|
||||
}),
|
||||
@@ -115,8 +117,6 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!yacht) throw new NotFoundError('Yacht');
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
if (!client) throw new NotFoundError('Client');
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
|
||||
@@ -157,11 +157,28 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
}
|
||||
: null;
|
||||
|
||||
// 7 + 8. Yacht owner (polymorphic) + optional company billing address.
|
||||
// EOI hard gate: the document's top paragraph (Section 2) requires Name,
|
||||
// Address, and Email. Without these the rendered EOI is unsignable. Yacht
|
||||
// and berth (Section 3) are intentionally optional and may be left blank.
|
||||
const missing: string[] = [];
|
||||
if (!client.fullName?.trim()) missing.push('client name');
|
||||
if (!firstEmail?.value?.trim()) missing.push('client email');
|
||||
if (!clientAddress || !clientAddress.street.trim()) missing.push('client address');
|
||||
if (missing.length > 0) {
|
||||
throw new ValidationError(
|
||||
`Cannot generate EOI — missing required client details: ${missing.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Owner block. When a yacht is linked, derive from the yacht's polymorphic
|
||||
// owner. When no yacht is linked, fall back to the interest's client so the
|
||||
// EOI's signing party is still resolvable.
|
||||
let ownerBlock: EoiContext['owner'];
|
||||
let companyBlock: EoiContext['company'] = null;
|
||||
|
||||
if (yacht.currentOwnerType === 'client') {
|
||||
if (!yacht) {
|
||||
ownerBlock = { type: 'client', name: client.fullName };
|
||||
} else if (yacht.currentOwnerType === 'client') {
|
||||
// The yacht-owning client may or may not be the same as the interest's client.
|
||||
const ownerClient =
|
||||
yacht.currentOwnerId === client.id
|
||||
@@ -228,7 +245,8 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
primaryPhone: firstPhone?.value ?? null,
|
||||
address: clientAddress,
|
||||
},
|
||||
yacht: {
|
||||
yacht: yacht
|
||||
? {
|
||||
name: yacht.name,
|
||||
lengthFt: yacht.lengthFt,
|
||||
widthFt: yacht.widthFt,
|
||||
@@ -239,17 +257,20 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
hullNumber: yacht.hullNumber,
|
||||
flag: yacht.flag,
|
||||
yearBuilt: yacht.yearBuilt,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
company: companyBlock,
|
||||
owner: ownerBlock,
|
||||
berth: {
|
||||
berth: berth
|
||||
? {
|
||||
mooringNumber: berth.mooringNumber,
|
||||
area: berth.area,
|
||||
lengthFt: berth.lengthFt,
|
||||
price: berth.price,
|
||||
priceCurrency: berth.priceCurrency,
|
||||
tenureType: berth.tenureType,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
interest: {
|
||||
stage: interest.pipelineStage,
|
||||
leadCategory: interest.leadCategory,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestTags } from '@/lib/db/schema/interests';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interests, interestTags, interestNotes } from '@/lib/db/schema/interests';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyMemberships } from '@/lib/db/schema/companies';
|
||||
@@ -182,6 +183,11 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
return interests.leadCategory;
|
||||
case 'createdAt':
|
||||
return interests.createdAt;
|
||||
case 'dateLastContact':
|
||||
// Postgres sorts NULLs last on DESC by default, which is the right
|
||||
// behaviour for triage (recently-contacted first, never-contacted
|
||||
// at the bottom).
|
||||
return interests.dateLastContact;
|
||||
default:
|
||||
return interests.updatedAt;
|
||||
}
|
||||
@@ -221,6 +227,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
let clientsMap: Record<string, string> = {};
|
||||
let berthsMap: Record<string, string> = {};
|
||||
const tagsByInterestId: Record<string, Array<{ id: string; name: string; color: string }>> = {};
|
||||
const notesCountByInterestId: Record<string, number> = {};
|
||||
|
||||
if (clientIds.length > 0) {
|
||||
const clientRows = await db
|
||||
@@ -254,6 +261,19 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
if (!tagsByInterestId[row.interestId]) tagsByInterestId[row.interestId] = [];
|
||||
tagsByInterestId[row.interestId]!.push({ id: row.id, name: row.name, color: row.color });
|
||||
}
|
||||
|
||||
// Note counts per interest, for the comment-icon row affordance.
|
||||
const noteCountRows = await db
|
||||
.select({
|
||||
interestId: interestNotes.interestId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.where(inArray(interestNotes.interestId, interestIds))
|
||||
.groupBy(interestNotes.interestId);
|
||||
for (const row of noteCountRows) {
|
||||
notesCountByInterestId[row.interestId] = row.count;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (result.data as Array<Record<string, unknown>>).map((i) => ({
|
||||
@@ -261,6 +281,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
clientName: clientsMap[i.clientId as string] ?? null,
|
||||
berthMooringNumber: i.berthId ? (berthsMap[i.berthId as string] ?? null) : null,
|
||||
tags: tagsByInterestId[i.id as string] ?? [],
|
||||
notesCount: notesCountByInterestId[i.id as string] ?? 0,
|
||||
}));
|
||||
|
||||
return { data, total: result.total };
|
||||
@@ -282,6 +303,37 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.from(clients)
|
||||
.where(eq(clients.id, interest.clientId));
|
||||
|
||||
// EOI prerequisites + interest-detail header contact actions: surface the
|
||||
// linked client's primary email/phone (and the canonical E.164 form for
|
||||
// wa.me) so the header can render Email / Call / WhatsApp buttons without
|
||||
// a second fetch, and the Documents tab can show the EOI prereq checklist.
|
||||
const [emailContact] = await db
|
||||
.select({ value: clientContacts.value })
|
||||
.from(clientContacts)
|
||||
.where(and(eq(clientContacts.clientId, interest.clientId), eq(clientContacts.channel, 'email')))
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [phoneContact] = await db
|
||||
.select({ value: clientContacts.value, valueE164: clientContacts.valueE164 })
|
||||
.from(clientContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(clientContacts.clientId, interest.clientId),
|
||||
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [addressRow] = await db
|
||||
.select({ id: clientAddresses.id })
|
||||
.from(clientAddresses)
|
||||
.where(
|
||||
and(eq(clientAddresses.clientId, interest.clientId), eq(clientAddresses.isPrimary, true)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let berthMooringNumber: string | null = null;
|
||||
if (interest.berthId) {
|
||||
const [berthRow] = await db
|
||||
@@ -297,11 +349,46 @@ export async function getInterestById(id: string, portId: string) {
|
||||
.innerJoin(tags, eq(interestTags.tagId, tags.id))
|
||||
.where(eq(interestTags.interestId, id));
|
||||
|
||||
// Most-recent note preview for the Overview tab (the "do you have anything
|
||||
// outstanding on this lead?" peek). Returns the latest note's truncated
|
||||
// content + author/timestamp so the UI can render a one-line teaser.
|
||||
const [recentNote] = await db
|
||||
.select({
|
||||
id: interestNotes.id,
|
||||
content: interestNotes.content,
|
||||
authorId: interestNotes.authorId,
|
||||
createdAt: interestNotes.createdAt,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.where(eq(interestNotes.interestId, id))
|
||||
.orderBy(desc(interestNotes.createdAt))
|
||||
.limit(1);
|
||||
|
||||
const [{ count: notesCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestNotes)
|
||||
.where(eq(interestNotes.interestId, id));
|
||||
|
||||
// Active reminder count for the interest's bell badge. Counts reminders
|
||||
// directly linked via interestId — `pending` and `snoozed` only;
|
||||
// completed/dismissed don't surface.
|
||||
const [{ count: activeReminderCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.interestId, id), inArray(reminders.status, ['pending', 'snoozed'])));
|
||||
|
||||
return {
|
||||
...interest,
|
||||
clientName: clientRow?.fullName ?? null,
|
||||
clientPrimaryEmail: emailContact?.value ?? null,
|
||||
clientPrimaryPhone: phoneContact?.value ?? null,
|
||||
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
|
||||
clientHasAddress: !!addressRow,
|
||||
berthMooringNumber,
|
||||
tags: tagRows,
|
||||
notesCount,
|
||||
recentNote: recentNote ?? null,
|
||||
activeReminderCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,20 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import { useSession } from '@/lib/auth/client';
|
||||
import { usePortStore } from '@/stores/ui-store';
|
||||
|
||||
const SocketContext = createContext<Socket | null>(null);
|
||||
interface SocketContextValue {
|
||||
/** Stable socket instance reference. Persists across reconnects — socket.io's
|
||||
* built-in reconnection re-establishes the underlying transport without
|
||||
* changing the JS object, so this stays valid as long as the session and
|
||||
* port are unchanged. Consumers should NOT null-check this for "is online";
|
||||
* use `isConnected` instead. */
|
||||
socket: Socket | null;
|
||||
/** Live transport state. Flips false on disconnect and back to true on
|
||||
* reconnect. Use this if you need to surface offline UX; the socket itself
|
||||
* stays subscribed to the same event handlers. */
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextValue>({ socket: null, isConnected: false });
|
||||
|
||||
/** Returns true once the component has mounted on the client. Avoids calling
|
||||
* better-auth's `useSession()` (which dispatches React hooks via nanostores)
|
||||
@@ -32,7 +45,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
return hasMounted ? (
|
||||
<SocketProviderClient>{children}</SocketProviderClient>
|
||||
) : (
|
||||
<SocketContext.Provider value={null}>{children}</SocketContext.Provider>
|
||||
<SocketContext.Provider value={{ socket: null, isConnected: false }}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +55,14 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
|
||||
const { data: session } = useSession();
|
||||
const currentPortId = usePortStore((s) => s.currentPortId);
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user || !currentPortId) return;
|
||||
if (!session?.user || !currentPortId) {
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const s = io(process.env.NEXT_PUBLIC_APP_URL!, {
|
||||
path: '/socket.io/',
|
||||
@@ -51,18 +71,38 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
s.on('connect', () => setSocket(s));
|
||||
s.on('disconnect', () => setSocket(null));
|
||||
// Set the socket reference immediately and keep it stable across the
|
||||
// session+port lifetime. socket.io reconnects internally; the same
|
||||
// instance survives transient drops, and any handlers registered via
|
||||
// `socket.on(...)` stay attached. Previously we set/unset `socket` on
|
||||
// connect/disconnect, which made the React context flip to null on every
|
||||
// network blip and silently killed every `useRealtimeInvalidation`
|
||||
// subscription session-wide.
|
||||
setSocket(s);
|
||||
|
||||
s.on('connect', () => setIsConnected(true));
|
||||
s.on('disconnect', () => setIsConnected(false));
|
||||
|
||||
return () => {
|
||||
s.disconnect();
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [session?.user, currentPortId]);
|
||||
|
||||
return <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>;
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
return useContext(SocketContext);
|
||||
/** Returns the Socket.IO client instance. The reference is stable for the
|
||||
* duration of a session+port, even across transient disconnects. */
|
||||
export function useSocket(): Socket | null {
|
||||
return useContext(SocketContext).socket;
|
||||
}
|
||||
|
||||
/** True while the socket transport is connected. Flips false on disconnect,
|
||||
* back to true on reconnect. Useful for surfacing an "offline" indicator. */
|
||||
export function useIsSocketConnected(): boolean {
|
||||
return useContext(SocketContext).isConnected;
|
||||
}
|
||||
|
||||
@@ -635,10 +635,11 @@ export function makeCreateInterestInput(overrides?: {
|
||||
| 'open'
|
||||
| 'details_sent'
|
||||
| 'in_communication'
|
||||
| 'visited'
|
||||
| 'signed_eoi_nda'
|
||||
| 'eoi_sent'
|
||||
| 'eoi_signed'
|
||||
| 'deposit_10pct'
|
||||
| 'contract'
|
||||
| 'contract_sent'
|
||||
| 'contract_signed'
|
||||
| 'completed';
|
||||
}) {
|
||||
return {
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('alert engine', () => {
|
||||
await db.insert(interests).values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
pipelineStage: 'visited',
|
||||
pipelineStage: 'in_communication',
|
||||
leadCategory: 'hot_lead',
|
||||
dateLastContact: stale,
|
||||
updatedAt: stale,
|
||||
|
||||
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
102
tests/integration/api/berth-reservations-list.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Port-scoped global reservations list — locks in feat(marina): the new
|
||||
* `GET /api/v1/berth-reservations` endpoint that powers the
|
||||
* `[portSlug]/berth-reservations` page. The route is thin (parseQuery →
|
||||
* listReservations); the test guarantees port scoping at the handler
|
||||
* boundary so a future refactor of the service can't accidentally leak
|
||||
* cross-port rows.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { listHandler } from '@/app/api/v1/berth-reservations/handlers';
|
||||
import { createHandler as createReservationHandler } from '@/app/api/v1/berths/[id]/reservations/handlers';
|
||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||
import {
|
||||
makeBerth,
|
||||
makeClient,
|
||||
makeFullPermissions,
|
||||
makePort,
|
||||
makeYacht,
|
||||
} from '../../helpers/factories';
|
||||
|
||||
async function seedReservation(portId: string) {
|
||||
const berth = await makeBerth({ portId });
|
||||
const client = await makeClient({ portId });
|
||||
const yacht = await makeYacht({ portId, ownerType: 'client', ownerId: client.id });
|
||||
const ctx = makeMockCtx({ portId, permissions: makeFullPermissions() });
|
||||
const res = await createReservationHandler(
|
||||
makeMockRequest('POST', `http://localhost/api/v1/berths/${berth.id}/reservations`, {
|
||||
body: {
|
||||
clientId: client.id,
|
||||
yachtId: yacht.id,
|
||||
startDate: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
ctx,
|
||||
{ id: berth.id },
|
||||
);
|
||||
return (await res.json()).data as { id: string; berthId: string };
|
||||
}
|
||||
|
||||
describe('GET /api/v1/berth-reservations', () => {
|
||||
it('returns all reservations for the requesting port', async () => {
|
||||
const port = await makePort();
|
||||
const r1 = await seedReservation(port.id);
|
||||
const r2 = await seedReservation(port.id);
|
||||
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
const ids = (body.data as Array<{ id: string }>).map((r) => r.id).sort();
|
||||
expect(ids).toEqual([r1.id, r2.id].sort());
|
||||
expect(body.pagination).toMatchObject({ page: 1, total: 2 });
|
||||
});
|
||||
|
||||
it('does not leak reservations from a different port', async () => {
|
||||
const portA = await makePort();
|
||||
const portB = await makePort();
|
||||
const reservationInB = await seedReservation(portB.id);
|
||||
|
||||
// Caller is operating in portA; portB's reservation must not appear.
|
||||
const ctx = makeMockCtx({ portId: portA.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
const ids = (body.data as Array<{ id: string }>).map((r) => r.id);
|
||||
expect(ids).not.toContain(reservationInB.id);
|
||||
});
|
||||
|
||||
it('honors pagination via query params', async () => {
|
||||
const port = await makePort();
|
||||
await seedReservation(port.id);
|
||||
await seedReservation(port.id);
|
||||
await seedReservation(port.id);
|
||||
|
||||
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
||||
const res = await listHandler(
|
||||
makeMockRequest('GET', 'http://localhost/api/v1/berth-reservations?page=1&limit=2'),
|
||||
ctx,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.pagination).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
total: 3,
|
||||
totalPages: 2,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
126
tests/integration/api/saved-views-ownership.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Saved-views ownership enforcement — locks in the 403/404 split shipped
|
||||
* in fix(auth). The route handlers preflight `assertViewOwner` BEFORE the
|
||||
* service call, so even if the service's internal userId filter is later
|
||||
* refactored, the route still rejects cross-user mutations.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { savedViewsService } from '@/lib/services/saved-views.service';
|
||||
import { patchHandler, deleteHandler } from '@/app/api/v1/saved-views/[id]/handlers';
|
||||
import { makeMockCtx, makeMockRequest } from '../../helpers/route-tester';
|
||||
import { makePort } from '../../helpers/factories';
|
||||
|
||||
describe('saved-views ownership enforcement', () => {
|
||||
let portId: string;
|
||||
let viewId: string;
|
||||
const ownerUserId = 'user-owner';
|
||||
const otherUserId = 'user-other';
|
||||
|
||||
beforeAll(async () => {
|
||||
const port = await makePort();
|
||||
portId = port.id;
|
||||
const view = await savedViewsService.create(portId, ownerUserId, {
|
||||
entityType: 'clients',
|
||||
name: 'Hot leads',
|
||||
filters: { stage: 'hot_lead' } as Record<string, unknown>,
|
||||
isShared: false,
|
||||
isDefault: false,
|
||||
});
|
||||
if (!view) throw new Error('seed view failed');
|
||||
viewId = view.id;
|
||||
});
|
||||
|
||||
it('PATCH from owner: 200', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'Renamed by owner' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.name).toBe('Renamed by owner');
|
||||
});
|
||||
|
||||
it('PATCH from a different user: 403 (not 404)', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'Hostile rename' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Verify the row was not mutated.
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row?.name).toBe('Renamed by owner');
|
||||
});
|
||||
|
||||
it('DELETE from a different user: 403 and view still exists', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: otherUserId });
|
||||
const res = await deleteHandler(
|
||||
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PATCH on a non-existent id: 404', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest(
|
||||
'PATCH',
|
||||
'http://localhost/api/v1/saved-views/00000000-0000-0000-0000-000000000000',
|
||||
{ body: { name: 'no-op' } },
|
||||
),
|
||||
ctx,
|
||||
{ id: '00000000-0000-0000-0000-000000000000' },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PATCH on a view in a different port: 404 (cross-port enumeration is blocked)', async () => {
|
||||
// The view exists in `portId` but the auth context says we're operating
|
||||
// in a different port. The lookup is scoped to `(id, portId)` so the row
|
||||
// is invisible — should 404, not 403.
|
||||
const otherPort = await makePort();
|
||||
const ctx = makeMockCtx({ portId: otherPort.id, userId: ownerUserId });
|
||||
const res = await patchHandler(
|
||||
makeMockRequest('PATCH', `http://localhost/api/v1/saved-views/${viewId}`, {
|
||||
body: { name: 'cross-port attempt' },
|
||||
}),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE from owner: 200 and view is gone', async () => {
|
||||
const ctx = makeMockCtx({ portId, userId: ownerUserId });
|
||||
const res = await deleteHandler(
|
||||
makeMockRequest('DELETE', `http://localhost/api/v1/saved-views/${viewId}`),
|
||||
ctx,
|
||||
{ id: viewId },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = await db.query.savedViews.findFirst({
|
||||
where: (sv, { eq }) => eq(sv.id, viewId),
|
||||
});
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -268,6 +268,21 @@ describe('resolveTemplate — company-owned yacht', () => {
|
||||
portId: port.id,
|
||||
overrides: { fullName: 'Bob Contact' },
|
||||
});
|
||||
// EOI gate now requires client primary email + address.
|
||||
await db.insert(clientContacts).values({
|
||||
clientId: client.id,
|
||||
channel: 'email',
|
||||
value: 'bob@example.com',
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId: client.id,
|
||||
portId: port.id,
|
||||
streetAddress: '1 Marina Way',
|
||||
city: 'Anguilla',
|
||||
countryIso: 'AI',
|
||||
isPrimary: true,
|
||||
});
|
||||
const yacht = await makeYacht({
|
||||
portId: port.id,
|
||||
ownerType: 'company',
|
||||
|
||||
92
tests/integration/documents-expired-webhook.test.ts
Normal file
92
tests/integration/documents-expired-webhook.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* DOCUMENT_EXPIRED webhook handling — locks in fix(documenso). The handler
|
||||
* was previously defined but never wired to the route's event switch, so
|
||||
* expired EOIs stayed in `sent` / `partially_signed` forever.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { handleDocumentExpired } from '@/lib/services/documents.service';
|
||||
import { makeBerth, makeClient, makePort } from '../helpers/factories';
|
||||
|
||||
describe('handleDocumentExpired', () => {
|
||||
it('flips a sent EOI to expired and writes a documentEvents row', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
|
||||
const documensoId = `documenso-test-${Date.now()}`;
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
documentType: 'eoi',
|
||||
title: 'Expiring EOI',
|
||||
status: 'sent',
|
||||
documensoId,
|
||||
createdBy: 'seed',
|
||||
})
|
||||
.returning();
|
||||
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
|
||||
const after = await db.query.documents.findFirst({
|
||||
where: eq(documents.id, doc!.id),
|
||||
});
|
||||
expect(after?.status).toBe('expired');
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(documentEvents)
|
||||
.where(eq(documentEvents.documentId, doc!.id));
|
||||
expect(events.map((e) => e.eventType)).toContain('expired');
|
||||
});
|
||||
|
||||
it('also flips the linked interest eoiStatus to expired', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
|
||||
const [interest] = await db
|
||||
.insert(interests)
|
||||
.values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
berthId: berth.id,
|
||||
pipelineStage: 'eoi_sent',
|
||||
leadCategory: 'hot_lead',
|
||||
eoiStatus: 'sent',
|
||||
})
|
||||
.returning();
|
||||
|
||||
const documensoId = `documenso-test-${Date.now()}-i`;
|
||||
await db.insert(documents).values({
|
||||
portId: port.id,
|
||||
clientId: client.id,
|
||||
interestId: interest!.id,
|
||||
documentType: 'eoi',
|
||||
title: 'Expiring EOI for interest',
|
||||
status: 'sent',
|
||||
documensoId,
|
||||
createdBy: 'seed',
|
||||
});
|
||||
|
||||
await handleDocumentExpired({ documentId: documensoId });
|
||||
|
||||
const updatedInterest = await db.query.interests.findFirst({
|
||||
where: eq(interests.id, interest!.id),
|
||||
});
|
||||
expect(updatedInterest?.eoiStatus).toBe('expired');
|
||||
});
|
||||
|
||||
it('is a no-op when the documensoId does not match any document', async () => {
|
||||
// Should NOT throw — the handler logs a warning and returns. Verify no
|
||||
// exception propagates up to the webhook route.
|
||||
await expect(
|
||||
handleDocumentExpired({ documentId: 'definitely-not-a-real-doc' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -170,7 +170,7 @@ describe('Pipeline Transitions', () => {
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta);
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'eoi_signed' }, meta);
|
||||
|
||||
const updated = await getInterestById(interestId, portId);
|
||||
expect(updated.dateEoiSigned).not.toBeNull();
|
||||
@@ -181,7 +181,7 @@ describe('Pipeline Transitions', () => {
|
||||
await import('@/lib/services/interests.service');
|
||||
const meta = makeAuditMeta({ portId });
|
||||
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta);
|
||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract_signed' }, meta);
|
||||
|
||||
const updated = await getInterestById(interestId, portId);
|
||||
expect(updated.dateContractSigned).not.toBeNull();
|
||||
|
||||
84
tests/integration/portal-auth.test.ts
Normal file
84
tests/integration/portal-auth.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Portal JWT verification — locks in the audience/issuer hardening shipped
|
||||
* in fix(auth): a token without `aud: 'portal'` + `iss: 'pn-crm'` claims
|
||||
* must NOT verify, even if it's signed with the correct shared secret.
|
||||
*
|
||||
* Without these claims the CRM (better-auth) and portal sessions are
|
||||
* structurally identical, so a portal token could be replayed against any
|
||||
* `verifyPortalToken` consumer (and vice versa).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SignJWT } from 'jose';
|
||||
|
||||
import { createPortalToken, verifyPortalToken } from '@/lib/portal/auth';
|
||||
|
||||
const SECRET = new TextEncoder().encode(process.env.BETTER_AUTH_SECRET);
|
||||
|
||||
const SESSION = {
|
||||
clientId: '11111111-1111-1111-1111-111111111111',
|
||||
portId: '22222222-2222-2222-2222-222222222222',
|
||||
email: 'client@example.com',
|
||||
};
|
||||
|
||||
describe('portal JWT', () => {
|
||||
it('round-trips a token signed with createPortalToken', async () => {
|
||||
const token = await createPortalToken(SESSION);
|
||||
const verified = await verifyPortalToken(token);
|
||||
expect(verified).toMatchObject(SESSION);
|
||||
});
|
||||
|
||||
it('rejects a token missing the `aud: portal` claim', async () => {
|
||||
// Issuer present, audience absent — exactly the shape an old (pre-fix)
|
||||
// portal session would have.
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuer('pn-crm')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token missing the `iss: pn-crm` claim', async () => {
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('portal')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong audience (CRM session replay shape)', async () => {
|
||||
// What a better-auth session token might roughly look like — same secret,
|
||||
// different audience. Must not verify against the portal path.
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('crm')
|
||||
.setIssuer('pn-crm')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a token with the wrong issuer', async () => {
|
||||
const token = await new SignJWT(SESSION as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setAudience('portal')
|
||||
.setIssuer('attacker')
|
||||
.setExpirationTime('24h')
|
||||
.setIssuedAt()
|
||||
.sign(SECRET);
|
||||
|
||||
expect(await verifyPortalToken(token)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage', async () => {
|
||||
expect(await verifyPortalToken('not.a.jwt')).toBeNull();
|
||||
expect(await verifyPortalToken('')).toBeNull();
|
||||
});
|
||||
});
|
||||
158
tests/unit/hooks/realtime-invalidation.test.ts
Normal file
158
tests/unit/hooks/realtime-invalidation.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
subscribeRealtimeInvalidations,
|
||||
type EventMap,
|
||||
type SocketLike,
|
||||
} from '@/hooks/realtime-invalidation-core';
|
||||
|
||||
/**
|
||||
* Pure-logic tests for the realtime-invalidation subscription helper. The
|
||||
* React hook (`useRealtimeInvalidation`) is just a thin wrapper around this
|
||||
* function — verifying the handler-registration / fire-time-lookup behavior
|
||||
* here is sufficient to lock in the bug fixes:
|
||||
* 1. Re-subscribe storm (caller passing inline literals)
|
||||
* 2. Fresh queryKeys read at fire-time
|
||||
*
|
||||
* The `useSocket` provider fix (don't null-context on disconnect) is verified
|
||||
* separately by manual smoke + the existing socket integration coverage.
|
||||
*/
|
||||
|
||||
function makeStubSocket() {
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
const onCalls: Array<{ event: string }> = [];
|
||||
const offCalls: Array<{ event: string }> = [];
|
||||
|
||||
const socket: SocketLike = {
|
||||
on(event, handler) {
|
||||
onCalls.push({ event });
|
||||
const arr = listeners.get(event) ?? [];
|
||||
arr.push(handler);
|
||||
listeners.set(event, arr);
|
||||
},
|
||||
off(event, handler) {
|
||||
offCalls.push({ event });
|
||||
const arr = listeners.get(event) ?? [];
|
||||
listeners.set(
|
||||
event,
|
||||
arr.filter((h) => h !== handler),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function emit(event: string, ...args: unknown[]) {
|
||||
for (const h of listeners.get(event) ?? []) h(...args);
|
||||
}
|
||||
|
||||
return { socket, emit, onCalls, offCalls, listeners };
|
||||
}
|
||||
|
||||
function makeStubQueryClient() {
|
||||
const calls: QueryKey[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: vi.fn(({ queryKey }: { queryKey: QueryKey }) => {
|
||||
calls.push(queryKey);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
} as unknown as QueryClient;
|
||||
return { queryClient, calls };
|
||||
}
|
||||
|
||||
describe('subscribeRealtimeInvalidations', () => {
|
||||
it('registers one .on() per event key', () => {
|
||||
const { socket, onCalls } = makeStubSocket();
|
||||
const { queryClient } = makeStubQueryClient();
|
||||
const map: EventMap = {
|
||||
'client:created': [['clients']],
|
||||
'client:updated': [['clients'], ['clients', 'abc']],
|
||||
};
|
||||
|
||||
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
|
||||
|
||||
expect(onCalls.map((c) => c.event).sort()).toEqual(['client:created', 'client:updated']);
|
||||
});
|
||||
|
||||
it('invalidates each queryKey for the matching event', () => {
|
||||
const { socket, emit } = makeStubSocket();
|
||||
const { queryClient, calls } = makeStubQueryClient();
|
||||
const map: EventMap = {
|
||||
'client:updated': [['clients'], ['clients', 'abc']],
|
||||
};
|
||||
|
||||
subscribeRealtimeInvalidations(socket, Object.keys(map), queryClient, () => map);
|
||||
emit('client:updated');
|
||||
|
||||
expect(calls).toEqual([['clients'], ['clients', 'abc']]);
|
||||
});
|
||||
|
||||
it('reads the LATEST eventMap at fire time, not at subscription time', () => {
|
||||
// This is the core of the re-subscribe-storm fix: callers can swap in a
|
||||
// new eventMap object without re-subscribing, and the handler still sees
|
||||
// the fresh queryKey list.
|
||||
const { socket, emit } = makeStubSocket();
|
||||
const { queryClient, calls } = makeStubQueryClient();
|
||||
|
||||
let currentMap: EventMap = {
|
||||
'client:updated': [['clients']],
|
||||
};
|
||||
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
|
||||
|
||||
// First fire: see the original map
|
||||
emit('client:updated');
|
||||
expect(calls).toEqual([['clients']]);
|
||||
|
||||
// Caller re-renders with a fresh literal that includes more queryKeys
|
||||
currentMap = {
|
||||
'client:updated': [['clients'], ['clients', 'abc']],
|
||||
};
|
||||
emit('client:updated');
|
||||
|
||||
expect(calls).toEqual([['clients'], ['clients'], ['clients', 'abc']]);
|
||||
});
|
||||
|
||||
it('cleanup deregisters every handler it registered', () => {
|
||||
const { socket, emit, offCalls, listeners } = makeStubSocket();
|
||||
const { queryClient, calls } = makeStubQueryClient();
|
||||
const map: EventMap = {
|
||||
'a:event': [['a']],
|
||||
'b:event': [['b']],
|
||||
};
|
||||
|
||||
const cleanup = subscribeRealtimeInvalidations(
|
||||
socket,
|
||||
Object.keys(map),
|
||||
queryClient,
|
||||
() => map,
|
||||
);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(offCalls.map((c) => c.event).sort()).toEqual(['a:event', 'b:event']);
|
||||
// All listeners removed — emitting after cleanup invalidates nothing.
|
||||
emit('a:event');
|
||||
emit('b:event');
|
||||
expect(calls).toEqual([]);
|
||||
// Defensive: the listener list should be empty after cleanup.
|
||||
expect(listeners.get('a:event')?.length ?? 0).toBe(0);
|
||||
expect(listeners.get('b:event')?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('silently ignores events that have no entry in the current map', () => {
|
||||
// If the caller swaps an event OUT mid-session, the registered handler
|
||||
// still fires (we don't re-subscribe) but should be a no-op rather than
|
||||
// throw.
|
||||
const { socket, emit } = makeStubSocket();
|
||||
const { queryClient, calls } = makeStubQueryClient();
|
||||
|
||||
let currentMap: EventMap = {
|
||||
'client:updated': [['clients']],
|
||||
};
|
||||
subscribeRealtimeInvalidations(socket, ['client:updated'], queryClient, () => currentMap);
|
||||
|
||||
// Wipe the entry — handler will fire but find nothing to invalidate.
|
||||
currentMap = {};
|
||||
expect(() => emit('client:updated')).not.toThrow();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -142,7 +142,7 @@ describe('calculateInterestScore', () => {
|
||||
portId: 'p1',
|
||||
clientId: 'c1',
|
||||
createdAt: daysAgo(10),
|
||||
pipelineStage: 'contract',
|
||||
pipelineStage: 'contract_signed',
|
||||
eoiStatus: 'signed',
|
||||
contractStatus: 'signed',
|
||||
depositStatus: 'received',
|
||||
|
||||
@@ -91,8 +91,9 @@ describe('buildDocumensoPayload', () => {
|
||||
});
|
||||
|
||||
it('defaults missing yacht dimensions to empty strings', () => {
|
||||
const baseYacht = makeContext().yacht!;
|
||||
const ctx = makeContext({
|
||||
yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null },
|
||||
yacht: { ...baseYacht, lengthFt: null, widthFt: null, draftFt: null },
|
||||
});
|
||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||
expect(payload.formValues.Length).toBe('');
|
||||
@@ -100,6 +101,16 @@ describe('buildDocumensoPayload', () => {
|
||||
expect(payload.formValues.Draft).toBe('');
|
||||
});
|
||||
|
||||
it('renders empty Section 3 when yacht and berth are not linked', () => {
|
||||
const ctx = makeContext({ yacht: null, berth: null });
|
||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||
expect(payload.formValues['Yacht Name']).toBe('');
|
||||
expect(payload.formValues.Length).toBe('');
|
||||
expect(payload.formValues.Width).toBe('');
|
||||
expect(payload.formValues.Draft).toBe('');
|
||||
expect(payload.formValues['Berth Number']).toBe('');
|
||||
});
|
||||
|
||||
it('formats empty address when client has no address', () => {
|
||||
const ctx = makeContext({ client: { ...makeContext().client, address: null } });
|
||||
const payload = buildDocumensoPayload(ctx, OPTIONS);
|
||||
|
||||
@@ -28,6 +28,33 @@ async function insertInterest(args: {
|
||||
return row!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the EOI-required client details (primary email + primary address) so
|
||||
* `buildEoiContext` clears its hard gate. Tests that exercise non-EOI-gating
|
||||
* behavior should call this once per client they create.
|
||||
*/
|
||||
async function seedClientEoiPrereqs(args: {
|
||||
clientId: string;
|
||||
portId: string;
|
||||
email?: string;
|
||||
street?: string;
|
||||
}) {
|
||||
await db.insert(clientContacts).values({
|
||||
clientId: args.clientId,
|
||||
channel: 'email',
|
||||
value: args.email ?? `client-${args.clientId.slice(0, 8)}@example.com`,
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId: args.clientId,
|
||||
portId: args.portId,
|
||||
streetAddress: args.street ?? '1 Harbour Way',
|
||||
city: 'Anguilla',
|
||||
countryIso: 'AI',
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildEoiContext', () => {
|
||||
@@ -107,13 +134,13 @@ describe('buildEoiContext', () => {
|
||||
});
|
||||
|
||||
// Yacht assertions.
|
||||
expect(ctx.yacht.name).toBe('Sea Breeze');
|
||||
expect(ctx.yacht.hullNumber).toBe('HN-1');
|
||||
expect(ctx.yacht.yearBuilt).toBe(2020);
|
||||
expect(ctx.yacht?.name).toBe('Sea Breeze');
|
||||
expect(ctx.yacht?.hullNumber).toBe('HN-1');
|
||||
expect(ctx.yacht?.yearBuilt).toBe(2020);
|
||||
|
||||
// Berth assertions.
|
||||
expect(ctx.berth.mooringNumber).toBe('M-42');
|
||||
expect(ctx.berth.area).toBe('North');
|
||||
expect(ctx.berth?.mooringNumber).toBe('M-42');
|
||||
expect(ctx.berth?.area).toBe('North');
|
||||
|
||||
// Interest assertions.
|
||||
expect(ctx.interest.stage).toBe('in_communication');
|
||||
@@ -144,6 +171,7 @@ describe('buildEoiContext', () => {
|
||||
portId: port.id,
|
||||
overrides: { fullName: 'Bob Contact' },
|
||||
});
|
||||
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||
|
||||
const yacht = await makeYacht({
|
||||
portId: port.id,
|
||||
@@ -187,6 +215,7 @@ describe('buildEoiContext', () => {
|
||||
});
|
||||
|
||||
const client = await makeClient({ portId: port.id });
|
||||
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||
const yacht = await makeYacht({
|
||||
portId: port.id,
|
||||
ownerType: 'company',
|
||||
@@ -211,9 +240,10 @@ describe('buildEoiContext', () => {
|
||||
expect(ctx.company!.billingAddress).toContain('Anguilla');
|
||||
});
|
||||
|
||||
it('throws ValidationError when interest has no yacht', async () => {
|
||||
it('builds a valid context when yacht is missing (Section 3 left blank)', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||
const berth = await makeBerth({ portId: port.id });
|
||||
|
||||
const interest = await insertInterest({
|
||||
@@ -223,13 +253,18 @@ describe('buildEoiContext', () => {
|
||||
berthId: berth.id,
|
||||
});
|
||||
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no yacht/i);
|
||||
const ctx = await buildEoiContext(interest.id, port.id);
|
||||
expect(ctx.yacht).toBeNull();
|
||||
expect(ctx.berth?.mooringNumber).toBe(berth.mooringNumber);
|
||||
// Owner falls back to the interest's client when no yacht is linked.
|
||||
expect(ctx.owner.type).toBe('client');
|
||||
expect(ctx.owner.name).toBe(client.fullName);
|
||||
});
|
||||
|
||||
it('throws ValidationError when interest has no berth', async () => {
|
||||
it('builds a valid context when berth is missing (Section 3 left blank)', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
await seedClientEoiPrereqs({ clientId: client.id, portId: port.id });
|
||||
const yacht = await makeYacht({
|
||||
portId: port.id,
|
||||
ownerType: 'client',
|
||||
@@ -243,8 +278,45 @@ describe('buildEoiContext', () => {
|
||||
berthId: null,
|
||||
});
|
||||
|
||||
const ctx = await buildEoiContext(interest.id, port.id);
|
||||
expect(ctx.berth).toBeNull();
|
||||
expect(ctx.yacht?.name).toBe(yacht.name);
|
||||
});
|
||||
|
||||
it('throws ValidationError when client has no email', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
// Address only, no email — gate should fail.
|
||||
await db.insert(clientAddresses).values({
|
||||
clientId: client.id,
|
||||
portId: port.id,
|
||||
streetAddress: '1 Harbour Way',
|
||||
city: 'Anguilla',
|
||||
countryIso: 'AI',
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const interest = await insertInterest({ portId: port.id, clientId: client.id });
|
||||
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/interest has no berth/i);
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client email/i);
|
||||
});
|
||||
|
||||
it('throws ValidationError when client has no primary address', async () => {
|
||||
const port = await makePort();
|
||||
const client = await makeClient({ portId: port.id });
|
||||
// Email only, no address — gate should fail.
|
||||
await db.insert(clientContacts).values({
|
||||
clientId: client.id,
|
||||
channel: 'email',
|
||||
value: 'test@example.com',
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const interest = await insertInterest({ portId: port.id, clientId: client.id });
|
||||
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(ValidationError);
|
||||
await expect(buildEoiContext(interest.id, port.id)).rejects.toThrow(/client address/i);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for non-existent interest', async () => {
|
||||
|
||||
Reference in New Issue
Block a user