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

@@ -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}

View File

@@ -129,11 +129,11 @@ export function ResidentialClientsList() {
{c.fullName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{c.email ?? ''}</td>
<td className="px-3 py-2 text-muted-foreground">{c.phone ?? ''}</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? ''}</td>
<td className="px-3 py-2 text-muted-foreground">{c.email ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.phone ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '-'}</td>
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? ''}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '-'}</td>
</tr>
))}
</tbody>
@@ -323,7 +323,7 @@ function NewResidentialClientSheet({
value={residenceCountry}
onChange={(iso) => {
setResidenceCountry(iso);
// Wipe subdivision when country flips codes are scoped per country.
// Wipe subdivision when country flips - codes are scoped per country.
setResidenceSubdivision(null);
}}
data-testid="rc-residence-country"

View File

@@ -27,8 +27,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',
};
const STAGE_OPTIONS = PIPELINE_STAGES.map((s) => ({

View File

@@ -40,8 +40,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 ResidentialInterestsList() {
@@ -136,12 +136,12 @@ export function ResidentialInterestsList() {
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.preferences ?? ''}
{i.preferences ?? '-'}
</td>
<td className="px-3 py-2 text-muted-foreground truncate max-w-xs">
{i.notes ?? ''}
{i.notes ?? '-'}
</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{i.source ?? ''}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{i.source ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{new Date(i.updatedAt).toLocaleDateString()}
</td>

View File

@@ -80,14 +80,19 @@ export function CommandSearch() {
} as const;
return (
<div ref={wrapperRef} className="relative">
{/* ── Single persistent search bar ── */}
// Width is now driven by the parent slot (topbar centers a 360640px
// column). Removed fixed widths so the bar fills its container instead
// of shrinking to the old fixed pixel sizes.
<div ref={wrapperRef} className="relative w-full">
{/* Single persistent search bar.
Focus state is intentionally subtle: a 1px brand-coloured border,
no fat outer glow. The earlier `ring-4 ring-brand/15` produced a
chunky pale-blue rectangle that read as a stray UI element rather
than a focus indicator. */}
<div
className={cn(
'flex items-center gap-2 rounded-md border bg-background px-2.5 shadow-xs transition-all duration-base ease-smooth',
focused
? 'border-brand/60 ring-4 ring-brand/15 w-64 lg:w-80'
: 'border-input w-44 lg:w-60',
'flex items-center gap-2 rounded-lg border bg-background px-3 shadow-xs transition-colors w-full',
focused ? 'border-brand/70' : 'border-input',
)}
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -98,16 +103,18 @@ export function CommandSearch() {
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onKeyDown={onInputKeyDown}
placeholder="Search..."
className="h-8 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 placeholder:text-muted-foreground"
placeholder="Search clients, yachts, berths... (⌘K)"
className="h-9 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 placeholder:text-muted-foreground"
/>
</div>
{/* ── Results dropdown ── */}
{showDropdown && (
<div className="absolute top-[calc(100%+4px)] left-0 w-[min(420px,calc(100vw-2rem))] z-50 rounded-md border bg-popover shadow-lg overflow-hidden">
// Dropdown width matches the search input (full width of the slot),
// capped on viewport so it doesn't bleed past the screen edge.
<div className="absolute top-[calc(100%+4px)] left-0 w-full max-w-[min(640px,calc(100vw-2rem))] z-50 rounded-md border bg-popover shadow-lg overflow-hidden">
<div className="max-h-[340px] overflow-y-auto py-1">
{/* No query yet show recent or hint */}
{/* No query yet - show recent or hint */}
{!hasQuery && recentSearches.length > 0 && (
<div>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Recent</div>
@@ -260,7 +267,7 @@ function ResultGroup({
);
}
// Keep export for backwards compat it's a no-op
// Keep export for backwards compat - it's a no-op
export function SearchTrigger() {
return null;
}

View File

@@ -186,7 +186,7 @@ function AddressCard({
<CountryFieldInline
value={address.countryIso}
onSave={async (iso) => {
// Clear subdivision if country changes codes are scoped per country.
// Clear subdivision if country changes - codes are scoped per country.
const patch: AddressPatch = { countryIso: iso };
if (iso !== address.countryIso) patch.subdivisionIso = null;
await onUpdate(patch);
@@ -256,7 +256,7 @@ function CountryFieldInline({
}}
clearable
className="w-full"
// Drop the user straight into the picker no extra click on the
// Drop the user straight into the picker - no extra click on the
// trigger required.
defaultOpen
onOpenChange={(open) => {

View File

@@ -3,7 +3,7 @@ const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
/**
* Branded shell shared by every auth/form surface CRM login, portal login,
* Branded shell shared by every auth/form surface - CRM login, portal login,
* password set/reset/activate, forgot-password. Renders the blurred Port
* Nimara overhead background, the circular logo, and a centered white card
* that consumers populate with their own form/content.
@@ -12,7 +12,7 @@ export function BrandedAuthShell({ children }: { children: React.ReactNode }) {
return (
<div className="relative min-h-screen min-h-[100dvh] flex items-center justify-center px-4 py-8">
{/*
Full-viewport background layer pinned to the visible viewport via
Full-viewport background layer - pinned to the visible viewport via
`fixed inset-0` so the marina image always reaches the actual screen
edges regardless of the iOS Safari URL bar showing/hiding. The shell's
layout layer above sits on top via z-index.

View File

@@ -23,7 +23,7 @@ interface ConfirmationDialogProps {
confirmLabel?: string;
/** Label for the cancel button (default: "Cancel") */
cancelLabel?: string;
/** Whether the confirm action is destructive renders in red (default: true) */
/** Whether the confirm action is destructive - renders in red (default: true) */
destructive?: boolean;
/** Called when the user confirms the action */
onConfirm: () => void | Promise<void>;

View File

@@ -141,7 +141,7 @@ export function CountryCombobox({
{options.map((opt) => (
<CommandItem
key={opt.code}
// cmdk filters by `value` include both code + name.
// cmdk filters by `value` - include both code + name.
value={`${opt.name} ${opt.code}`}
onSelect={() => {
onChange(opt.code);

View File

@@ -20,7 +20,7 @@ import {
* Filters and sort live above the rendered rows; callers pass them as
* `headerSlot`. On desktop the rows are sortable via column header clicks
* (TanStack default); on mobile, sort is exposed via a `<Drawer>` opened by
* the caller's headerSlot this primitive doesn't enforce a sort UI.
* the caller's headerSlot - this primitive doesn't enforce a sort UI.
*/
export function DataView<TData>({
table,

View File

@@ -41,7 +41,7 @@ export function DetailPageShell({
return (
<div className={cn('flex flex-col min-h-full', className)}>
{/* Desktop-only sticky header mobile topbar covers this on small viewports. */}
{/* Desktop-only sticky header - mobile topbar covers this on small viewports. */}
<div className="hidden sm:block sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 sm:px-6">
<div className="flex items-center gap-3 min-w-0">
<h2 className="truncate text-lg font-semibold text-foreground">{entityName}</h2>
@@ -49,7 +49,7 @@ export function DetailPageShell({
</div>
</div>
{/* Mobile inline status row only shown when the page wants to display a status pill. */}
{/* Mobile inline status row - only shown when the page wants to display a status pill. */}
{status ? (
<div className="sm:hidden flex items-center justify-end px-1 pt-1">
<div className="shrink-0">{status}</div>

View File

@@ -24,7 +24,7 @@ interface InlineCountryFieldProps {
export function InlineCountryField({
value,
onSave,
emptyText = '',
emptyText = '-',
disabled,
className,
'data-testid': testId,

View File

@@ -51,7 +51,7 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '', className, disabled } = props;
const { value, onSave, placeholder, emptyText = '-', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);

View File

@@ -35,7 +35,7 @@ export function InlinePhoneField({
defaultCountry,
onSave,
onEditingChange,
emptyText = '',
emptyText = '-',
disabled,
className,
'data-testid': testId,

View File

@@ -17,7 +17,7 @@ interface Tag {
}
export interface InlineTagEditorProps {
/** PUT endpoint for replacing the entity's tag list body shape `{ tagIds: string[] }`. */
/** PUT endpoint for replacing the entity's tag list - body shape `{ tagIds: string[] }`. */
endpoint: string;
currentTags: Tag[];
/** TanStack Query key to invalidate after a successful change. */

View File

@@ -24,7 +24,7 @@ export function InlineTimezoneField({
value,
onSave,
countryHint,
emptyText = '',
emptyText = '-',
disabled,
className,
'data-testid': testId,

View File

@@ -11,12 +11,12 @@ interface ListCardProps {
href: string;
/**
* Optional Tailwind background class painted on a 3px vertical strip on the
* left edge used to encode pipeline stage / status / category at a glance.
* left edge - used to encode pipeline stage / status / category at a glance.
* Pass `undefined` for entities with no status to surface (clients, etc.).
*/
accentClassName?: string;
/**
* Top-right action slot typically a `<DropdownMenu>` for edit/archive.
* Top-right action slot - typically a `<DropdownMenu>` for edit/archive.
* Rendered absolutely-positioned outside the navigation Link so its clicks
* don't trigger detail navigation.
*/
@@ -71,7 +71,7 @@ export function ListCard({
interface ListCardAvatarProps {
/** Two-letter initials (or one for single-word names). Caller derives. */
initials?: string;
/** Domain icon (Lucide). Used when the entity isn't a person yacht, berth, company. */
/** Domain icon (Lucide). Used when the entity isn't a person - yacht, berth, company. */
icon?: ReactNode;
className?: string;
}

View File

@@ -6,7 +6,7 @@ interface LoadingSkeletonProps {
}
/**
* Table skeleton mimics a data table with header + rows.
* Table skeleton - mimics a data table with header + rows.
*/
export function TableSkeleton({ rows = 6, columns = 5 }: { rows?: number; columns?: number }) {
return (
@@ -27,10 +27,7 @@ export function TableSkeleton({ rows = 6, columns = 5 }: { rows?: number; column
)}
>
{Array.from({ length: columns }).map((_, colIdx) => (
<Skeleton
key={colIdx}
className={cn('h-4', colIdx === 0 ? 'w-1/4' : 'flex-1')}
/>
<Skeleton key={colIdx} className={cn('h-4', colIdx === 0 ? 'w-1/4' : 'flex-1')} />
))}
</div>
))}
@@ -39,7 +36,7 @@ export function TableSkeleton({ rows = 6, columns = 5 }: { rows?: number; column
}
/**
* Card skeleton mimics a content card.
* Card skeleton - mimics a content card.
*/
export function CardSkeleton({ className }: LoadingSkeletonProps) {
return (
@@ -59,7 +56,7 @@ export function CardSkeleton({ className }: LoadingSkeletonProps) {
}
/**
* Form skeleton mimics a form with labeled inputs.
* Form skeleton - mimics a form with labeled inputs.
*/
export function FormSkeleton({ fields = 4 }: { fields?: number }) {
return (
@@ -79,7 +76,7 @@ export function FormSkeleton({ fields = 4 }: { fields?: number }) {
}
/**
* Grid skeleton a responsive card grid.
* Grid skeleton - a responsive card grid.
*/
export function GridSkeleton({ cards = 6 }: { cards?: number }) {
return (
@@ -92,7 +89,7 @@ export function GridSkeleton({ cards = 6 }: { cards?: number }) {
}
/**
* Page-level loading skeleton header + content area.
* Page-level loading skeleton - header + content area.
*/
export function PageSkeleton() {
return (

View File

@@ -64,7 +64,7 @@ export function OwnerPicker({
const options = data?.data ?? [];
// Selected display label show entity's name from current options if
// Selected display label - show entity's name from current options if
// available, otherwise a truncated id fallback.
const selectedLabel = (() => {
if (!value) return placeholder;

View File

@@ -21,7 +21,7 @@ interface PageHeaderProps {
* existing call-sites stay unchanged.
*
* Mobile-aware: below sm (640px) the title/eyebrow/description/gradient
* frame all collapse the page title is already shown by the mobile topbar,
* frame all collapse - the page title is already shown by the mobile topbar,
* so duplicating it in the body wastes scroll real estate. What remains is a
* flush right-aligned action row (or nothing if there are no actions). On sm+
* the full strip with title+description renders as before.
@@ -48,7 +48,10 @@ export function PageHeader({
{/* Desktop: full strip with title, eyebrow, description, kpi line, actions. */}
<div
className={cn(
'hidden sm:flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-start sm:justify-between sm:gap-4',
// Removed `sm:mb-6` - the parent shell already provides
// appropriate gap-y between header and the next section, and the
// double-spacing produced an oversized top margin on dashboards.
'hidden sm:flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4',
isGradient &&
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
className,

View File

@@ -11,7 +11,7 @@ import { detectDefaultCountry, type CountryCode } from '@/lib/i18n/countries';
export interface PhoneInputValue {
/** E.164 form ('+442079460958'). Null when empty or unparseable. */
e164: string | null;
/** Country selected in the dropdown drives the AsYouType formatter. */
/** Country selected in the dropdown - drives the AsYouType formatter. */
country: CountryCode;
}
@@ -32,7 +32,7 @@ interface PhoneInputProps {
* Phone input with a country flag dropdown + format-as-you-type.
*
* Wire shape: emits `{ e164, country }` on every change. E.164 is null
* while the input is too short to parse that's a form-validation
* while the input is too short to parse - that's a form-validation
* concern, not an input concern. Pasting an international number
* (`+1 415…`) auto-switches the country dropdown to match.
*
@@ -62,7 +62,7 @@ export function PhoneInput({
const parsed = parsePhone(value.e164, value.country);
return parsed.national ?? '';
});
// Track whether the user has typed since mount keeps a controlled-from-props
// Track whether the user has typed since mount - keeps a controlled-from-props
// value sync on first render only.
const initialized = useRef(false);
@@ -78,7 +78,7 @@ export function PhoneInput({
function handleInput(raw: string) {
// Paste-detect: if user pasted an international format, parse it
// and flip the country dropdown to match better UX than asking
// and flip the country dropdown to match - better UX than asking
// them to also click the dropdown.
if (raw.startsWith('+')) {
const parsed = parsePhone(raw);

View File

@@ -11,7 +11,7 @@ import { stageLabel } from '@/lib/constants';
* toasts. Mounted once inside SocketProvider so reps see "EOI signed",
* "Deposit recorded", "Stage advanced" without having to refresh.
*
* Render-only no children. Intentionally narrow in scope: only toast on
* Render-only - no children. Intentionally narrow in scope: only toast on
* events that are noteworthy *to a user staring at any page*. Per-page
* cache invalidations stay in `useRealtimeInvalidation`.
*/
@@ -36,7 +36,7 @@ export function RealtimeToasts() {
}
function onDocumentCompleted(payload: { type?: string }) {
// Kick a generic "fully signed" the type-specific message is
// Kick a generic "fully signed" - the type-specific message is
// friendlier when we can identify it as an EOI.
if (payload?.type === 'eoi') {
toast.success('EOI fully signed', {
@@ -64,7 +64,7 @@ export function RealtimeToasts() {
const isWon = payload.outcome === 'won';
const label = payload.outcome.replace(/_/g, ' ');
const fn = isWon ? toast.success : toast.message;
fn(`Interest closed ${label}`);
fn(`Interest closed - ${label}`);
}
socket.on('interest:stageChanged', onStageChanged);

View File

@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
// Mobile: full-screen sheet anchored to all four sides via individual
// top/right/bottom/left utilities. Desktop (sm+): override each side
// individually so tailwind-merge doesn't collapse our centering classes.
// (Don't use `inset-0` + `sm:inset-auto` here twMerge sees that as a
// (Don't use `inset-0` + `sm:inset-auto` here - twMerge sees that as a
// conflict and silently strips `sm:left-[50%]` / `sm:top-[50%]`.)
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-6 shadow-lg duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',

View File

@@ -7,7 +7,7 @@ interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
value: React.ReactNode;
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
delta?: number;
/** Pre-rendered sparkline (recharts) caller decides shape. */
/** Pre-rendered sparkline (recharts) - caller decides shape. */
sparkline?: React.ReactNode;
/** Optional accent stripe colour token; defaults to brand. */
accent?: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';

View File

@@ -4,7 +4,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Status pill a single visual primitive for "this thing is in state X" across
* Status pill - a single visual primitive for "this thing is in state X" across
* documents, signers, reservations, interests. Replaces ad-hoc Badge variants
* sprinkled through detail pages so the colour mapping stays consistent.
*/
@@ -41,7 +41,7 @@ export type StatusPillStatus = NonNullable<VariantProps<typeof statusPillVariant
interface StatusPillProps
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof statusPillVariants> {
/** Optional leading dot useful for "in-progress" style indicators. */
/** Optional leading dot - useful for "in-progress" style indicators. */
withDot?: boolean;
}

View File

@@ -40,12 +40,12 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
// Prefer metric dimensions; fall back to imperial
let dimText: string | null = null;
if (yacht.lengthM || yacht.widthM) {
const l = yacht.lengthM ?? '';
const w = yacht.widthM ?? '';
const l = yacht.lengthM ?? '-';
const w = yacht.widthM ?? '-';
dimText = `${l}m × ${w}m`;
} else if (yacht.lengthFt || yacht.widthFt) {
const l = yacht.lengthFt ?? '';
const w = yacht.widthFt ?? '';
const l = yacht.lengthFt ?? '-';
const w = yacht.widthFt ?? '-';
dimText = `${l}ft × ${w}ft`;
}

View File

@@ -45,13 +45,13 @@ const STATUS_LABELS: Record<string, string> = {
function formatDimensions(yacht: YachtRow): string | null {
if (yacht.lengthFt || yacht.widthFt) {
const length = yacht.lengthFt ?? '';
const width = yacht.widthFt ?? '';
const length = yacht.lengthFt ?? '-';
const width = yacht.widthFt ?? '-';
return `${length} × ${width} ft`;
}
if (yacht.lengthM || yacht.widthM) {
const length = yacht.lengthM ?? '';
const width = yacht.widthM ?? '';
const length = yacht.lengthM ?? '-';
const width = yacht.widthM ?? '-';
return `${length} × ${width} m`;
}
return null;
@@ -103,7 +103,7 @@ export function getYachtColumns({
enableSorting: false,
cell: ({ row }) => {
const dims = formatDimensions(row.original);
if (!dims) return <span className="text-muted-foreground"></span>;
if (!dims) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{dims}</span>;
},
},
@@ -114,7 +114,7 @@ export function getYachtColumns({
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>;
},
},

View File

@@ -68,7 +68,7 @@ export function OwnerLink({
* per-row fetch (avoids an N+1 round-trip on lists). */
preloadedName?: string | null;
}) {
// Only fetch when the parent didn't already supply a name list endpoints
// Only fetch when the parent didn't already supply a name - list endpoints
// batch-resolve owners server-side via a join.
const { data } = useQuery<{ fullName?: string; name?: string }>({
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],

View File

@@ -117,7 +117,7 @@ export function YachtForm({ open, onOpenChange, yacht }: YachtFormProps) {
const mutation = useMutation({
mutationFn: async (data: CreateYachtInput) => {
if (isEdit) {
// updateYachtSchema omits owner + tagIds strip them from PATCH body.
// updateYachtSchema omits owner + tagIds - strip them from PATCH body.
const { owner: _owner, tagIds: _tIds, ...rest } = data;
void _owner;
void _tIds;

View File

@@ -43,7 +43,7 @@ const REASON_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();
@@ -109,10 +109,10 @@ export function YachtOwnershipHistory({ yachtId }: YachtOwnershipHistoryProps) {
<TableCell className="text-sm text-muted-foreground">
{row.transferReason
? (REASON_LABELS[row.transferReason] ?? row.transferReason)
: ''}
: '-'}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[320px] truncate">
{row.transferNotes ?? ''}
{row.transferNotes ?? '-'}
</TableCell>
</TableRow>
))}

View File

@@ -190,7 +190,7 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
variant="textarea"
value={yacht.notes}
onSave={save('notes')}
emptyText="No notes click to add"
emptyText="No notes - click to add"
/>
</div>
@@ -238,7 +238,7 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
{i.pipelineStage.replace(/_/g, ' ')}
</span>
<span className="flex-1 truncate">{i.clientName ?? ''}</span>
<span className="flex-1 truncate">{i.clientName ?? '-'}</span>
{i.berthMooringNumber && (
<span className="shrink-0 text-xs text-muted-foreground">
Berth {i.berthMooringNumber}