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)',
description: 'Appended to the bottom of system-generated emails.',
type: 'html',
placeholder: '<p><br>The Port Nimara team</p>',
placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '',
},
{
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{
key: 'smtp_pass_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',
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
* 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.
*/
export default function Loading() {
return (
<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="flex items-center gap-3">
<Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]);
// 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.
const { data: prefilledInterest } = useQuery<{
data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return (
<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">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
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 linked interest to "Deposit 10%".'}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider>
<SocketProvider>
<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">
<Sidebar
portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
ports={ports}
/>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
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>
{/* Mobile shell hidden by CSS on desktop */}
{/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
</SocketProvider>
</PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
},
};
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
// This layout wraps all portal routes including login/verify
// 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.
const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
{children}
</main>
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm
endpoint="/api/portal/auth/activate"
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"
successDescription="You can now sign in with your new password."
submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault();
setLoading(true);
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', {
method: 'POST',
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>
)}
{interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span>
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
)}
</div>
{interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{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>
<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 manifest = {
name: `${portName} Scanner`,
name: `${portName} - Scanner`,
short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`,

View File

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

View File

@@ -1,11 +1,11 @@
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
* 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).
*
* 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.
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 +
// membership, all inside a single transaction.
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';
// 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.
let berthId: string | null = 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
* `residential_clients` row and an opening `residential_interests` row in a
* 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_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).
void sendResidentialNotifications({
portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
});
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.
const recipientsRow = await db.query.systemSettings.findFirst({
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
* load balancer until the next probe succeeds; it should not trigger a
* 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.
*
* 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) => {
try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
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.
*/
export const POST = withAuth(

View File

@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
.map((p) => {
const a = clientById.get(p.clientAId);
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.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return {

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
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
// port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
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.
export const POST = withAuth(
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
*
* 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.
*
* Body:
* content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions
* sampleData?: Record<string, string> - variable substitutions
*/
export const POST = withAuth(
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.
*/
function substituteInDoc(
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
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';
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
// handler after the body is parsed.
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(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
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(
withPermission('berths', 'view', async (req, ctx) => {
try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
*
* 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, returns 409 the admin can resend the activation via
* email, returns 409 - the admin can resend the activation via
* ?action=resend.
*/
export const POST = withAuth(

View File

@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
const nameResult = rawName ? normalizeName(rawName) : null;
// 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) {
return NextResponse.json({ data: [] });
}
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
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).
const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) {

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
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),
});

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
// would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
});
} catch (err) {
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({
data: {
parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try {
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
.replace(/\x00/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(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost went to another marina',
lost_unqualified: 'Lost unqualified',
lost_no_response: 'Lost no response',
lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost - no response',
cancelled: 'Cancelled',
};
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
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') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system';
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) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
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);
return NextResponse.json(results);

View File

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

View File

@@ -93,7 +93,7 @@
@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 {
background-image: repeating-linear-gradient(
135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* 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
* be overridden by `display: block` (same specificity, later cascade).
*/
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
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 [loadingMore, setLoadingMore] = useState(false);
// Filter state debounce text inputs.
// Filter state - debounce text inputs.
const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all');
@@ -215,7 +215,7 @@ export function AuditLogList() {
</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
title="Audit Log"
eyebrow="Admin"
description="Every state change in this port fully searchable."
description="Every state change in this port - fully searchable."
variant="gradient"
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ export function InvitationsManager() {
{invites.map((i) => (
<tr key={i.id} className="border-t">
<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">
{i.isSuperAdmin ? 'Super admin' : 'Standard user'}
</td>
@@ -163,7 +163,7 @@ export function InvitationsManager() {
)}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
</tr>

View File

@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
Enable AI receipt parsing for this port
</Label>
<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
re-parse receipts server-side for higher accuracy on hard-to-read images.
</p>
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'}
autoComplete="off"
placeholder={hasKey ? '•••••• (saved leave blank to keep)' : 'sk-…'}
placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);

View File

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

View File

@@ -95,7 +95,7 @@ export function RoleList() {
accessorKey: 'description',
header: 'Description',
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);
} 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" />
</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 ? (
<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 />

View File

@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
useEffect(() => {
void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhookId, page]);
if (loading && deliveries.length === 0) {
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
</TableCell>
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{d.deliveredAt

View File

@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false);
// 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.
// 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.
const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime();

View File

@@ -22,11 +22,10 @@ export function AlertRail() {
<section
data-testid="alert-rail"
aria-label="Active alerts"
// `h-full` is intentional only at xl: where the parent dashboard grid
// gives this rail a sibling column whose height it should match. On
// mobile (single-column stack) there's no fixed-height context, so
// forcing 100% height makes the section overflow / look stretched.
className="flex flex-col gap-3 xl:h-full"
// Natural height - the parent aside no longer forces 100% of the
// dashboard grid row, so the rail can sit compactly under Reminders
// without bleeding down into the Recent Activity panel below.
className="flex flex-col gap-3"
>
<div className="flex items-baseline justify-between">
<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)}
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>
) : null}
</div>

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ export function BerthList() {
title="Berths"
description="View and manage berth allocations"
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">

View File

@@ -109,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return (
<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. */}
<BerthInterestPulse berthId={berth.id} />

View File

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

View File

@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
function formatSince(startDate: string | Date): string {
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');
}
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
Primary
</Badge>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">

View File

@@ -169,7 +169,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)}
</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. */}
<button
type="button"

View File

@@ -150,7 +150,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</SheetHeader>
<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
an existing client when one matches. The user can
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
// 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.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
@@ -243,7 +243,7 @@ function InterestPreviewDrawer({
</DrawerHeader>
<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
across surfaces. */}
{stage ? (
@@ -255,7 +255,7 @@ function InterestPreviewDrawer({
</div>
) : 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
the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step
@@ -308,7 +308,7 @@ function InterestPreviewDrawer({
</div>
</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
missing so the drawer scales from sparse seed data to full
records without empty placeholders. */}

View File

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

View File

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

View File

@@ -225,10 +225,10 @@ function ContactRow({
{/* Bottom / right: tag + actions.
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
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;
neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry.

View File

@@ -63,7 +63,7 @@ export function DedupSuggestionPanel({
useEffect(() => {
const t = setTimeout(() => {
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.
setDismissed(false);
}, 300);
@@ -83,7 +83,7 @@ export function DedupSuggestionPanel({
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
},
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,
});
@@ -120,7 +120,7 @@ export function DedupSuggestionPanel({
<p className="text-sm font-semibold leading-tight">
{isHigh
? 'This looks like an existing client'
: 'Possible match check before creating'}
: 'Possible match - check before creating'}
</p>
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2">

View File

@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
},
}),
onSuccess: () => {
toast.success('Export queued refresh in ~30 seconds');
toast.success('Export queued - refresh in ~30 seconds');
qc.invalidateQueries({ queryKey });
setEmailOverride('');
},
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
Email the bundle when ready
</Label>
<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.
</p>
{emailToClient ? (

View File

@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
},
onError: (err: unknown) => {
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)) {
msg = 'This membership already exists (same client + role + start date).';
}

View File

@@ -76,7 +76,7 @@ export function getCompanyColumns({
enableSorting: false,
cell: ({ getValue }) => {
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>;
},
},
@@ -87,7 +87,7 @@ export function getCompanyColumns({
enableSorting: false,
cell: ({ getValue }) => {
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>;
},
},
@@ -98,7 +98,7 @@ export function getCompanyColumns({
size: 88,
cell: ({ row }) => {
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>;
},
},
@@ -109,7 +109,7 @@ export function getCompanyColumns({
size: 88,
cell: ({ row }) => {
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>;
},
},

View File

@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) {
// updateCompanySchema omits tagIds strip them from PATCH body.
// updateCompanySchema omits tagIds - strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data;
void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, {
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
value={watch('incorporationCountryIso')}
onChange={(iso) => {
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);
}}
data-testid="company-incorp-country"

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ function ActivityFeedInner() {
<CardContent>
{items.length === 0 ? (
<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.
</p>
) : (

View File

@@ -56,7 +56,7 @@ export function LeadSourceChart({ range }: Props) {
) : !slices.length ? (
<EmptyState
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

View File

@@ -174,7 +174,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> };
}>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' });
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) {
toast.error(err instanceof Error ? err.message : 'Failed to prepare email');

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ export function getExpenseColumns({
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.establishmentName ?? ''}
{row.original.establishmentName ?? '-'}
</Link>
),
},
@@ -113,7 +113,7 @@ export function getExpenseColumns({
header: 'Category',
cell: ({ getValue }) => {
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 (
<Badge variant="outline" className="capitalize text-xs">
{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">
<div>
<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>
<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>
<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>
<span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? ''}</p>
<p className="mt-0.5">{expense.description ?? '-'}</p>
</div>
</CardContent>
</Card>

View File

@@ -30,7 +30,7 @@ interface InlineStagePickerProps {
/**
* 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
* compact: a small reason field above the stage list, and clicking any stage
* fires the mutation immediately.
@@ -140,7 +140,7 @@ export function InlineStagePicker({
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) turns
{/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}

View File

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

View File

@@ -30,9 +30,9 @@ import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
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_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_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_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
};
@@ -69,7 +69,7 @@ interface InterestDetailHeaderProps {
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
/** Pending/snoozed reminders attached to this interest. Drives the
* alarm-bell badge on the header surfaces follow-ups so the rep
* alarm-bell badge on the header - surfaces follow-ups so the rep
* doesn't have to remember to check /reminders. */
activeReminderCount?: number;
berthId: string | null;
@@ -107,7 +107,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const outcomeBadge = resolveOutcomeBadge(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
// stripping non-digits from the display value when the canonical form is
// missing.
@@ -258,7 +258,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</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
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
@@ -343,7 +343,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and
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. */}
<button
type="button"

View File

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

View File

@@ -314,7 +314,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
</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) */}
</div>
</div>

View File

@@ -71,7 +71,7 @@ export function InterestList() {
const bulkArchiveMutation = useMutation({
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
// surfaces a toast via the standard apiFetch error path.
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
gap). Hidden on lg+ where the header button already does the job. */}
<PermissionGate resource="interests" action="create">

View File

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

View File

@@ -2,12 +2,7 @@
import { useQuery } from '@tanstack/react-query';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { apiFetch } from '@/lib/api/client';
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 ───────────────────────────────────────────────────────
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 >= 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' };
if (score >= 80)
return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-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' };
}
@@ -34,7 +32,7 @@ export function InterestScoreBadge({ interestId }: InterestScoreBadgeProps) {
queryKey: ['interest-score', interestId],
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
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;

View File

@@ -55,7 +55,7 @@ interface InterestTabsOptions {
reminderLastFired: string | null;
notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note"
* teaser saves a click into the Notes tab to peek at the latest. */
* teaser - saves a click into the Notes tab to peek at the latest. */
notesCount?: number;
recentNote?: {
id: string;
@@ -145,7 +145,7 @@ interface MilestoneSectionProps {
onAdvance: (stage: string) => void;
isPending: boolean;
/** Current pipelineStage. Used to mark steps as done when the pipeline has
* moved past their advanceStage even if the date stamp is missing e.g.
* moved past their advanceStage even if the date stamp is missing - e.g.
* a seed-data interest that started already at eoi_signed will show both
* EOI sub-steps as done. Stage truth > date truth. */
currentStage: string;
@@ -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…"
* 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
@@ -308,7 +308,7 @@ function OverviewTab({
return (
<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
(Documenso webhook, paid deposit invoice, signed contract). Until the
automation lands, salespeople nudge stages forward via the inline
@@ -420,7 +420,7 @@ function OverviewTab({
</dl>
</div>
{/* Contact dates (read-only kept compact next to Lead) */}
{/* Contact dates (read-only - kept compact next to Lead) */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
@@ -484,7 +484,7 @@ function OverviewTab({
variant="textarea"
value={interest.notes}
onSave={save('notes')}
emptyText="No notes click to add"
emptyText="No notes - click to add"
/>
</div>

View File

@@ -2,7 +2,7 @@
* Sales-triage urgency badges for interest list rows + cards.
*
* Derived purely from the dates we already return on the row, so this is a
* pure function no DB hits, no extra fetch. Mirrors the logic the
* pure function - no DB hits, no extra fetch. Mirrors the logic the
* server-side alert-rules engine uses, but for at-a-glance rendering on
* the list itself.
*/
@@ -47,7 +47,7 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): 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)) {
const lastTouchIso = row.dateLastContact ?? row.updatedAt ?? null;
const days = daysSince(lastTouchIso);

View File

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

View File

@@ -55,12 +55,12 @@ const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [
];
function formatPaymentMethod(method: string | null | undefined): string {
if (!method) return '';
if (!method) return '-';
return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' ');
}
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.
const isoDate = value.length === 10 ? value + 'T00:00:00' : value;
const d = new Date(isoDate);
@@ -299,7 +299,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<div>
<p className="font-medium">{exp.establishmentName ?? 'Unnamed Expense'}</p>
<p className="text-muted-foreground text-xs">
{exp.category ?? ''} &middot; {exp.expenseDate}
{exp.category ?? '-'} &middot; {exp.expenseDate}
</p>
</div>
<span className="font-medium tabular-nums">
@@ -341,7 +341,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div>
<div>
<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>
</CardContent>

View File

@@ -35,7 +35,7 @@ const SEGMENT_LABELS: Record<string, string> = {
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.
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
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.
const segments = (
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments

View File

@@ -13,16 +13,16 @@ type TabSpec = {
};
// Bottom nav ordering, left → right:
// Dashboard daily overview
// Berths marina inventory grid (touches sales + ops both)
// Clients the address book / dedup surface (centered: it's the
// Dashboard - daily overview
// Berths - marina inventory grid (touches sales + ops both)
// Clients - the address book / dedup surface (centered: it's the
// primary mental anchor for "find this person", with
// interests living as a tab on the client detail rather
// than a peer in the bottom nav)
// Documents signature tracking (chase signers, EOI queue)
// More overflow drawer (Interests, Yachts, Companies, …)
// Documents - signature tracking (chase signers, EOI queue)
// 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
// 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.

View File

@@ -10,7 +10,7 @@ import { MoreSheet } from './more-sheet';
/**
* 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
* derive the active port slug from the URL themselves, so this layout takes
* 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
* 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
* 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);
// Invalidate all cached queries they are port-scoped
// Invalidate all cached queries - they are port-scoped
queryClient.invalidateQueries();
// Navigate to the selected port's dashboard

View File

@@ -221,7 +221,7 @@ export function ReminderForm({
<div className="space-y-2">
<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>
<div className="grid grid-cols-1 gap-2">
<Input

View File

@@ -55,9 +55,7 @@ export function ReportsList() {
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
refetchInterval: (query) => {
const rows = query.state.data?.data ?? [];
const hasPending = rows.some(
(r) => r.status === 'queued' || r.status === 'processing',
);
const hasPending = rows.some((r) => r.status === 'queued' || r.status === 'processing');
return hasPending ? 5000 : false;
},
});
@@ -65,9 +63,7 @@ export function ReportsList() {
const handleDownload = async (reportId: string) => {
setDownloadingId(reportId);
try {
const result = await apiFetch<{ url: string }>(
`/api/v1/reports/${reportId}/download`,
);
const result = await apiFetch<{ url: string }>(`/api/v1/reports/${reportId}/download`);
window.open(result.url, '_blank');
} catch (err) {
console.error('Download failed', err);
@@ -91,9 +87,7 @@ export function ReportsList() {
) : !data?.data.length ? (
<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" />
<p className="text-sm font-medium text-muted-foreground">
No reports generated yet
</p>
<p className="text-sm font-medium text-muted-foreground">No reports generated yet</p>
<p className="text-xs text-muted-foreground">
Use the form above to generate your first report.
</p>
@@ -127,18 +121,18 @@ export function ReportsList() {
})}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{report.completedAt
? new Date(report.completedAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})
: report.status === 'failed' && report.errorMessage
? (
<span className="text-destructive text-xs" title={report.errorMessage}>
Failed
</span>
)
: '—'}
{report.completedAt ? (
new Date(report.completedAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})
) : report.status === 'failed' && report.errorMessage ? (
<span className="text-destructive text-xs" title={report.errorMessage}>
Failed
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="text-right">
{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';
if (/active reservation|conflict|409/i.test(msg)) {
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 {
setFormError(msg);

View File

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

View File

@@ -72,8 +72,8 @@ const STAGE_LABELS: Record<string, string> = {
viewing_scheduled: 'Viewing scheduled',
offer_made: 'Offer made',
offer_accepted: 'Offer accepted',
closed_won: 'Closed won',
closed_lost: 'Closed lost',
closed_won: 'Closed - won',
closed_lost: 'Closed - lost',
};
export function ResidentialClientDetail({ clientId }: { clientId: string }) {
@@ -188,7 +188,7 @@ export function ResidentialClientDetail({ clientId }: { clientId: string }) {
<InlineCountryField
value={client.placeOfResidenceCountryIso}
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({
placeOfResidenceCountryIso: iso,
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">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</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
/* eslint-disable-next-line @typescript-eslint/no-explicit-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