chore(style): codebase em-dash sweep + minor layout polish
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:
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -363,7 +363,7 @@ export function SettingsManager() {
|
||||
);
|
||||
void saveSetting(setting.key, parsed);
|
||||
} catch {
|
||||
// invalid JSON — do nothing
|
||||
// invalid JSON - do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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's primary email — or to the
|
||||
Sends a 7-day signed download link to the client's primary email - or to the
|
||||
override below.
|
||||
</p>
|
||||
{emailToClient ? (
|
||||
|
||||
@@ -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).';
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function ActivityFeedInner() {
|
||||
<CardContent>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No recent activity yet — your team's actions (interests created, stages changed,
|
||||
No recent activity yet - your team's actions (interests created, stages changed,
|
||||
invoices sent) will appear here.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, ' ')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 "Open" 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '—'} · {exp.expenseDate}
|
||||
{exp.category ?? '-'} · {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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -208,7 +208,7 @@ export function ReservationList({
|
||||
View contract
|
||||
</button>
|
||||
) : (
|
||||
'—'
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 360–640px
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,7 @@ interface InlineCountryFieldProps {
|
||||
export function InlineCountryField({
|
||||
value,
|
||||
onSave,
|
||||
emptyText = '—',
|
||||
emptyText = '-',
|
||||
disabled,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function InlinePhoneField({
|
||||
defaultCountry,
|
||||
onSave,
|
||||
onEditingChange,
|
||||
emptyText = '—',
|
||||
emptyText = '-',
|
||||
disabled,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -24,7 +24,7 @@ export function InlineTimezoneField({
|
||||
value,
|
||||
onSave,
|
||||
countryHint,
|
||||
emptyText = '—',
|
||||
emptyText = '-',
|
||||
disabled,
|
||||
className,
|
||||
'data-testid': testId,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user