chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped

Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:57:01 +02:00
parent d62822c284
commit 8699f81879
225 changed files with 844 additions and 845 deletions

View File

@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)', label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.', description: 'Appended to the bottom of system-generated emails.',
type: 'html', type: 'html',
placeholder: '<p><br>The Port Nimara team</p>', placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '', defaultValue: '',
}, },
{ {
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{ {
key: 'smtp_pass_override', key: 'smtp_pass_override',
label: 'SMTP password override', label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.', description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password', type: 'password',
defaultValue: '', defaultValue: '',
}, },

View File

@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
/** /**
* Route-level loading UI for the client detail page. Renders while the * Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps * server component resolves the session and the client component bootstraps
* its initial query replaces the previous empty-header flash on direct * its initial query - replaces the previous empty-header flash on direct
* URL visits. * URL visits.
*/ */
export default function Loading() { export default function Loading() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header strip title, badges, action buttons */} {/* 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="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" /> <Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]); }, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`, // When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} Berth {n}" in // fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill. // the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{ const { data: prefilledInterest } = useQuery<{
data: { data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header desktop only; mobile gets the title from the topbar */} {/* Header - desktop only; mobile gets the title from the topbar */}
<div className="hidden sm:flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data {prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${ ? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber prefilledInterest.data.berthMooringNumber
? ` Berth ${prefilledInterest.data.berthMooringNumber}` ? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: '' : ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".` }. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'} : 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>
<RealtimeToasts /> <RealtimeToasts />
{/* Desktop shell hidden by CSS on mobile */} {/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background"> <div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
portRoles={portRoles} portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email, name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email, email: session.user.email,
}} }}
ports={ports}
/> />
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar <Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email, email: session.user.email,
}} }}
/> />
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main> <main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
{children}
</main>
</div> </div>
</div> </div>
{/* Mobile shell hidden by CSS on desktop */} {/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout> <MobileLayout>{children}</MobileLayout>
</SocketProvider> </SocketProvider>
</PermissionsProvider> </PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
}, },
}; };
export default async function PortalLayout({ export default async function PortalLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
// This layout wraps all portal routes including login/verify // This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt // We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down login/verify pages handle their own // to get the session and pass it down - login/verify pages handle their own
// redirect logic independently. // redirect logic independently.
const session = await getPortalSession().catch(() => null); const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{session && ( {session && (
<> <>
<PortalHeader <PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalNav /> <PortalNav />
</> </>
)} )}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}> <main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
{children}
</main>
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm <PasswordSetForm
endpoint="/api/portal/auth/activate" endpoint="/api/portal/auth/activate"
title="Activate your account" title="Activate your account"
description="Welcome choose a password to finish setting up your client portal account." description="Welcome - choose a password to finish setting up your client portal account."
successTitle="Account activated" successTitle="Account activated"
successDescription="You can now sign in with your new password." successDescription="You can now sign in with your new password."
submitLabel="Activate account" submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
// Always returns 200 caller never sees whether email exists. // Always returns 200 - caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', { await fetch('/api/portal/auth/forgot-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
<span className="font-medium text-gray-900">General Interest</span> <span className="font-medium text-gray-900">General Interest</span>
)} )}
{interest.berthArea && ( {interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span> <span className="text-sm text-gray-400">- {interest.berthArea}</span>
)} )}
</div> </div>
{interest.leadCategory && ( {interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span> <span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && ( {r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span> <span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
)} )}
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">

View File

@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const manifest = { const manifest = {
name: `${portName} Scanner`, name: `${portName} - Scanner`,
short_name: 'Scanner', short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`, description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`, start_url: `/${portSlug}/scan`,

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell'; import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt Port Nimara', title: 'Scan receipt - Port Nimara',
}; };
export default function ScanPage() { export default function ScanPage() {

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
/** /**
* Liveness probe confirms the Next.js process is responding. * Liveness probe - confirms the Next.js process is responding.
* *
* Returns 200 unconditionally; if the process is wedged or has crashed * Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO * the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint a transient downstream blip should drop the * checks in this endpoint - a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness). * pod from the load balancer (readiness), not restart the pod (liveness).
* *
* For deep dependency checks, hit `/api/ready` instead. * For deep dependency checks, hit `/api/ready` instead.

View File

@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
// Keep the helper aligned with that. // Keep the helper aligned with that.
type Tx = typeof db; type Tx = typeof db;
// POST /api/public/interests unauthenticated public interest registration. // POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company + // Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction. // membership, all inside a single transaction.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup safe // Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction. // to do outside the transaction.
let berthId: string | null = null; let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null; let resolvedMooringNumber: string | null = data.mooringNumber ?? null;

View File

@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
} }
/** /**
* POST /api/public/residential-inquiries unauthenticated entry point for * POST /api/public/residential-inquiries - unauthenticated entry point for
* the public website's residential interest form. Creates a * the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a * `residential_clients` row and an opening `residential_interests` row in a
* single transaction. * single transaction.
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking failures shouldn't 500 the // Send notification emails (non-blocking - failures shouldn't 500 the
// public form). // public form).
void sendResidentialNotifications({ void sendResidentialNotifications({
portId, portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
}); });
await sendEmail(data.email, confirmation.subject, confirmation.html); await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert pull recipients from system_settings if configured; // Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available. // fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({ const recipientsRow = await db.query.systemSettings.findFirst({
where: and( where: and(

View File

@@ -21,7 +21,7 @@ interface ReadyResponse {
} }
/** /**
* Readiness probe verifies that every backing service this process * Readiness probe - verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the * needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a * load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for). * pod restart (that's what `/api/health` is for).

View File

@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
* exercised by the realapi socket fanout test. * exercised by the realapi socket fanout test.
* *
* Requires super_admin or per-port admin permissions; the engine itself * Requires super_admin or per-port admin permissions; the engine itself
* is idempotent duplicate runs only re-evaluate, never duplicate rows. * is idempotent - duplicate runs only re-evaluate, never duplicate rows.
*/ */
export const POST = withAuth(async (_req, ctx) => { export const POST = withAuth(async (_req, ctx) => {
try { try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { checkDocumensoHealth } from '@/lib/services/documenso-client'; import { checkDocumensoHealth } from '@/lib/services/documenso-client';
/** /**
* Admin probe calls Documenso /api/v1/health using the port's effective * Admin probe - calls Documenso /api/v1/health using the port's effective
* config. Used by the "Test connection" button on /admin/documenso. * config. Used by the "Test connection" button on /admin/documenso.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
.map((p) => { .map((p) => {
const a = clientById.get(p.clientAId); const a = clientById.get(p.clientAId);
const b = clientById.get(p.clientBId); const b = clientById.get(p.clientBId);
if (!a || !b) return null; // FK orphan shouldn't happen, but be defensive if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
// Skip pairs where one side has already been merged or archived. // Skip pairs where one side has already been merged or archived.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null; if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return { return {

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth( export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => { withPermission('admin', 'manage_users', async (_req, ctx) => {
try { try {
// crm_user_invites is a global table (no per-port column) invites // crm_user_invites is a global table (no per-port column) - invites
// mint better-auth users that may later be assigned roles in any // mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director // port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags // enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
apiKey: z.string().min(1), apiKey: z.string().min(1),
}); });
// `manage_settings`-gated for parity with the parent OCR settings route // `manage_settings`-gated for parity with the parent OCR settings route -
// triggers outbound AI provider auth requests using a caller-supplied key. // triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth( export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => { withPermission('admin', 'manage_settings', async (req) => {

View File

@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
* POST /api/v1/admin/templates/preview * POST /api/v1/admin/templates/preview
* *
* Generates a preview PDF from a TipTap JSON content block. * Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } the client can render this * Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab. * in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
* *
* Body: * Body:
* content: TipTap JSON document * content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions * sampleData?: Record<string, string> - variable substitutions
*/ */
export const POST = withAuth( export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => { withPermission('documents', 'manage', async (req, _ctx) => {
@@ -60,10 +60,7 @@ export const POST = withAuth(
/** /**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc. * Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/ */
function substituteInDoc( function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
if (node.type === 'text' && node.text) { if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) }; return { ...node, text: substituteVariables(node.text, data) };
} }

View File

@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers'; import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', getHandler)); export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper the required permission depends // PATCH cannot use `withPermission` wrapper - the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the // on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed. // handler after the body is parsed.
export const PATCH = withAuth(patchHandler); export const PATCH = withAuth(patchHandler);

View File

@@ -40,7 +40,7 @@ export const PUT = withAuth(
}), }),
); );
// PATCH /api/v1/berths/[id]/waiting-list reorder a single entry // PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => { withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try { try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service'; import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options lightweight list for selects/comboboxes // GET /api/v1/berths/options - lightweight list for selects/comboboxes
export const GET = withAuth( export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => { withPermission('berths', 'view', async (req, ctx) => {
try { try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
* *
* Admin creates a portal account for a client and triggers the activation * Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the * email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 the admin can resend the activation via * email, returns 409 - the admin can resend the activation via
* ?action=resend. * ?action=resend.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
const nameResult = rawName ? normalizeName(rawName) : null; const nameResult = rawName ? normalizeName(rawName) : null;
// If the caller didn't give us anything useful to match on, return empty // If the caller didn't give us anything useful to match on, return empty
// short-circuit rather than scan every client for nothing. // - short-circuit rather than scan every client for nothing.
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) { if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
return NextResponse.json({ data: [] }); return NextResponse.json({ data: [] });
} }
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
mediumScore: 50, mediumScore: 50,
}); });
// Only return medium+ low-confidence noise isn't useful at the // Only return medium+ - low-confidence noise isn't useful at the
// create-form layer (background scoring queue picks those up). // create-form layer (background scoring queue picks those up).
const useful = matches.filter((m) => m.confidence !== 'low'); const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) { if (useful.length === 0) {

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service'; import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({ const mergeSchema = z.object({
/** Surviving expense id typically the row's existing `duplicateOf` pointer. */ /** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1), targetId: z.string().min(1),
}); });

View File

@@ -51,7 +51,7 @@ export const POST = withAuth(
}); });
} }
// Per-port budget gate refuse the call before we spend tokens // Per-port budget gate - refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request // when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the // would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking. // success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
}); });
} catch (err) { } catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed'); logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup degrade to manual entry rather than 500-ing. // Provider hiccup - degrade to manual entry rather than 500-ing.
return NextResponse.json({ return NextResponse.json({
data: { data: {
parsed: EMPTY, parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try { try {
const body = await parseBody(req, createFolderSchema); const body = await parseBody(req, createFolderSchema);
// Sanitize path no null bytes, no path traversal // Sanitize path - no null bytes, no path traversal
const safePath = body.path const safePath = body.path
.replace(/\x00/g, '') .replace(/\x00/g, '')
.replace(/\.\.\//g, '') .replace(/\.\.\//g, '')

View File

@@ -20,7 +20,7 @@ export const GET = withAuth(
}), }),
); );
// POST /api/v1/interests/[id]/recommendations add manual recommendation // POST /api/v1/interests/[id]/recommendations - add manual recommendation
export const POST = withAuth( export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => { withPermission('interests', 'edit', async (req, ctx, params) => {
try { try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = { const OUTCOME_LABELS: Record<string, string> = {
won: 'Won', won: 'Won',
lost_other_marina: 'Lost went to another marina', lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost unqualified', lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost no response', lost_no_response: 'Lost - no response',
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? ''; const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed'; const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? ''; const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label} ${reason}` : `Marked as ${label}`; return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
} }
if (type === 'outcome_cleared') { if (type === 'outcome_cleared') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? ''; const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system'; const auto = userId === 'system';
if (auto) { if (auto) {
return reason ? `${stage} (auto-advanced ${reason})` : `Stage advanced to ${stage}`; return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
} }
return reason ? `Stage changed to ${stage} ${reason}` : `Stage changed to ${stage}`; return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
} }
if (action === 'update' && newValue?.pipelineStage) { if (action === 'update' && newValue?.pipelineStage) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
const results = await search(ctx.portId, q); const results = await search(ctx.portId, q);
// Fire-and-forget do not await // Fire-and-forget - do not await
saveRecentSearch(ctx.userId, ctx.portId, q); saveRecentSearch(ctx.userId, ctx.portId, q);
return NextResponse.json(results); return NextResponse.json(results);

View File

@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}); });
if (existing) { if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook skipping'); logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 }); return NextResponse.json({ ok: true }, { status: 200 });
} }
} catch (err) { } catch (err) {

View File

@@ -93,7 +93,7 @@
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
} }
/* Wave watermark subtle background texture for auth pages */ /* Wave watermark - subtle background texture for auth pages */
.wave-watermark { .wave-watermark {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
135deg, 135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback * from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs. * handles desktop browsers resized below lg (1024px), or stripped UAs.
* *
* IMPORTANT: only `display: none` rules are emitted we never set a positive * IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would * display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade). * be overridden by `display: block` (same specificity, later cascade).
*/ */
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
display: none !important; display: none !important;
} }
} }
/*
* Recharts focus-ring suppression.
*
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
* them (the library adds tabindex on chart sectors / paths). The global
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
* which on a chart surface reads as a stray rectangle around the plot
* area. Hover/tooltip already handles chart interactivity, so suppress
* the ring entirely here.
*
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
* it during purge (an earlier copy inside `@layer base` was being
* silently removed at build time, leaving the ring intact).
*/
div.recharts-wrapper:focus,
div.recharts-wrapper:focus-visible,
svg.recharts-surface:focus,
svg.recharts-surface:focus-visible,
div.recharts-responsive-container:focus,
div.recharts-responsive-container:focus-visible,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
outline: none !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
--tw-ring-color: transparent !important;
--tw-ring-offset-color: transparent !important;
}

View File

@@ -87,7 +87,7 @@ export function AuditLogList() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Filter state debounce text inputs. // Filter state - debounce text inputs.
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all'); const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all'); const [action, setAction] = useState<string>('all');
@@ -215,7 +215,7 @@ export function AuditLogList() {
</span> </span>
); );
} }
return <span className="text-xs text-muted-foreground"></span>; return <span className="text-xs text-muted-foreground">-</span>;
}, },
}, },
{ {
@@ -245,7 +245,7 @@ export function AuditLogList() {
<PageHeader <PageHeader
title="Audit Log" title="Audit Log"
eyebrow="Admin" eyebrow="Admin"
description="Every state change in this port fully searchable." description="Every state change in this port - fully searchable."
variant="gradient" variant="gradient"
/> />

View File

@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
open,
onOpenChange,
field,
onSuccess,
}: CustomFieldFormProps) {
const isEdit = !!field; const isEdit = !!field;
// Form state // Form state
@@ -72,9 +67,7 @@ export function CustomFieldForm({
const [fieldName, setFieldName] = useState(field?.fieldName ?? ''); const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? ''); const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text'); const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
const [selectOptions, setSelectOptions] = useState<string[]>( const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
field?.selectOptions ?? [],
);
const [newOption, setNewOption] = useState(''); const [newOption, setNewOption] = useState('');
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false); const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0); const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
@@ -169,13 +162,11 @@ export function CustomFieldForm({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2"> <form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Entity Type create only */} {/* Entity Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-entity-type">Entity Type</Label> <Label htmlFor="cf-entity-type">Entity Type</Label>
{isEdit ? ( {isEdit ? (
@@ -198,7 +189,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Field Name create only */} {/* Field Name - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-name"> <Label htmlFor="cf-field-name">
Field Name Field Name
@@ -232,7 +223,7 @@ export function CustomFieldForm({
/> />
</div> </div>
{/* Field Type create only */} {/* Field Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-type">Field Type</Label> <Label htmlFor="cf-field-type">Field Type</Label>
{isEdit ? ( {isEdit ? (
@@ -260,7 +251,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Select Options visible when fieldType = 'select' */} {/* Select Options - visible when fieldType = 'select' */}
{fieldType === 'select' && ( {fieldType === 'select' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Options</Label> <Label>Options</Label>
@@ -302,11 +293,7 @@ export function CustomFieldForm({
{/* Is Required */} {/* Is Required */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="cf-is-required">Required field</Label> <Label htmlFor="cf-is-required">Required field</Label>
<Switch <Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
id="cf-is-required"
checked={isRequired}
onCheckedChange={setIsRequired}
/>
</div> </div>
{/* Sort Order */} {/* Sort Order */}

View File

@@ -11,13 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -61,20 +55,13 @@ interface TemplateFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function TemplateForm({ export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
open,
onOpenChange,
template,
onSuccess,
}: TemplateFormProps) {
const isEdit = !!template; const isEdit = !!template;
const [name, setName] = useState(template?.name ?? ''); const [name, setName] = useState(template?.name ?? '');
const [type, setType] = useState(template?.templateType ?? 'other'); const [type, setType] = useState(template?.templateType ?? 'other');
const [contentJson, setContentJson] = useState( const [contentJson, setContentJson] = useState(
template?.content template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
? JSON.stringify(template.content, null, 2)
: EMPTY_DOC,
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -86,7 +73,7 @@ export function TemplateForm({
setJsonError(null); setJsonError(null);
return true; return true;
} catch { } catch {
setJsonError('Invalid JSON check syntax.'); setJsonError('Invalid JSON - check syntax.');
return false; return false;
} }
} }
@@ -115,8 +102,7 @@ export function TemplateForm({
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (err: unknown) { } catch (err: unknown) {
const message = const message = err instanceof Error ? err.message : 'Something went wrong';
err instanceof Error ? err.message : 'Something went wrong';
setError(message); setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -127,9 +113,7 @@ export function TemplateForm({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl"> <SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
{isEdit ? 'Edit Template' : 'New Document Template'}
</SheetTitle>
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6"> <form onSubmit={handleSubmit} className="mt-6 space-y-6">
@@ -145,7 +129,7 @@ export function TemplateForm({
/> />
</div> </div>
{/* Type only on create */} {/* Type - only on create */}
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-type">Document Type</Label> <Label htmlFor="template-type">Document Type</Label>
@@ -166,15 +150,11 @@ export function TemplateForm({
{/* TipTap JSON Content */} {/* TipTap JSON Content */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-content"> <Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
Document Content (TipTap JSON)
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Paste or edit TipTap JSON. Use{' '} Paste or edit TipTap JSON. Use{' '}
<code className="rounded bg-muted px-1 text-xs"> <code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
{'{{variable.key}}'} dynamic content.
</code>{' '}
tokens for dynamic content.
</p> </p>
<textarea <textarea
id="template-content" id="template-content"
@@ -187,9 +167,7 @@ export function TemplateForm({
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false} spellCheck={false}
/> />
{jsonError && ( {jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
<p className="text-xs text-destructive">{jsonError}</p>
)}
</div> </div>
{/* Available Variables Reference */} {/* Available Variables Reference */}
@@ -200,19 +178,15 @@ export function TemplateForm({
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2"> <div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
{TEMPLATE_VARIABLES.map((v) => ( {TEMPLATE_VARIABLES.map((v) => (
<div key={v.key} className="text-xs"> <div key={v.key} className="text-xs">
<code className="rounded bg-muted px-1"> <code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
{`{{${v.key}}}`} <span className="text-muted-foreground">- {v.label}</span>
</code>{' '}
<span className="text-muted-foreground"> {v.label}</span>
</div> </div>
))} ))}
</div> </div>
</details> </details>
{error && ( {error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive"> <p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
{error}
</p>
)} )}
<SheetFooter> <SheetFooter>
@@ -225,11 +199,7 @@ export function TemplateForm({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading || !!jsonError}> <Button type="submit" disabled={loading || !!jsonError}>
{loading {loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
? 'Saving…'
: isEdit
? 'Save Changes'
: 'Create Template'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</form> </form>

View File

@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TemplateForm } from './template-form'; import { TemplateForm } from './template-form';
import { TemplateVersionHistory } from './template-version-history'; import { TemplateVersionHistory } from './template-version-history';
@@ -57,9 +52,7 @@ export function TemplateList() {
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await apiFetch<{ data: AdminTemplate[] }>( const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
'/api/v1/admin/templates',
);
setTemplates(res.data); setTemplates(res.data);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -122,9 +115,7 @@ export function TemplateList() {
accessorKey: 'version', accessorKey: 'version',
header: 'Version', header: 'Version',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">v{row.original.version}</span>
v{row.original.version}
</span>
), ),
}, },
{ {
@@ -151,10 +142,7 @@ export function TemplateList() {
header: '', header: '',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<TemplatePreview <TemplatePreview content={row.original.content} templateName={row.original.name} />
content={row.original.content}
templateName={row.original.name}
/>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -177,9 +165,7 @@ export function TemplateList() {
title={row.original.isActive ? 'Deactivate' : 'Activate'} title={row.original.isActive ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(row.original)} onClick={() => handleToggleActive(row.original)}
> >
<span className="text-xs"> <span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
{row.original.isActive ? 'Off' : 'On'}
</span>
</Button> </Button>
<ConfirmationDialog <ConfirmationDialog
trigger={ trigger={
@@ -233,9 +219,7 @@ export function TemplateList() {
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}> <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto"> <SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
Version History {historyTemplate?.name}
</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-6"> <div className="mt-6">
{historyTemplate && ( {historyTemplate && (

View File

@@ -3,12 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye, ExternalLink } from 'lucide-react'; import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Build sample data from TEMPLATE_VARIABLES examples // Build sample data from TEMPLATE_VARIABLES examples
const sampleData = Object.fromEntries( const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
);
async function handlePreview() { async function handlePreview() {
if (!content) { if (!content) {
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle>Preview {templateName}</DialogTitle> <DialogTitle>Preview - {templateName}</DialogTitle>
{pdfBase64 && ( {pdfBase64 && (
<Button <Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="mr-6"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" /> <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open in new tab Open in new tab
</Button> </Button>
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
)} )}
{error && !loading && ( {error && !loading && (
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive"> <div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
{error}
</div>
)} )}
{pdfBase64 && !loading && ( {pdfBase64 && !loading && (

View File

@@ -117,7 +117,7 @@ export function InvitationsManager() {
{invites.map((i) => ( {invites.map((i) => (
<tr key={i.id} className="border-t"> <tr key={i.id} className="border-t">
<td className="px-3 py-2 font-medium">{i.email}</td> <td className="px-3 py-2 font-medium">{i.email}</td>
<td className="px-3 py-2 text-muted-foreground">{i.name ?? ''}</td> <td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.isSuperAdmin ? 'Super admin' : 'Standard user'} {i.isSuperAdmin ? 'Super admin' : 'Standard user'}
</td> </td>
@@ -163,7 +163,7 @@ export function InvitationsManager() {
)} )}
</div> </div>
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground">-</span>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
Enable AI receipt parsing for this port Enable AI receipt parsing for this port
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Off by default. Receipts are read on-device using Tesseract.js accurate enough for Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
most receipts and incurs no AI cost. Turning this on lets the configured provider most receipts and incurs no AI cost. Turning this on lets the configured provider
re-parse receipts server-side for higher accuracy on hard-to-read images. re-parse receipts server-side for higher accuracy on hard-to-read images.
</p> </p>
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
id={`apiKey-${scope}`} id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
autoComplete="off" autoComplete="off"
placeholder={hasKey ? '•••••• (saved leave blank to keep)' : 'sk-…'} placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
value={apiKey} value={apiKey}
onChange={(e) => { onChange={(e) => {
setApiKey(e.target.value); setApiKey(e.target.value);

View File

@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
}; };
function formatDate(ts: number | undefined): string { function formatDate(ts: number | undefined): string {
if (!ts) return ''; if (!ts) return '-';
return new Date(ts).toLocaleString(); return new Date(ts).toLocaleString();
} }
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
} }
function truncateReason(reason: string | undefined): string { function truncateReason(reason: string | undefined): string {
if (!reason) return ''; if (!reason) return '-';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason; return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
} }
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span> <span>
{total} total jobs page {page} of {totalPages} {total} total jobs - page {page} of {totalPages}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button

View File

@@ -95,7 +95,7 @@ export function RoleList() {
accessorKey: 'description', accessorKey: 'description',
header: 'Description', header: 'Description',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? ''}</span> <span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
), ),
}, },
{ {

View File

@@ -363,7 +363,7 @@ export function SettingsManager() {
); );
void saveSetting(setting.key, parsed); void saveSetting(setting.key, parsed);
} catch { } catch {
// invalid JSON do nothing // invalid JSON - do nothing
} }
}} }}
> >

View File

@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Email subtitle only when display name is shown as title */} {/* Email subtitle - only when display name is shown as title */}
{user.displayName && user.displayName !== user.email ? ( {user.displayName && user.displayName !== user.email ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground"> <p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden /> <Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />

View File

@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableRow key={d.id}> <TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell> <TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell> <TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}> <Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
</TableCell> </TableCell>
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell> <TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{d.deliveredAt {d.deliveredAt

View File

@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() { export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Count is cheap (one aggregate query) fire on every page so the badge stays live. // Count is cheap (one aggregate query) - fire on every page so the badge stays live.
// List is heavier only fetch when the popover is actually open. // List is heavier - only fetch when the popover is actually open.
const { data: count } = useAlertCount(); const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open); const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime(); useAlertRealtime();

View File

@@ -22,11 +22,10 @@ export function AlertRail() {
<section <section
data-testid="alert-rail" data-testid="alert-rail"
aria-label="Active alerts" aria-label="Active alerts"
// `h-full` is intentional only at xl: where the parent dashboard grid // Natural height - the parent aside no longer forces 100% of the
// gives this rail a sibling column whose height it should match. On // dashboard grid row, so the rail can sit compactly under Reminders
// mobile (single-column stack) there's no fixed-height context, so // without bleeding down into the Recent Activity panel below.
// forcing 100% height makes the section overflow / look stretched. className="flex flex-col gap-3"
className="flex flex-col gap-3 xl:h-full"
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2> <h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
@@ -57,7 +56,7 @@ export function AlertRail() {
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)} href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent" className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
> >
+{overflow} more view all +{overflow} more - view all
</Link> </Link>
) : null} ) : null}
</div> </div>

View File

@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'mooringNumber', accessorKey: 'mooringNumber',
header: 'Mooring #', header: 'Mooring #',
cell: ({ row }) => ( cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
<span className="font-medium">{row.original.mooringNumber}</span>
),
}, },
{ {
accessorKey: 'area', accessorKey: 'area',
header: 'Area', header: 'Area',
cell: ({ row }) => row.original.area ?? '', cell: ({ row }) => row.original.area ?? '-',
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const { lengthM, widthM } = row.original; const { lengthM, widthM } = row.original;
if (!lengthM && !widthM) return ''; if (!lengthM && !widthM) return '-';
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`; return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
}, },
}, },
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Price', header: 'Price',
cell: ({ row }) => { cell: ({ row }) => {
const { price, priceCurrency } = row.original; const { price, priceCurrency } = row.original;
if (!price) return ''; if (!price) return '-';
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: priceCurrency || 'USD', currency: priceCurrency || 'USD',
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'tenureType', accessorKey: 'tenureType',
header: 'Tenure', header: 'Tenure',
cell: ({ row }) => cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
}, },
{ {
id: 'tags', id: 'tags',

View File

@@ -93,7 +93,7 @@ function SelectOrEmpty({
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NONE}></SelectItem> <SelectItem value={NONE}>-</SelectItem>
{options.map((opt) => ( {options.map((opt) => (
<SelectItem key={opt} value={opt}> <SelectItem key={opt} value={opt}>
{opt} {opt}

View File

@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
href={`/${portSlug}/interests/${i.id}` as never} href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand" className="hover:text-brand"
> >
{i.clientName ?? ''} {i.clientName ?? '-'}
</Link> </Link>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : ''} {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : ''} {i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
</td> </td>
<td className="px-3 py-2 text-xs text-muted-foreground"> <td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()} {new Date(i.createdAt).toLocaleDateString()}

View File

@@ -36,7 +36,7 @@ export function BerthList() {
title="Berths" title="Berths"
description="View and manage berth allocations" description="View and manage berth allocations"
variant="gradient" variant="gradient"
// No "New" button berths are import-only // No "New" button - berths are import-only
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">

View File

@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sales pulse top-of-page so reps doing berth-level triage can see {/* Sales pulse - top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */} who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} /> <BerthInterestPulse berthId={berth.id} />

View File

@@ -70,7 +70,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary); const primary = row.original.contacts?.find((c) => c.isPrimary);
if (!primary) return <span className="text-muted-foreground"></span>; if (!primary) return <span className="text-muted-foreground">-</span>;
return ( return (
<span className="text-sm"> <span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span> <span className="text-muted-foreground capitalize">{primary.channel}: </span>
@@ -86,7 +86,7 @@ export function getClientColumns({
cell: ({ getValue }) => { cell: ({ getValue }) => {
const iso = getValue() as string | null; const iso = getValue() as string | null;
return ( return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : ''}</span> <span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
); );
}, },
}, },
@@ -96,7 +96,7 @@ export function getClientColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>; if (!source) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source} {SOURCE_LABELS[source] ?? source}
@@ -111,7 +111,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.yachtCount ?? 0; const c = row.original.yachtCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -126,7 +126,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.companyCount ?? 0; const c = row.original.companyCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -140,7 +140,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const clientTags = row.original.tags ?? []; const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground"></span>; if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{clientTags.slice(0, 3).map((tag) => ( {clientTags.slice(0, 3).map((tag) => (

View File

@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
function formatSince(startDate: string | Date): string { function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate; const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return ''; if (Number.isNaN(d.getTime())) return '-';
return format(d, 'MMM d, yyyy'); return format(d, 'MMM d, yyyy');
} }
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">

View File

@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
{/* Top-right: archive/restore as a small icon button destructive {/* Top-right: archive/restore as a small icon button - destructive
action sits out of the primary action flow. */} action sits out of the primary action flow. */}
<button <button
type="button" type="button"

View File

@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6"> <form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
{/* Dedup suggestion only on the create path. Watches the {/* Dedup suggestion - only on the create path. Watches the
live form values for email / phone / name and surfaces live form values for email / phone / name and surfaces
an existing client when one matches. The user can an existing client when one matches. The user can
attach the new interest to that client instead of attach the new interest to that client instead of

View File

@@ -180,7 +180,7 @@ function InterestPreviewDrawer({
}) { }) {
// Pin the most recently selected interest so the drawer stays populated // Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms // during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here the guard // after `open=false`). Conditional setState is safe here - the guard
// ensures it only fires when the prop actually changes to a new row. // ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest); const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest); if (interest && interest !== pinned) setPinned(interest);
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
</DrawerHeader> </DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4"> <div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar the same primitive used on the {/* Pipeline-stepper segmented bar - the same primitive used on the
row card, so the at-a-glance progress hint is consistent row card, so the at-a-glance progress hint is consistent
across surfaces. */} across surfaces. */}
{stage ? ( {stage ? (
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
</div> </div>
) : null} ) : null}
{/* Milestones three sections matching the full interest detail {/* Milestones - three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step renders correctly. The full milestone columns + per-step
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
</div> </div>
</section> </section>
{/* Compact key/value pairs lead category, source, last contact, {/* Compact key/value pairs - lead category, source, last contact,
activity. Each row collapses cleanly when its value is activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full missing so the drawer scales from sparse seed data to full
records without empty placeholders. */} records without empty placeholders. */}

View File

@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
interface PipelineSummaryProps { interface PipelineSummaryProps {
clientId: string; clientId: string;
/** /**
* `hero` single-line pulse for the detail header (highest active stage only). * `hero` - single-line pulse for the detail header (highest active stage only).
* `panel` compact list of every active interest, for the Overview tab. * `panel` - compact list of every active interest, for the Overview tab.
*/ */
variant?: 'hero' | 'panel'; variant?: 'hero' | 'panel';
} }

View File

@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : ''} {y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
</TableCell> </TableCell>
<TableCell>{y.hullNumber ?? ''}</TableCell> <TableCell>{y.hullNumber ?? '-'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell> <TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -225,10 +225,10 @@ function ContactRow({
{/* Bottom / right: tag + actions. {/* Bottom / right: tag + actions.
Two layers of hiding compose here: Two layers of hiding compose here:
(a) phoneEditing when the phone editor is open, hide the entire (a) phoneEditing - when the phone editor is open, hide the entire
action cluster (tag + star + trash) so the user can focus on action cluster (tag + star + trash) so the user can focus on
the form without chips fighting for space. the form without chips fighting for space.
(b) contact.value when the value is empty (stale import row, (b) contact.value - when the value is empty (stale import row,
aborted edit), hide just the tag + Make-primary star; aborted edit), hide just the tag + Make-primary star;
neither makes sense without a value. The trash icon stays neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry. so the user can clean up the empty entry.

View File

@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' }); setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
// Clear the dismissed flag when inputs change the user typed // Clear the dismissed flag when inputs change - the user typed
// something new, so the prior dismissal no longer applies. // something new, so the prior dismissal no longer applies.
setDismissed(false); setDismissed(false);
}, 300); }, 300);
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`); return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
}, },
enabled: hasSomething && !dismissed, enabled: hasSomething && !dismissed,
// Same query is fine to cache for a minute moves are slow at this layer. // Same query is fine to cache for a minute - moves are slow at this layer.
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
<p className="text-sm font-semibold leading-tight"> <p className="text-sm font-semibold leading-tight">
{isHigh {isHigh
? 'This looks like an existing client' ? 'This looks like an existing client'
: 'Possible match check before creating'} : 'Possible match - check before creating'}
</p> </p>
<div className="mt-2 rounded-md border bg-background/80 p-2.5"> <div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
}, },
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Export queued refresh in ~30 seconds'); toast.success('Export queued - refresh in ~30 seconds');
qc.invalidateQueries({ queryKey }); qc.invalidateQueries({ queryKey });
setEmailOverride(''); setEmailOverride('');
}, },
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
Email the bundle when ready Email the bundle when ready
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sends a 7-day signed download link to the client&apos;s primary email or to the Sends a 7-day signed download link to the client&apos;s primary email - or to the
override below. override below.
</p> </p>
{emailToClient ? ( {emailToClient ? (

View File

@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
let msg = err instanceof Error ? err.message : 'Failed to add membership'; let msg = err instanceof Error ? err.message : 'Failed to add membership';
// Detect 409 service returns a "membership already exists" message // Detect 409 - service returns a "membership already exists" message
if (/already exists/i.test(msg)) { if (/already exists/i.test(msg)) {
msg = 'This membership already exists (same client + role + start date).'; msg = 'This membership already exists (same client + role + start date).';
} }

View File

@@ -76,7 +76,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -87,7 +87,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -98,7 +98,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.memberCount ?? 0; const n = row.original.memberCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },
@@ -109,7 +109,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.yachtCount ?? 0; const n = row.original.yachtCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },

View File

@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => { mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) { if (isEdit) {
// updateCompanySchema omits tagIds strip them from PATCH body. // updateCompanySchema omits tagIds - strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data; const { tagIds: _tIds, ...rest } = data;
void _tIds; void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, { await apiFetch(`/api/v1/companies/${company!.id}`, {
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
value={watch('incorporationCountryIso')} value={watch('incorporationCountryIso')}
onChange={(iso) => { onChange={(iso) => {
setValue('incorporationCountryIso', iso ?? undefined); setValue('incorporationCountryIso', iso ?? undefined);
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
setValue('incorporationSubdivisionIso', undefined); setValue('incorporationSubdivisionIso', undefined);
}} }}
data-testid="company-incorp-country" data-testid="company-incorp-country"

View File

@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
}; };
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) return ''; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(); return date.toLocaleDateString();
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
</TableCell> </TableCell>
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell> <TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate"> <TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
{m.roleDetail ?? ''} {m.roleDetail ?? '-'}
</TableCell> </TableCell>
<TableCell>{formatDate(m.startDate)}</TableCell> <TableCell>{formatDate(m.startDate)}</TableCell>
<TableCell> <TableCell>
{m.endDate ? ( {m.endDate ? (
formatDate(m.endDate) formatDate(m.endDate)
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
function formatDimensions(y: OwnedYachtRow): string | null { function formatDimensions(y: OwnedYachtRow): string | null {
if (y.lengthFt || y.widthFt) { if (y.lengthFt || y.widthFt) {
const length = y.lengthFt ?? ''; const length = y.lengthFt ?? '-';
const width = y.widthFt ?? ''; const width = y.widthFt ?? '-';
return `${length} × ${width} ft`; return `${length} × ${width} ft`;
} }
if (y.lengthM || y.widthM) { if (y.lengthM || y.widthM) {
const length = y.lengthM ?? ''; const length = y.lengthM ?? '-';
const width = y.widthM ?? ''; const width = y.widthM ?? '-';
return `${length} × ${width} m`; return `${length} × ${width} m`;
} }
return null; return null;
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
{dims ? ( {dims ? (
<span className="text-sm">{dims}</span> <span className="text-sm">{dims}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.hullNumber ? ( {y.hullNumber ? (
<span className="text-sm">{y.hullNumber}</span> <span className="text-sm">{y.hullNumber}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
<InlineCountryField <InlineCountryField
value={company.incorporationCountryIso} value={company.incorporationCountryIso}
onSave={async (iso) => { onSave={async (iso) => {
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
await mutation.mutateAsync({ await mutation.mutateAsync({
incorporationCountryIso: iso, incorporationCountryIso: iso,
incorporationSubdivisionIso: null, incorporationSubdivisionIso: null,
@@ -175,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
variant="textarea" variant="textarea"
value={company.notes} value={company.notes}
onSave={save('notes')} onSave={save('notes')}
emptyText="No notes click to add" emptyText="No notes - click to add"
/> />
</div> </div>

View File

@@ -58,7 +58,7 @@ function ActivityFeedInner() {
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed, No recent activity yet - your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here. invoices sent) will appear here.
</p> </p>
) : ( ) : (

View File

@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
) : !slices.length ? ( ) : !slices.length ? (
<EmptyState <EmptyState
title="No interests in range" title="No interests in range"
description="Lights up once new interests are created tracks where each came from (website, referral, broker)." description="Lights up once new interests are created - tracks where each came from (website, referral, broker)."
/> />
) : ( ) : (
// Percentage radii + center-anchored chart so the pie scales with // Percentage radii + center-anchored chart so the pie scales with

View File

@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> }; data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' }); }>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
toast.info( toast.info(
`Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} opens in PR8 wizard`, `Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`,
); );
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to prepare email'); toast.error(err instanceof Error ? err.message : 'Failed to prepare email');

View File

@@ -26,7 +26,7 @@ interface DocumentListProps {
interestId?: string; interestId?: string;
clientId?: string; clientId?: string;
/** Override the default empty state ("No documents yet.") with a contextual /** 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. */ * CTA - e.g. on the interest Documents tab we render a Generate EOI prompt. */
emptyState?: React.ReactNode; emptyState?: React.ReactNode;
} }
@@ -80,7 +80,7 @@ export function DocumentList({ interestId, clientId, emptyState }: DocumentListP
}; };
const getSignerProgress = (doc: DocumentRow) => { const getSignerProgress = (doc: DocumentRow) => {
if (!doc.signers) return ''; if (!doc.signers) return '-';
const signed = doc.signers.filter((s) => s.status === 'signed').length; const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`; return `${signed}/${doc.signers.length} signed`;
}; };

View File

@@ -180,7 +180,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
{isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')} {isNonSignature && doc.status === 'sent' ? 'Delivered' : doc.status.replace(/_/g, ' ')}
</StatusPill> </StatusPill>
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : ''} {totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')} {new Date(doc.createdAt).toLocaleDateString('en-GB')}

View File

@@ -22,14 +22,14 @@ import {
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
/** Required for the EOI's top paragraph (Section 2) without these the /** Required for the EOI's top paragraph (Section 2) - without these the
* document is unsignable, so generation is blocked. Yacht and berth fields * document is unsignable, so generation is blocked. Yacht and berth fields
* belong to Section 3 and may be left blank. */ * belong to Section 3 and may be left blank. */
interface EoiPrerequisites { interface EoiPrerequisites {
hasName: boolean; hasName: boolean;
hasEmail: boolean; hasEmail: boolean;
hasAddress: boolean; hasAddress: boolean;
/** Optional info-only checks. Generation proceeds without them. */ /** Optional - info-only checks. Generation proceeds without them. */
hasYacht: boolean; hasYacht: boolean;
hasBerth: boolean; hasBerth: boolean;
} }
@@ -180,7 +180,7 @@ export function EoiGenerateDialog({
<div className="space-y-1.5"> <div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
Optional (Section 3 left blank if absent) Optional (Section 3 - left blank if absent)
</p> </p>
{OPTIONAL_LABELS.map(({ key, label }) => ( {OPTIONAL_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3 text-sm"> <div key={key} className="flex items-center gap-3 text-sm">

View File

@@ -156,7 +156,7 @@ export function ExpenseCard({ expense, portSlug, onEdit, onArchive }: ExpenseCar
</p> </p>
) : null} ) : null}
{/* Amount prominent */} {/* Amount - prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground"> <p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted} {amountFormatted}
</p> </p>

View File

@@ -72,7 +72,7 @@ export function getExpenseColumns({
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.original.establishmentName ?? ''} {row.original.establishmentName ?? '-'}
</Link> </Link>
), ),
}, },
@@ -113,7 +113,7 @@ export function getExpenseColumns({
header: 'Category', header: 'Category',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cat = getValue() as string | null; const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>; if (!cat) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{cat.replace(/_/g, ' ')} {cat.replace(/_/g, ' ')}

View File

@@ -146,19 +146,19 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr
<CardContent className="grid grid-cols-2 gap-4 text-sm"> <CardContent className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-muted-foreground">Category</span> <span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? ''}</p> <p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Payment Method</span> <span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? ''}</p> <p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Payer</span> <span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? ''}</p> <p className="mt-0.5">{expense.payer ?? '-'}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">Description</span> <span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? ''}</p> <p className="mt-0.5">{expense.description ?? '-'}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
/** /**
* Click-to-change stage chip. Replaces the modal-based InterestStagePicker * Click-to-change stage chip. Replaces the modal-based InterestStagePicker
* for inline editing user clicks the chip, picks a new stage from the * for inline editing - user clicks the chip, picks a new stage from the
* popover (with optional reason), commits in one click. The popover stays * popover (with optional reason), commits in one click. The popover stays
* compact: a small reason field above the stage list, and clicking any stage * compact: a small reason field above the stage list, and clicking any stage
* fires the mutation immediately. * fires the mutation immediately.
@@ -140,7 +140,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium', isCurrent && 'font-medium',
)} )}
> >
{/* Colored chip (mirrors the inline stage badge) turns {/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */} the picker into a visual scan rather than just a list. */}
<span <span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])} className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}

View File

@@ -78,7 +78,7 @@ export function getInterestColumns({
className="truncate font-medium text-primary hover:underline" className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.original.clientName ?? ''} {row.original.clientName ?? '-'}
</Link> </Link>
{notesCount > 0 ? ( {notesCount > 0 ? (
<span <span
@@ -99,7 +99,7 @@ export function getInterestColumns({
header: 'Berth', header: 'Berth',
cell: ({ row }) => { cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) { if (!row.original.berthId || !row.original.berthMooringNumber) {
return <span className="text-muted-foreground"></span>; return <span className="text-muted-foreground">-</span>;
} }
return ( return (
<Link <Link
@@ -150,7 +150,7 @@ export function getInterestColumns({
header: 'Category', header: 'Category',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const cat = getValue() as string | null; const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>; if (!cat) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="text-xs capitalize"> <Badge variant="outline" className="text-xs capitalize">
{CATEGORY_LABELS[cat] ?? cat} {CATEGORY_LABELS[cat] ?? cat}
@@ -164,7 +164,7 @@ export function getInterestColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>; if (!source) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{SOURCE_LABELS[source] ?? source} {SOURCE_LABELS[source] ?? source}
@@ -178,7 +178,7 @@ export function getInterestColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const rowTags = row.original.tags ?? []; const rowTags = row.original.tags ?? [];
if (rowTags.length === 0) return <span className="text-muted-foreground"></span>; if (rowTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{rowTags.slice(0, 3).map((tag) => ( {rowTags.slice(0, 3).map((tag) => (
@@ -203,7 +203,7 @@ export function getInterestColumns({
cell: ({ row }) => { cell: ({ row }) => {
const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null; const lastIso = row.original.dateLastContact ?? row.original.updatedAt ?? null;
if (!lastIso) { if (!lastIso) {
return <span className="text-muted-foreground text-sm"></span>; return <span className="text-muted-foreground text-sm">-</span>;
} }
const d = new Date(lastIso); const d = new Date(lastIso);
return ( return (

View File

@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = { const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' }, won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
lost_other_marina: { label: 'Lost other marina', className: 'bg-rose-100 text-rose-700' }, lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost unqualified', className: 'bg-rose-100 text-rose-700' }, lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost no response', className: 'bg-rose-100 text-rose-700' }, lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' }, cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
}; };
@@ -69,7 +69,7 @@ interface InterestDetailHeaderProps {
clientPrimaryPhone?: string | null; clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null; clientPrimaryPhoneE164?: string | null;
/** Pending/snoozed reminders attached to this interest. Drives the /** Pending/snoozed reminders attached to this interest. Drives the
* alarm-bell badge on the header surfaces follow-ups so the rep * alarm-bell badge on the header - surfaces follow-ups so the rep
* doesn't have to remember to check /reminders. */ * doesn't have to remember to check /reminders. */
activeReminderCount?: number; activeReminderCount?: number;
berthId: string | null; berthId: string | null;
@@ -107,7 +107,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const outcomeBadge = resolveOutcomeBadge(interest.outcome); const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome; const isClosed = !!interest.outcome;
// Contact deep-links resolved from the linked client's primary channels. // 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 // 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 // stripping non-digits from the display value when the canonical form is
// missing. // missing.
@@ -258,7 +258,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div> </div>
)} )}
{/* Contact deep-links let the rep email / call / WhatsApp the {/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side the linked client's primary contact channels (server-side
fetch in getInterestById). */} fetch in getInterestById). */}
@@ -343,7 +343,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
the won/lost meaning (green vs rose). Adding a "Won" / the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and "Lost" text label inline blew out the cluster width and
forced the Email/Call/WhatsApp action-chip row above to forced the Email/Call/WhatsApp action-chip row above to
stack vertically bad trade. From sm up, the full stack vertically - bad trade. From sm up, the full
"Mark won" / "Close as lost" labels read clearly. */} "Mark won" / "Close as lost" labels read clearly. */}
<button <button
type="button" type="button"

View File

@@ -36,7 +36,7 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
}); });
const prerequisites = { const prerequisites = {
// Required (EOI Section 2 top paragraph): name, address, email. // Required (EOI Section 2 - top paragraph): name, address, email.
hasName: Boolean(interest?.clientName), hasName: Boolean(interest?.clientName),
hasEmail: Boolean(interest?.clientPrimaryEmail), hasEmail: Boolean(interest?.clientPrimaryEmail),
hasAddress: Boolean(interest?.clientHasAddress), hasAddress: Boolean(interest?.clientHasAddress),

View File

@@ -314,7 +314,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage. Required before the interest can leave the &quot;Open&quot; stage.
</p> </p>
{/* TODO: also include company-owned yachts where client is a member requires autocomplete owner=any|company filter */} {/* TODO: also include company-owned yachts where client is a member - requires autocomplete owner=any|company filter */}
{/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */} {/* TODO: add "Add new yacht" inline shortcut (requires YachtForm integration) */}
</div> </div>
</div> </div>

View File

@@ -71,7 +71,7 @@ export function InterestList() {
const bulkArchiveMutation = useMutation({ const bulkArchiveMutation = useMutation({
mutationFn: async (ids: string[]) => { mutationFn: async (ids: string[]) => {
// Concurrent fan-out small batches in practice (page size cap = 100). // Concurrent fan-out - small batches in practice (page size cap = 100).
// If a single delete fails the others still run; the rejected one // If a single delete fails the others still run; the rejected one
// surfaces a toast via the standard apiFetch error path. // surfaces a toast via the standard apiFetch error path.
await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' }))); await Promise.all(ids.map((id) => apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' })));
@@ -194,7 +194,7 @@ export function InterestList() {
/> />
)} )}
{/* Mobile FAB primary "New interest" affordance for the bottom-tab UX. {/* Mobile FAB - primary "New interest" affordance for the bottom-tab UX.
Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px Sits above the bottom nav (pb-safe-bottom + 70px tab height + 16px
gap). Hidden on lg+ where the header button already does the job. */} gap). Hidden on lg+ where the header button already does the job. */}
<PermissionGate resource="interests" action="create"> <PermissionGate resource="interests" action="create">

View File

@@ -26,9 +26,9 @@ import { type InterestOutcome } from '@/lib/validators/interests';
const OUTCOME_LABELS: Record<InterestOutcome, string> = { const OUTCOME_LABELS: Record<InterestOutcome, string> = {
won: 'Won', won: 'Won',
lost_other_marina: 'Lost went to another marina', lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost unqualified', lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost no response', lost_no_response: 'Lost - no response',
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };

View File

@@ -2,12 +2,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import type { InterestScore } from '@/lib/services/interest-scoring.service'; import type { InterestScore } from '@/lib/services/interest-scoring.service';
@@ -15,9 +10,12 @@ import type { InterestScore } from '@/lib/services/interest-scoring.service';
// ─── Score tier helpers ─────────────────────────────────────────────────────── // ─── Score tier helpers ───────────────────────────────────────────────────────
function getScoreTier(score: number): { label: string; className: string } { function getScoreTier(score: number): { label: string; className: string } {
if (score >= 80) return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' }; if (score >= 80)
if (score >= 60) return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' }; return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' };
if (score >= 40) return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' }; if (score >= 60)
return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' };
if (score >= 40)
return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' };
return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' }; return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' };
} }
@@ -34,7 +32,7 @@ export function InterestScoreBadge({ interestId }: InterestScoreBadgeProps) {
queryKey: ['interest-score', interestId], queryKey: ['interest-score', interestId],
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`), queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
enabled: featureEnabled, enabled: featureEnabled,
staleTime: 60 * 60 * 1000, // 1 hour mirrors server-side cache TTL staleTime: 60 * 60 * 1000, // 1 hour - mirrors server-side cache TTL
}); });
if (!featureEnabled) return null; if (!featureEnabled) return null;

View File

@@ -55,7 +55,7 @@ interface InterestTabsOptions {
reminderLastFired: string | null; reminderLastFired: string | null;
notes: string | null; notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note" /** Surfaced by getInterestById for the Overview "most recent note"
* teaser saves a click into the Notes tab to peek at the latest. */ * teaser - saves a click into the Notes tab to peek at the latest. */
notesCount?: number; notesCount?: number;
recentNote?: { recentNote?: {
id: string; id: string;
@@ -145,7 +145,7 @@ interface MilestoneSectionProps {
onAdvance: (stage: string) => void; onAdvance: (stage: string) => void;
isPending: boolean; isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has /** 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. * 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 * a seed-data interest that started already at eoi_signed will show both
* EOI sub-steps as done. Stage truth > date truth. */ * EOI sub-steps as done. Stage truth > date truth. */
currentStage: string; currentStage: string;
@@ -158,7 +158,7 @@ interface MilestoneSectionProps {
} }
/** /**
* One milestone section (EOI / Deposit / Contract) shows a vertical lifecycle * One milestone section (EOI / Deposit / Contract) - shows a vertical lifecycle
* with completed steps checked, the next step exposing a quick "mark as…" * with completed steps checked, the next step exposing a quick "mark as…"
* button that bumps the pipeline stage. Each stage flip auto-stamps its date * button that bumps the pipeline stage. Each stage flip auto-stamps its date
* via the service layer (interests.service.ts). When external systems wire in * via the service layer (interests.service.ts). When external systems wire in
@@ -308,7 +308,7 @@ function OverviewTab({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sales-process milestones the heart of the system. Each section is a {/* Sales-process milestones - the heart of the system. Each section is a
mini lifecycle that auto-completes as actions happen on the platform mini lifecycle that auto-completes as actions happen on the platform
(Documenso webhook, paid deposit invoice, signed contract). Until the (Documenso webhook, paid deposit invoice, signed contract). Until the
automation lands, salespeople nudge stages forward via the inline automation lands, salespeople nudge stages forward via the inline
@@ -420,7 +420,7 @@ function OverviewTab({
</dl> </dl>
</div> </div>
{/* Contact dates (read-only kept compact next to Lead) */} {/* Contact dates (read-only - kept compact next to Lead) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3> <h3 className="text-sm font-medium mb-2">Contact</h3>
<dl> <dl>
@@ -484,7 +484,7 @@ function OverviewTab({
variant="textarea" variant="textarea"
value={interest.notes} value={interest.notes}
onSave={save('notes')} onSave={save('notes')}
emptyText="No notes click to add" emptyText="No notes - click to add"
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@
* Sales-triage urgency badges for interest list rows + cards. * Sales-triage urgency badges for interest list rows + cards.
* *
* Derived purely from the dates we already return on the row, so this is a * 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 * 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 * server-side alert-rules engine uses, but for at-a-glance rendering on
* the list itself. * the list itself.
*/ */
@@ -47,7 +47,7 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[]
const badges: UrgencyBadge[] = []; const badges: UrgencyBadge[] = [];
// Silent in mid-funnel stages most actionable. // Silent in mid-funnel stages - most actionable.
if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) { if (ACTIVE_MID_FUNNEL_STAGES.has(row.pipelineStage)) {
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null; const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
const days = daysSince(lastTouchIso); const days = daysSince(lastTouchIso);

View File

@@ -155,7 +155,7 @@ export function InvoiceCard({
<span className="truncate">{invoice.clientName}</span> <span className="truncate">{invoice.clientName}</span>
</p> </p>
{/* Amount prominent */} {/* Amount - prominent */}
<p className="mt-1 text-base font-semibold tabular-nums text-foreground"> <p className="mt-1 text-base font-semibold tabular-nums text-foreground">
{amountFormatted} {amountFormatted}
</p> </p>

View File

@@ -55,12 +55,12 @@ const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
]; ];
function formatPaymentMethod(method: string | null | undefined): string { function formatPaymentMethod(method: string | null | undefined): string {
if (!method) return ''; if (!method) return '-';
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' '); return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
} }
function formatDateOnly(value: string | null | undefined): string { function formatDateOnly(value: string | null | undefined): string {
if (!value) return ''; if (!value) return '-';
// Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift. // 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 isoDate = value.length === 10 ? value + 'T00:00:00' : value;
const d = new Date(isoDate); const d = new Date(isoDate);
@@ -299,7 +299,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<div> <div>
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p> <p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{exp.category ?? ''} &middot; {exp.expenseDate} {exp.category ?? '-'} &middot; {exp.expenseDate}
</p> </p>
</div> </div>
<span className="font-medium tabular-nums"> <span className="font-medium tabular-nums">
@@ -341,7 +341,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div> </div>
<div> <div>
<span className="text-muted-foreground">Reference</span> <span className="text-muted-foreground">Reference</span>
<p className="mt-0.5">{invoice.paymentReference ?? ''}</p> <p className="mt-0.5">{invoice.paymentReference ?? '-'}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -35,7 +35,7 @@ const SEGMENT_LABELS: Record<string, string> = {
profile: 'Profile', profile: 'Profile',
}; };
// UUID v4-ish (or any 36-char hex+dash) used to skip entity-id segments // UUID v4-ish (or any 36-char hex+dash) - used to skip entity-id segments
// from the breadcrumbs since the page H1 already shows the entity name. // from the breadcrumbs since the page H1 already shows the entity name.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -56,7 +56,7 @@ export function Breadcrumbs() {
// Split pathname and filter empty segments // Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean); const rawSegments = pathname.split('/').filter(Boolean);
// Remove the portSlug segment and any UUID-ish entity-id segments the // Remove the portSlug segment and any UUID-ish entity-id segments - the
// page H1 already shows the entity name, no need to leak the raw id. // page H1 already shows the entity name, no need to leak the raw id.
const segments = ( const segments = (
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments

View File

@@ -13,16 +13,16 @@ type TabSpec = {
}; };
// Bottom nav ordering, left → right: // Bottom nav ordering, left → right:
// Dashboard daily overview // Dashboard - daily overview
// Berths marina inventory grid (touches sales + ops both) // Berths - marina inventory grid (touches sales + ops both)
// Clients the address book / dedup surface (centered: it's the // Clients - the address book / dedup surface (centered: it's the
// primary mental anchor for "find this person", with // primary mental anchor for "find this person", with
// interests living as a tab on the client detail rather // interests living as a tab on the client detail rather
// than a peer in the bottom nav) // than a peer in the bottom nav)
// Documents signature tracking (chase signers, EOI queue) // Documents - signature tracking (chase signers, EOI queue)
// More overflow drawer (Interests, Yachts, Companies, …) // More - overflow drawer (Interests, Yachts, Companies, …)
// //
// Interests is intentionally NOT in the bottom row having both Clients // Interests is intentionally NOT in the bottom row - having both Clients
// and Interests as peer tabs created a Clients-vs-Interests confusion // and Interests as peer tabs created a Clients-vs-Interests confusion
// for sales reps, and the per-client interests tab + the new bottom-sheet // 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. // drawer cover the day-to-day deal review without needing a dedicated tab.

View File

@@ -10,7 +10,7 @@ import { MoreSheet } from './more-sheet';
/** /**
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab * Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
* bar. Renders only when CSS reveals it (data-shell="mobile") both shells * bar. Renders only when CSS reveals it (data-shell="mobile") - both shells
* are in the DOM, see src/app/globals.css. The bottom tabs and More sheet * are in the DOM, see src/app/globals.css. The bottom tabs and More sheet
* derive the active port slug from the URL themselves, so this layout takes * derive the active port slug from the URL themselves, so this layout takes
* no portSlug prop. * no portSlug prop.

View File

@@ -12,7 +12,7 @@ import { useMobileChrome } from './mobile-layout-provider';
* left when there's no back affordance, and a soft glow shadow underneath * left when there's no back affordance, and a soft glow shadow underneath
* for depth instead of a hard divider line. * for depth instead of a hard divider line.
* *
* Slots: title (auto-truncating), back arrow, primary action all driven by * Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the * `useMobileChrome()` from the active page. When no page has set a title the
* URL's last segment is title-cased as a fallback. * URL's last segment is title-cased as a fallback.
*/ */

View File

@@ -32,7 +32,7 @@ export function PortSwitcher({ ports }: PortSwitcherProps) {
setPort(port.id, port.slug); setPort(port.id, port.slug);
// Invalidate all cached queries they are port-scoped // Invalidate all cached queries - they are port-scoped
queryClient.invalidateQueries(); queryClient.invalidateQueries();
// Navigate to the selected port's dashboard // Navigate to the selected port's dashboard

View File

@@ -221,7 +221,7 @@ export function ReminderForm({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground text-xs"> <Label className="text-muted-foreground text-xs">
Link to Entity (optional paste UUIDs, or leave blank) Link to Entity (optional - paste UUIDs, or leave blank)
</Label> </Label>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<Input <Input

View File

@@ -55,9 +55,7 @@ export function ReportsList() {
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'), queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
refetchInterval: (query) => { refetchInterval: (query) => {
const rows = query.state.data?.data ?? []; const rows = query.state.data?.data ?? [];
const hasPending = rows.some( const hasPending = rows.some((r) => r.status === 'queued' || r.status === 'processing');
(r) => r.status === 'queued' || r.status === 'processing',
);
return hasPending ? 5000 : false; return hasPending ? 5000 : false;
}, },
}); });
@@ -65,9 +63,7 @@ export function ReportsList() {
const handleDownload = async (reportId: string) => { const handleDownload = async (reportId: string) => {
setDownloadingId(reportId); setDownloadingId(reportId);
try { try {
const result = await apiFetch<{ url: string }>( const result = await apiFetch<{ url: string }>(`/api/v1/reports/${reportId}/download`);
`/api/v1/reports/${reportId}/download`,
);
window.open(result.url, '_blank'); window.open(result.url, '_blank');
} catch (err) { } catch (err) {
console.error('Download failed', err); console.error('Download failed', err);
@@ -91,9 +87,7 @@ export function ReportsList() {
) : !data?.data.length ? ( ) : !data?.data.length ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" /> <FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">No reports generated yet</p>
No reports generated yet
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Use the form above to generate your first report. Use the form above to generate your first report.
</p> </p>
@@ -127,18 +121,18 @@ export function ReportsList() {
})} })}
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{report.completedAt {report.completedAt ? (
? new Date(report.completedAt).toLocaleString('en-GB', { new Date(report.completedAt).toLocaleString('en-GB', {
dateStyle: 'short', dateStyle: 'short',
timeStyle: 'short', timeStyle: 'short',
}) })
: report.status === 'failed' && report.errorMessage ) : report.status === 'failed' && report.errorMessage ? (
? (
<span className="text-destructive text-xs" title={report.errorMessage}> <span className="text-destructive text-xs" title={report.errorMessage}>
Failed Failed
</span> </span>
) ) : (
: '—'} '-'
)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{report.status === 'ready' && report.fileId && ( {report.status === 'ready' && report.fileId && (

View File

@@ -147,7 +147,7 @@ export function BerthReserveDialog({ open, onOpenChange, berthId }: BerthReserve
const msg = err instanceof Error ? err.message : 'Failed to activate'; const msg = err instanceof Error ? err.message : 'Failed to activate';
if (/active reservation|conflict|409/i.test(msg)) { if (/active reservation|conflict|409/i.test(msg)) {
setFormError( setFormError(
'This berth already has an active reservation. The pending record was created activate it manually once the other reservation ends.', 'This berth already has an active reservation. The pending record was created - activate it manually once the other reservation ends.',
); );
} else { } else {
setFormError(msg); setFormError(msg);

View File

@@ -208,7 +208,7 @@ export function ReservationList({
View contract View contract
</button> </button>
) : ( ) : (
'' '-'
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -72,8 +72,8 @@ const STAGE_LABELS: Record<string, string> = {
viewing_scheduled: 'Viewing scheduled', viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made', offer_made: 'Offer made',
offer_accepted: 'Offer accepted', offer_accepted: 'Offer accepted',
closed_won: 'Closed won', closed_won: 'Closed - won',
closed_lost: 'Closed lost', closed_lost: 'Closed - lost',
}; };
export function ResidentialClientDetail({ clientId }: { clientId: string }) { export function ResidentialClientDetail({ clientId }: { clientId: string }) {
@@ -188,7 +188,7 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
<InlineCountryField <InlineCountryField
value={client.placeOfResidenceCountryIso} value={client.placeOfResidenceCountryIso}
onSave={async (iso) => { onSave={async (iso) => {
// When country flips, clear the subdivision codes are country-scoped. // When country flips, clear the subdivision - codes are country-scoped.
await update.mutateAsync({ await update.mutateAsync({
placeOfResidenceCountryIso: iso, placeOfResidenceCountryIso: iso,
subdivisionIso: null, subdivisionIso: null,
@@ -249,7 +249,7 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0"> <span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</span> </span>
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || ''}</span> <span className="flex-1 truncate text-sm">{i.preferences || i.notes || '-'}</span>
<Link <Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${i.id}` as any} href={`/${portSlug}/residential/interests/${i.id}` as any}

Some files were not shown because too many files have changed in this diff Show More