fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc

UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 15:56:11 +02:00
parent 8c669e2918
commit 449b9497ab
59 changed files with 1831 additions and 631 deletions

View File

@@ -32,7 +32,13 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
generate_eoi: false,
export: false,
},
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
berths: {
view: false,
edit: false,
import: false,
manage_waiting_list: false,
update_prices: false,
},
documents: {
view: false,
create: false,

View File

@@ -104,7 +104,7 @@ const KNOWN_SETTINGS: Array<{
description:
'Reply-to email shown in client confirmation emails when a new interest is registered',
type: 'string',
defaultValue: 'sales@portnimara.com',
defaultValue: '',
},
{
key: 'inquiry_notification_recipients',
@@ -129,8 +129,8 @@ const KNOWN_SETTINGS: Array<{
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
type: 'json',
defaultValue: {
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
developer: { name: '', email: '' },
approver: { name: '', email: '' },
},
},
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────

View File

@@ -67,7 +67,7 @@ const PERMISSION_LEAVES: Record<string, string[]> = {
'generate_eoi',
'export',
],
berths: ['view', 'edit', 'import', 'manage_waiting_list'],
berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'],
documents: [
'view',
'create',

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -22,6 +22,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input';
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
@@ -107,6 +108,18 @@ export function ClientForm({
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
const tagIds = watch('tagIds') ?? [];
// Primary-address fields. Live outside RHF because the API splits
// client creation (`POST /api/v1/clients`) from address creation
// (`POST /api/v1/clients/{id}/addresses`) — the address gets chained
// after the client POST returns the new id. Edit mode uses the
// dedicated Addresses tab; the form here is create-only.
const [addressOpen, setAddressOpen] = useState(false);
const [addrStreet, setAddrStreet] = useState('');
const [addrCity, setAddrCity] = useState('');
const [addrSubdivisionIso, setAddrSubdivisionIso] = useState<string | null>(null);
const [addrPostal, setAddrPostal] = useState('');
const [addrCountryIso, setAddrCountryIso] = useState<string | null>(null);
// When the rep picks a country and no timezone is set yet, pre-fill the
// timezone with the country's primary IANA zone. Skips when the user has
// already chosen a zone explicitly so we never clobber a deliberate pick.
@@ -169,10 +182,21 @@ export function ClientForm({
reset({
fullName: prefill?.fullName ?? '',
contacts,
source: prefill?.source,
// Default a manually-created client to `source='manual'` so reps
// don't have to remember to pick it. The inquiry-inbox triage
// flow overrides this via `prefill.source='website'`; the global
// command-search quick-create has no `prefill.source` and
// therefore correctly lands on 'manual'.
source: prefill?.source ?? 'manual',
sourceInquiryId: prefill?.sourceInquiryId,
tagIds: [],
});
setAddressOpen(false);
setAddrStreet('');
setAddrCity('');
setAddrSubdivisionIso(null);
setAddrPostal('');
setAddrCountryIso(null);
}
}, [client, open, reset, prefill]);
@@ -224,7 +248,39 @@ export function ClientForm({
});
}
} else {
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
method: 'POST',
body: payload,
});
// Chain the address POST when any field is filled. Address errors
// don't unwind the client create — surface a toast warning and
// leave the client in place so the rep can finish in the
// Addresses tab.
const hasAddress =
addrStreet.trim() ||
addrCity.trim() ||
addrPostal.trim() ||
addrSubdivisionIso ||
addrCountryIso;
if (hasAddress) {
try {
await apiFetch(`/api/v1/clients/${res.data.id}/addresses`, {
method: 'POST',
body: {
streetAddress: addrStreet.trim() || null,
city: addrCity.trim() || null,
subdivisionIso: addrSubdivisionIso ?? undefined,
postalCode: addrPostal.trim() || null,
countryIso: addrCountryIso ?? undefined,
isPrimary: true,
},
});
} catch {
toast.error(
'Client created but the address could not be saved. Add it from the Addresses tab.',
);
}
}
}
},
onSuccess: () => {
@@ -533,6 +589,95 @@ export function ClientForm({
<Separator />
{/* Primary Address — create-only. Editing happens in the
client detail page's Addresses tab. */}
{!isEdit ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Primary Address
</h3>
{!addressOpen ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAddressOpen(true)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
Add address
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setAddressOpen(false);
setAddrStreet('');
setAddrCity('');
setAddrSubdivisionIso(null);
setAddrPostal('');
setAddrCountryIso(null);
}}
>
Remove
</Button>
)}
</div>
{addressOpen ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1">
<Label>Street address</Label>
<Input
value={addrStreet}
onChange={(e) => setAddrStreet(e.target.value)}
placeholder="123 Marina Way, Suite 4"
/>
</div>
<div className="space-y-1">
<Label>City</Label>
<Input
value={addrCity}
onChange={(e) => setAddrCity(e.target.value)}
placeholder="Anguilla"
/>
</div>
<div className="space-y-1">
<Label>Postal code</Label>
<Input
value={addrPostal}
onChange={(e) => setAddrPostal(e.target.value)}
placeholder="AI-2640"
/>
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={addrCountryIso}
onChange={(iso) => {
setAddrCountryIso(iso ?? null);
// Clear region if country changes — keeps the
// subdivision picker consistent with its country.
setAddrSubdivisionIso(null);
}}
/>
</div>
<div className="space-y-1">
<Label>Region / State</Label>
<SubdivisionCombobox
country={addrCountryIso as CountryCode | null}
value={addrSubdivisionIso}
onChange={(iso) => setAddrSubdivisionIso(iso)}
/>
</div>
</div>
) : null}
</div>
) : null}
{!isEdit ? <Separator /> : null}
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>

View File

@@ -75,9 +75,17 @@ interface CompanyFormProps {
billingEmail: string | null;
notes: string | null;
};
/**
* Optional initial values for the create flow — used by the global
* command-search quick-create ("New company 'matthew'" → lands on
* `/companies?create=1&prefill_name=matthew`). Ignored in edit mode.
*/
prefill?: {
name?: string;
};
}
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFormProps) {
const queryClient = useQueryClient();
const router = useRouter();
const isEdit = !!company;
@@ -141,10 +149,10 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
tagIds: [],
});
} else if (!company && open) {
reset({ name: '', status: 'active', tagIds: [] });
reset({ name: prefill?.name ?? '', status: 'active', tagIds: [] });
}
setFormError(null);
}, [company, open, reset]);
}, [company, open, reset, prefill]);
const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => {

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -49,6 +49,16 @@ export function CompanyList() {
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
// Quick-create from the global command-search lands here with
// `?create=1&prefill_name=…`. Hydrate the company-form's name field
// so the rep doesn't retype.
const searchParams = useSearchParams();
const createPrefill = useMemo(() => {
const name = searchParams?.get('prefill_name');
return name ? { name } : undefined;
}, [searchParams]);
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
@@ -274,7 +284,7 @@ export function CompanyList() {
</DialogContent>
</Dialog>
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} />
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
{editCompany && (
<CompanyForm

View File

@@ -35,10 +35,11 @@ export function ActiveDealsTile() {
return (
<Card>
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
with `pt-0` (it assumes a CardHeader sits above). Without these
overrides the tile content snaps to the top edge of the card. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
CardHeader sits above). The `sm:` variants are required — without
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
the top edge. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<TrendingUp className="size-5" aria-hidden />
</div>

View File

@@ -1,19 +1,20 @@
'use client';
/**
* Berth Heat widget — ranked table of berths by active interest count.
* Investor-friendly "where is the demand pressure?" surface. Renders
* a sortable table that exports cleanly to PDF/CSV. A future heatmap
* visualization can sit beside this table reading the same data.
* Berth-demand widget — ranks berths by active interest count, with a
* horizontal bar per row encoding magnitude relative to the leader.
* Matches the standard CardHeader / CardContent layout of its dashboard
* siblings; the bars (not chrome) do the visual work.
*/
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { ArrowRight, TrendingUp } from 'lucide-react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { StatusPill } from '@/components/ui/status-pill';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface HeatRow {
berthId: string;
@@ -27,13 +28,16 @@ interface HeatResponse {
data: { rows: HeatRow[] };
}
// Render the raw status — StatusPill recognizes 'available' /
// 'under_offer' / 'sold' as canonical tokens and applies the right tone.
function statusToken(s: string): 'available' | 'under_offer' | 'sold' | 'pending' {
if (s === 'available' || s === 'under_offer' || s === 'sold') return s;
return 'pending';
function formatStatus(s: string): string {
return s.replace(/_/g, ' ');
}
const BAR_COLOR_BY_STATUS: Record<string, string> = {
available: 'bg-success/70',
under_offer: 'bg-warning/80',
sold: 'bg-error/60',
};
export function BerthHeatWidget() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -43,52 +47,87 @@ export function BerthHeatWidget() {
staleTime: 60_000,
});
const ranked = (data?.data?.rows ?? []).filter((r) => r.activeInterestCount > 0);
const visible = ranked.slice(0, 6);
const max = visible[0]?.activeInterestCount ?? 1;
const totalActive = ranked.reduce((sum, r) => sum + r.activeInterestCount, 0);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Berth heat</CardTitle>
<CardDescription>Top 15 berths by active interest count.</CardDescription>
<CardTitle className="flex items-center gap-1.5 text-base">
<TrendingUp className="size-4 text-brand-600" aria-hidden />
Berth demand
</CardTitle>
<CardDescription>
{ranked.length > 0
? `${totalActive} active interest${totalActive === 1 ? '' : 's'} across ${ranked.length} ${ranked.length === 1 ? 'berth' : 'berths'}.`
: 'Berths ranked by active interest count.'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden /> Loading
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" aria-hidden />
))}
</div>
) : !data || data.data.rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No active interests yet.</p>
) : visible.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No active demand yet. Berths will appear here as interests land.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="py-1.5 text-left font-medium">Berth</th>
<th className="py-1.5 text-left font-medium">Dock</th>
<th className="py-1.5 text-left font-medium">Status</th>
<th className="py-1.5 text-right font-medium">Interests</th>
</tr>
</thead>
<tbody>
{data.data.rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-b-0">
<td className="py-1.5">
<>
<ul className="space-y-1">
{visible.map((r) => {
const widthPct = Math.max(6, Math.round((r.activeInterestCount / max) * 100));
return (
<li key={r.berthId}>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/berths/${r.berthId}` as any}
className="font-medium hover:underline"
className="-mx-2 grid grid-cols-[3.25rem_1fr_2rem] items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
>
{r.mooringNumber}
<span className="truncate font-mono text-sm font-medium text-foreground">
{r.mooringNumber}
</span>
<div className="min-w-0">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
<span
aria-hidden
className={cn(
'h-full rounded-full',
BAR_COLOR_BY_STATUS[r.status] ?? 'bg-brand-400',
)}
style={{ width: `${widthPct}%` }}
/>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{r.area ?? 'Unassigned'} ·{' '}
<span className="capitalize">{formatStatus(r.status)}</span>
</p>
</div>
<span className="text-right text-sm font-semibold text-foreground tabular-nums">
{r.activeInterestCount}
</span>
</Link>
</td>
<td className="py-1.5 text-muted-foreground">{r.area ?? '—'}</td>
<td className="py-1.5">
<StatusPill status={statusToken(r.status)}>
{r.status.replace(/_/g, ' ')}
</StatusPill>
</td>
<td className="py-1.5 text-right font-semibold">{r.activeInterestCount}</td>
</tr>
))}
</tbody>
</table>
</li>
);
})}
</ul>
{ranked.length > visible.length ? (
<div className="mt-3 border-t pt-2 text-right">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/berths?sort=activeInterestCount&order=desc` as any}
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
View all by demand
<ArrowRight className="size-3" aria-hidden />
</Link>
</div>
) : null}
</>
)}
</CardContent>
</Card>

View File

@@ -1,57 +1,230 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DollarSign } from 'lucide-react';
import { AlertTriangle, Info } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/skeleton';
import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants';
import { formatCurrency } from '@/lib/utils/currency';
import { cn } from '@/lib/utils';
interface KpiResponse {
pipelineValue: number;
pipelineValueCurrency: string;
activeInterests: number;
}
interface StageRow {
stage: string;
count: number;
grossValue: number;
weightedValue: number;
weight: number;
dealsMissingPrice: number;
}
interface ForecastResponse {
totalGrossValue: number;
totalWeightedValue: number;
stageBreakdown: StageRow[];
weightsSource: 'db' | 'default';
}
// Same brand-coloured family the pipeline-funnel chart uses so the two
// surfaces feel anchored to the same palette.
const STAGE_BAR_CLASS: Record<string, string> = {
enquiry: 'bg-slate-300',
qualified: 'bg-brand-200',
nurturing: 'bg-brand-300',
eoi: 'bg-brand-400',
reservation: 'bg-amber-400',
deposit_paid: 'bg-orange-400',
contract: 'bg-success/70',
};
/**
* Total pipeline value for active interests, converted to the port's
* default currency at display time. Sourced from the same KPIs endpoint
* as the active-deals tile so the two share a cache entry and render in
* lockstep.
* Headline pipeline value plus a per-stage breakdown showing gross
* value, deal count, and the weighted forecast (gross × stage close-
* probability). Replaces the single-number KPI: leadership can now see
* how much of the headline number is near-close vs speculative.
*
* Pulls from two endpoints: `/kpis` for the gross headline + currency
* and `/forecast` for the weighted breakdown. Both share cache entries
* with other widgets so this is mostly free.
*/
export function PipelineValueTile() {
const { data, isLoading } = useQuery<KpiResponse>({
const kpis = useQuery<KpiResponse>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
});
const forecast = useQuery<ForecastResponse>({
queryKey: ['dashboard', 'forecast'],
queryFn: () => apiFetch<ForecastResponse>('/api/v1/dashboard/forecast'),
staleTime: 60_000,
});
const isLoading = kpis.isLoading || forecast.isLoading;
const currency = kpis.data?.pipelineValueCurrency ?? 'USD';
const grossTotal = kpis.data?.pipelineValue ?? 0;
const weightedTotal = forecast.data?.totalWeightedValue ?? 0;
const activeDeals = kpis.data?.activeInterests ?? 0;
const activeStages = (forecast.data?.stageBreakdown ?? []).filter((s) => s.count > 0);
const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1;
const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 });
return (
<Card>
{/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships
with `pt-0` (it assumes a CardHeader sits above). Without these
overrides the tile content snaps to the top edge of the card. */}
<CardContent className="flex items-center gap-3 pt-5 pb-5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
<DollarSign className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline value
</p>
{isLoading ? (
<Skeleton className="mt-1 h-7 w-24" aria-hidden />
) : (
<p
className="truncate text-2xl font-bold leading-tight text-foreground"
title={formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD')}
<CardHeader>
<CardTitle className="text-base">Pipeline value</CardTitle>
<CardDescription className="flex items-center gap-1.5">
<span>
{activeDeals > 0
? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability`
: 'Gross berth value across active deals, with weighted forecast.'}
</span>
<Popover>
<PopoverTrigger
type="button"
aria-label="How does the weighted forecast work?"
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
>
{formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD', {
maxFractionDigits: 0,
})}
<Info className="size-3.5" aria-hidden />
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
<p className="font-semibold text-foreground">How the weighted forecast works</p>
<p className="mt-2 text-muted-foreground">
Each pipeline stage has a close-probability how likely a deal at that stage is to
actually close. Multiplying the berth price by the stage weight gives an{' '}
<strong>expected</strong> value for that deal. Summing across every active deal
yields the weighted forecast a defensible &ldquo;what will likely land&rdquo;
number, vs the gross which assumes every deal closes at full value.
</p>
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
{PIPELINE_STAGES.map((s) => {
const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight;
const weight = dbWeight ?? STAGE_WEIGHTS[s];
return (
<div key={s} className="contents">
<span className="text-muted-foreground">{stageLabel(s)}</span>
<span className="text-right font-medium tabular-nums text-foreground">
{Math.round(weight * 100)}%
</span>
</div>
);
})}
</div>
<p className="mt-3 text-[11px] text-muted-foreground">
{forecast.data?.weightsSource === 'db'
? 'Using per-port weights (admins tune these in Settings → Pipeline).'
: 'Using system defaults. Admins can override per port in Settings → Pipeline.'}
</p>
</PopoverContent>
</Popover>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ── Headline numbers ─────────────────────────────────────── */}
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Gross
</p>
)}
{isLoading ? (
<Skeleton className="mt-1 h-7 w-28" aria-hidden />
) : (
<p
className="truncate text-2xl font-bold leading-tight text-foreground"
title={formatCurrency(grossTotal, currency)}
>
{fmt(grossTotal)}
</p>
)}
</div>
<div className="text-right">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Weighted forecast
</p>
{isLoading ? (
<Skeleton className="ml-auto mt-1 h-6 w-24" aria-hidden />
) : (
<p
className="text-lg font-semibold leading-tight text-foreground tabular-nums"
title={formatCurrency(weightedTotal, currency)}
>
{fmt(weightedTotal)}
</p>
)}
</div>
</div>
{/* ── Per-stage breakdown ─────────────────────────────────── */}
{isLoading ? (
<div className="space-y-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" aria-hidden />
))}
</div>
) : activeStages.length === 0 ? (
<p className="text-sm text-muted-foreground">No active deals with linked berths yet.</p>
) : (
<ul className="space-y-0.5">
{activeStages.map((s) => {
const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100));
return (
<li
key={s.stage}
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-0.5 py-1"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{stageLabel(s.stage)}
</p>
<div className="mt-1 flex h-1 w-full overflow-hidden rounded-full bg-muted">
<span
aria-hidden
className={cn(
'h-full rounded-full',
STAGE_BAR_CLASS[s.stage] ?? 'bg-brand-400',
)}
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-foreground tabular-nums">
{fmt(s.grossValue)}
</p>
<p className="text-[11px] text-muted-foreground tabular-nums">
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
</p>
{s.dealsMissingPrice > 0 ? (
<p
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
>
<AlertTriangle className="size-3" aria-hidden />
{s.dealsMissingPrice === s.count
? 'berth price missing'
: `${s.dealsMissingPrice} of ${s.count} missing price`}
</p>
) : null}
</div>
</li>
);
})}
</ul>
)}
{forecast.data?.weightsSource === 'default' ? (
<p className="text-[11px] text-muted-foreground">
Using default stage weights. Tune them in Settings Pipeline.
</p>
) : null}
</CardContent>
</Card>
);

View File

@@ -1,87 +0,0 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { EmptyState } from '@/components/shared/empty-state';
import { ChartCard } from './chart-card';
import { useRevenue } from './use-analytics';
import type { DateRange } from '@/lib/services/analytics.service';
import { rangeToSlug } from '@/lib/analytics/range';
import { formatCurrency } from '@/lib/utils/currency';
interface Props {
range: DateRange;
}
const STATUS_LABELS: Record<string, string> = {
draft: 'Draft',
sent: 'Sent',
paid: 'Paid',
overdue: 'Overdue',
cancelled: 'Cancelled',
};
export function RevenueBreakdownChart({ range }: Props) {
const { data, isLoading } = useRevenue(range);
const bars = data?.bars ?? [];
function toCsv(): string | null {
if (!bars.length) return null;
const header = 'status,currency,amount';
const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`);
return [header, ...rows].join('\n');
}
const chartData = bars.map((b) => ({
label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`,
amount: b.amount,
currency: b.currency,
}));
return (
<ChartCard
title="Revenue Breakdown"
description="Invoice totals grouped by status and currency"
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
toCsv={toCsv}
>
{isLoading ? (
<CardSkeleton />
) : !bars.length ? (
<EmptyState
title="No invoices in range"
description="Issued, paid, and overdue totals appear here once you create invoices."
/>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-30}
textAnchor="end"
interval={0}
/>
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, _name, item) => {
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD';
const num = typeof value === 'number' ? value : Number(value);
return [formatCurrency(num, c), 'Amount'];
}}
/>
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -9,7 +9,6 @@ import type {
MetricBase,
OccupancyTimelineData,
PipelineFunnelData,
RevenueBreakdownData,
} from '@/lib/services/analytics.service';
interface MetricResponse<T> {
@@ -51,7 +50,5 @@ export const useFunnel = (range: DateRange) =>
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
export const useOccupancy = (range: DateRange) =>
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
export const useRevenue = (range: DateRange) =>
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
export const useLeadSource = (range: DateRange) =>
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);

View File

@@ -48,10 +48,6 @@ const PipelineFunnelChart = dynamic(
() => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })),
{ loading: ChartFallback, ssr: false },
);
const RevenueBreakdownChart = dynamic(
() => import('./revenue-breakdown-chart').then((m) => ({ default: m.RevenueBreakdownChart })),
{ loading: ChartFallback, ssr: false },
);
const SourceConversionChart = dynamic(
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
{ loading: ChartFallback, ssr: false },
@@ -123,12 +119,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
{
id: 'kpi_pipeline_value',
label: 'Pipeline Value',
description: 'Total berth value of active deals, converted to the port default currency.',
description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
render: () => <PipelineValueTile />,
group: 'rail',
// Flipped on by default 2026-05-14 — the dashboard wave prioritized
// investor-facing tiles, and this is the headline number leadership
// looks at first.
// Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles.
group: 'chart',
defaultVisible: true,
},
@@ -149,14 +146,6 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
group: 'chart',
defaultVisible: true,
},
{
id: 'revenue_breakdown',
label: 'Revenue Breakdown',
description: 'Invoice totals grouped by status and currency.',
render: (range) => <RevenueBreakdownChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'lead_source',
label: 'Lead Source Attribution',
@@ -186,8 +175,9 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
},
{
id: 'berth_heat',
label: 'Berth Heat',
description: 'Top 15 berths by active interest count. Investor-friendly demand pressure view.',
label: 'Berth Demand',
description:
'Ranks berths by active interest. Surfaces the leading mooring with its runners-up.',
render: () => <BerthHeatWidget />,
group: 'chart',
defaultVisible: true,
@@ -196,7 +186,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
id: 'website_analytics',
label: 'Website Analytics',
description: 'Quick glance at marketing site traffic. Requires Umami.',
render: () => <WebsiteGlanceTile />,
render: (range) => <WebsiteGlanceTile range={range} />,
group: 'rail',
defaultVisible: true,
selfGates: true,

View File

@@ -81,6 +81,14 @@ interface BerthRecommenderPanelProps {
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
/**
* Number of berths already linked to the interest. When ≥ 1 the panel
* defaults to collapsed (header-only) so the LinkedBerthsList card above
* dominates the rep's attention. They can expand to browse more options
* (multi-berth deals, swap recommendations). Zero / undefined keeps the
* panel expanded so reps see options immediately.
*/
linkedBerthCount?: number;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
@@ -358,6 +366,7 @@ export function BerthRecommenderPanel({
desiredWidthFt,
desiredDraftFt,
desiredUnit,
linkedBerthCount,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -370,6 +379,11 @@ export function BerthRecommenderPanel({
// single pier (e.g. "show me only A-row matches"). Client-side over
// the already-fetched result set; no service change required.
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
// Collapse state — defaults to collapsed when the deal already has at
// least one linked berth (recommender becomes a "browse more options"
// tool rather than the primary surface). Reps can manually expand any
// time. Header click toggles.
const [collapsed, setCollapsed] = useState<boolean>((linkedBerthCount ?? 0) > 0);
const hasDimensions = desiredLengthFt !== null;
@@ -380,7 +394,9 @@ export function BerthRecommenderPanel({
const { data, isFetching, refetch } = useQuery({
queryKey,
enabled: hasDimensions,
// Skip the network call when collapsed — no point fetching options
// the rep won't see. Re-fires automatically on expand.
enabled: hasDimensions && !collapsed,
queryFn: () =>
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
@@ -441,32 +457,56 @@ export function BerthRecommenderPanel({
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{!collapsed ? (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions}
>
<Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={!hasDimensions || isFetching}
>
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
Refresh
</Button>
</>
) : null}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setFiltersOpen((v) => !v)}
disabled={!hasDimensions}
variant="ghost"
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
aria-controls={`recommender-body-${interestId}`}
>
<Filter className="mr-1.5 size-3.5" aria-hidden />
{filtersOpen ? 'Hide filters' : 'Add filters'}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={!hasDimensions || isFetching}
>
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
Refresh
{collapsed ? (
<>
<ChevronDown className="mr-1.5 size-3.5" aria-hidden />
Show recommendations
</>
) : (
<>
<ChevronUp className="mr-1.5 size-3.5" aria-hidden />
Hide
</>
)}
</Button>
</div>
</div>
{filtersOpen && hasDimensions ? (
{!collapsed && filtersOpen && hasDimensions ? (
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
) : null}
{hasDimensions && areaChips.length > 1 ? (
{!collapsed && hasDimensions && areaChips.length > 1 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-xs font-medium text-muted-foreground">Area:</span>
{areaChips.map((letter) => {
@@ -504,55 +544,57 @@ export function BerthRecommenderPanel({
</div>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{!hasDimensions ? (
<p className="text-sm text-muted-foreground">
Once length, width, and draft are set on this interest, the recommender will surface
berths that fit. Edit the desired dimensions on the{' '}
<Link href="?tab=overview" className="text-primary underline">
Overview tab
</Link>
.
</p>
) : isFetching && recommendations.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
{collapsed ? null : (
<CardContent className="space-y-3" id={`recommender-body-${interestId}`}>
{!hasDimensions ? (
<p className="text-sm text-muted-foreground">
Once length, width, and draft are set on this interest, the recommender will surface
berths that fit. Edit the desired dimensions on the{' '}
<Link href="?tab=overview" className="text-primary underline">
Overview tab
</Link>
.
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
) : isFetching && recommendations.length === 0 ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
<RecommendationCard
key={rec.berthId}
rec={rec}
portSlug={portSlug}
onAdd={setPendingBerth}
/>
))}
</div>
)}
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
<RecommendationCard
key={rec.berthId}
rec={rec}
portSlug={portSlug}
onAdd={setPendingBerth}
/>
))}
</div>
)}
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}
</CardContent>
</div>
) : null}
</CardContent>
)}
{pendingBerth ? (
<AddBerthToInterestDialog

View File

@@ -108,6 +108,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
clientId: '',
yachtId: undefined,
pipelineStage: 'enquiry',
// Default a manually-created interest's source to 'manual' so the
// rep doesn't have to remember to pick it (mirrors the same
// default on client-form.tsx). Inquiry-inbox / website conversion
// flows can override via prefill once that path lands here.
source: 'manual',
reminderEnabled: false,
tagIds: [],
},

View File

@@ -50,7 +50,6 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
import { PaymentsSection } from '@/components/interests/payments-section';
import { StageGuidanceCard } from '@/components/interests/stage-guidance-card';
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
@@ -59,7 +58,12 @@ import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
type InterestPatchField = 'leadCategory' | 'source';
type InterestPatchField =
| 'leadCategory'
| 'source'
| 'desiredLengthFt'
| 'desiredWidthFt'
| 'desiredDraftFt';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
@@ -381,8 +385,9 @@ function MilestoneSection({
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
{isActive ? (
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
Next
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
Next step
</span>
) : null}
</div>
@@ -844,15 +849,22 @@ function OverviewTab({
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
) : (
// §7.2: replace the empty Payments slot with a stage-aware
// "next step" card on pre-reservation stages so the rep gets
// an actionable prompt instead of dead space.
<StageGuidanceCard
stage={interest.pipelineStage as PipelineStage}
hasLinkedBerth={(interest.linkedBerthCount ?? 0) > 0}
/>
)}
) : null}
{/* Pre-reservation: the dedicated "Next step" guidance card was
removed in favour of a brighter NEXT STEP pill on the active
MilestoneSection below (it already owns the workflow actions —
two surfaces was redundant). Nurturing keeps a slim helper
since no milestone is naturally "current" while a deal is
paused. */}
{interest.pipelineStage === 'nurturing' ? (
<div className="rounded-xl border bg-card p-4 text-sm">
<p className="font-medium text-foreground">Deal is on nurture</p>
<p className="mt-1 text-xs text-muted-foreground">
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
them back to Qualified.
</p>
</div>
) : null}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
@@ -1007,6 +1019,41 @@ function OverviewTab({
</dl>
</div>
{/* Berth requirements — desired length / width / draft. Editable
inline so reps can capture or correct a buyer's needs without
leaving the Overview tab. These values drive the auto-tick on
the "Dimensions confirmed" qualification row + the
BerthRecommenderPanel rankings below. */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
<dl>
<EditableRow label="Desired length (ft)">
<InlineEditableField
value={interest.desiredLengthFt ?? null}
onSave={save('desiredLengthFt')}
placeholder="e.g. 60"
emptyText="—"
/>
</EditableRow>
<EditableRow label="Desired width (ft)">
<InlineEditableField
value={interest.desiredWidthFt ?? null}
onSave={save('desiredWidthFt')}
placeholder="e.g. 25"
emptyText="—"
/>
</EditableRow>
<EditableRow label="Desired draft (ft)">
<InlineEditableField
value={interest.desiredDraftFt ?? null}
onSave={save('desiredDraftFt')}
placeholder="e.g. 6"
emptyText="—"
/>
</EditableRow>
</dl>
</div>
{/* Reminder */}
{interest.reminderEnabled && (
<div className="space-y-1">
@@ -1102,6 +1149,7 @@ function OverviewTab({
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
@@ -1197,7 +1245,7 @@ export function getInterestTabs({
},
{
id: 'recommendations',
label: 'Recommendations',
label: 'Berth Recommendations',
content: (
<BerthRecommenderPanel
interestId={interestId}

View File

@@ -109,14 +109,19 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
return (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event) => {
{events.map((event, idx) => {
const actor = actorLabel(event);
const isAuto = event.userId === 'system';
const isLast = idx === events.length - 1;
return (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Vertical line — only between bubbles, never trailing past the last. */}
{!isLast && (
<span
aria-hidden
className="absolute left-4 top-8 bottom-0 -translate-x-1/2 w-px bg-border"
/>
)}
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
{eventIcon(event)}

View File

@@ -21,6 +21,9 @@ interface QualificationRow {
confirmedBy: string | null;
notes: string | null;
autoSatisfied: boolean;
/** Human-readable explanation of what data drove auto-satisfaction
* (e.g. "Desired: 60 × 25 × 6 ft"). Empty when not auto-satisfied. */
evidence: string;
}
interface QualificationResponse {
@@ -104,9 +107,20 @@ export function QualificationChecklist({
)}
</div>
<ul className="space-y-2">
<ul className="space-y-1.5">
{criteria.map((c) => (
<li key={c.key} className="flex items-start gap-2.5">
<li
key={c.key}
className={cn(
'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
// Unconfirmed rows get a subtle amber accent (left border +
// tinted background) so reps can scan the checklist and
// immediately see what's outstanding. Confirmed rows stay
// muted with line-through; auto-satisfied rows are functionally
// confirmed and follow the confirmed styling.
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
)}
>
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
@@ -125,7 +139,7 @@ export function QualificationChecklist({
className={cn(
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
)}
>
<span className="flex flex-wrap items-center gap-1.5">
@@ -146,6 +160,11 @@ export function QualificationChecklist({
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
) : null}
{c.autoSatisfied && c.evidence ? (
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{c.evidence}
</p>
) : null}
</label>
</li>
))}

View File

@@ -60,7 +60,11 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
return (
<Card>
<CardContent className="space-y-3 p-4">
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it
assumes a CardHeader sits above. This card is intentionally
header-less, so we restore symmetric padding (`pt-` matches `p-`)
at both base and `sm:` breakpoints. */}
<CardContent className="space-y-3 p-4 pt-4 sm:p-6 sm:pt-6">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
<p className="text-xs text-muted-foreground">

View File

@@ -19,6 +19,9 @@ interface AppShellProps {
isSuperAdmin: boolean;
user: NonNullable<SidebarProps['user']>;
ports: TopbarProps['ports'];
/** Per-port logo URLs resolved server-side. Sidebar picks the entry
* matching the currently-active port from the UI store. */
portLogoUrls: Record<string, string | null>;
/**
* Server-rendered form-factor hint (from the request User-Agent). The
* shell mounts the matching tree on first render so we never paint the
@@ -59,6 +62,7 @@ export function AppShell({
isSuperAdmin,
user,
ports,
portLogoUrls,
initialFormFactor,
children,
}: AppShellProps) {
@@ -83,7 +87,13 @@ export function AppShell({
<MobileTopbar />
</>
) : (
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
<Sidebar
portRoles={portRoles}
isSuperAdmin={isSuperAdmin}
user={user}
ports={ports}
portLogoUrls={portLogoUrls}
/>
);
const footer = isMobile ? (

View File

@@ -41,15 +41,16 @@ import type { UserPortRole } from '@/lib/db/schema/users';
import type { Role } from '@/lib/db/schema/users';
import type { Port } from '@/lib/db/schema/ports';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
isSuperAdmin?: boolean;
user?: { name: string; email: string };
/** Ports the user has access to. Drives the footer port switcher. */
ports?: Port[];
/** Per-port logo URLs resolved server-side in the dashboard layout.
* The sidebar header swaps to the current port's logo via the UI
* store's `currentPortId`. Null entries render the wordmark fallback. */
portLogoUrls?: Record<string, string | null>;
}
interface NavItem {
@@ -103,6 +104,22 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
],
},
{
title: 'Insights',
marinaRequired: true,
umamiRequired: true,
items: [
// Marketing / Umami integration. Distinct from the main dashboard
// (which is sales-focused) so the audience and the metrics don't
// compete for visual real estate. Whole section is hidden when
// Umami isn't wired up — see SidebarContent.
{
href: `${base}/website-analytics`,
label: 'Website analytics',
icon: Globe,
},
],
},
{
title: 'Documents',
marinaRequired: true,
@@ -127,22 +144,6 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
},
],
},
{
title: 'Insights',
marinaRequired: true,
umamiRequired: true,
items: [
// Marketing / Umami integration. Distinct from the main dashboard
// (which is sales-focused) so the audience and the metrics don't
// compete for visual real estate. Whole section is hidden when
// Umami isn't wired up — see SidebarContent.
{
href: `${base}/website-analytics`,
label: 'Website analytics',
icon: Globe,
},
],
},
{
title: 'Communication',
marinaRequired: true,
@@ -236,6 +237,8 @@ function SidebarContent({
hasResidentialAccess,
user,
ports,
currentPort,
currentLogoUrl,
onToggleCollapse,
}: {
collapsed: boolean;
@@ -247,6 +250,8 @@ function SidebarContent({
hasResidentialAccess: boolean;
user?: SidebarProps['user'];
ports?: Port[];
currentPort: Port | null;
currentLogoUrl: string | null;
/** When provided, renders the collapse toggle row above the user footer (desktop). */
onToggleCollapse?: () => void;
}) {
@@ -295,15 +300,21 @@ function SidebarContent({
collapsed ? 'h-16 px-2' : 'h-24 px-4',
)}
>
<Image
src={LOGO_URL}
alt="Port Nimara"
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
className="rounded-full shadow-sm"
unoptimized
priority
/>
{currentLogoUrl ? (
<Image
src={currentLogoUrl}
alt={currentPort?.name ?? 'Logo'}
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
className="rounded-full shadow-sm"
unoptimized
priority
/>
) : (
<div className="flex h-12 items-center px-3 text-sm font-semibold text-slate-700">
{currentPort?.name ?? 'CRM'}
</div>
)}
{onToggleCollapse && (
<button
type="button"
@@ -439,12 +450,21 @@ function SidebarContent({
);
}
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
export function Sidebar({
portRoles,
isSuperAdmin = false,
user,
ports,
portLogoUrls,
}: SidebarProps) {
// Sidebar collapse removed — design preference is the always-expanded
// form. Forcibly false; the store flag stays for backwards-compat with
// any code still reading it.
const sidebarCollapsed = false;
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const currentPortId = useUIStore((s) => s.currentPortId);
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
// Super admins see every section regardless of role rows.
const hasAdminAccess =
@@ -478,6 +498,8 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
hasResidentialAccess={hasResidentialAccess}
user={user}
ports={ports}
currentPort={currentPort}
currentLogoUrl={currentLogoUrl}
/>
</aside>
);

View File

@@ -33,6 +33,62 @@ function toLocalDatetimeLocal(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* Build a Date relative to "now" for the quick-pick chips. Day-based
* presets land on the user's preferred time-of-day (`digestTimeOfDay`
* from user_profiles.preferences) — same source the default dueAt uses.
* Hour-based presets use the current time + N hours.
*/
function buildPresetDate(
preset:
| { kind: 'hours'; hours: number }
| { kind: 'tomorrow' }
| { kind: 'in_days'; days: number }
| { kind: 'next_monday' },
timeOfDay: string | null,
): Date {
const now = new Date();
let h = 9;
let m = 0;
if (timeOfDay && /^\d{2}:\d{2}$/.test(timeOfDay)) {
const [hh = '09', mm = '00'] = timeOfDay.split(':');
const parsedH = Number.parseInt(hh, 10);
const parsedM = Number.parseInt(mm, 10);
if (Number.isFinite(parsedH) && parsedH >= 0 && parsedH <= 23) h = parsedH;
if (Number.isFinite(parsedM) && parsedM >= 0 && parsedM <= 59) m = parsedM;
}
if (preset.kind === 'hours') {
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
}
if (preset.kind === 'tomorrow') {
const t = new Date(now);
t.setDate(t.getDate() + 1);
t.setHours(h, m, 0, 0);
return t;
}
if (preset.kind === 'in_days') {
const t = new Date(now);
t.setDate(t.getDate() + preset.days);
t.setHours(h, m, 0, 0);
return t;
}
// next_monday
const t = new Date(now);
const daysUntilMonday = (8 - t.getDay()) % 7 || 7;
t.setDate(t.getDate() + daysUntilMonday);
t.setHours(h, m, 0, 0);
return t;
}
const DUE_PRESETS = [
{ label: 'In 1 hour', spec: { kind: 'hours', hours: 1 } as const },
{ label: 'In 4 hours', spec: { kind: 'hours', hours: 4 } as const },
{ label: 'Tomorrow', spec: { kind: 'tomorrow' } as const },
{ label: 'In 3 days', spec: { kind: 'in_days', days: 3 } as const },
{ label: 'Next week', spec: { kind: 'next_monday' } as const },
{ label: 'In 2 weeks', spec: { kind: 'in_days', days: 14 } as const },
] as const;
interface UserOption {
id: string;
displayName: string;
@@ -230,6 +286,24 @@ function ReminderFormBody({
<div className="grid grid-cols-[2fr_1fr] gap-4">
<div className="space-y-2">
<Label htmlFor="reminder-due">Due Date & Time</Label>
{/* Quick-pick chips. Same idiom as the snooze dialog so reps
don't have to think about month-day-time for the 80%
common cases. Each chip writes the formatted datetime
into the input, leaving the rep free to tweak. */}
<div className="flex flex-wrap gap-1.5">
{DUE_PRESETS.map((p) => (
<button
key={p.label}
type="button"
onClick={() =>
setDueAt(toLocalDatetimeLocal(buildPresetDate(p.spec, userTodPref)))
}
className="rounded-full border bg-background px-2.5 py-0.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
{p.label}
</button>
))}
</div>
<Input
id="reminder-due"
type="datetime-local"

View File

@@ -5,9 +5,6 @@ import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
const LOGO_URL =
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -322,7 +319,15 @@ function VerifyForm({
// ─── Shell ────────────────────────────────────────────────────────────────────
export function ScanShell() {
interface ScanShellProps {
/** Per-port brand logo resolved server-side by the page wrapper.
* Null hides the logo block — we never fall back to another tenant's
* imagery. */
logoUrl?: string | null;
portName?: string | null;
}
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
@@ -505,15 +510,17 @@ export function ScanShell() {
{/* Brand header - logo centered, page title underneath. Establishes
the standalone identity (this is the PWA home for the scanner). */}
<header className="flex flex-col items-center gap-3">
<Image
src={LOGO_URL}
alt="Port Nimara"
width={64}
height={64}
className="rounded-full shadow-md"
priority
unoptimized
/>
{logoUrl ? (
<Image
src={logoUrl}
alt={portName ?? 'Logo'}
width={64}
height={64}
className="rounded-full shadow-md"
priority
unoptimized
/>
) : null}
<div className="flex w-full items-center justify-between">
<h1 className="text-xl font-semibold">Scan a receipt</h1>
{state.kind !== 'idle' ? (

View File

@@ -650,23 +650,27 @@ function ZeroState({ query, portSlug }: { query: string; portSlug: string | null
</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground/70 mb-2">Quick create</p>
<div className="flex flex-wrap gap-2">
{/* Land on the list page with `?create=1&prefill_name=…`; the list
page's `useCreateFromUrl` hook pops the create sheet with the
search query pre-filled. The bogus `/<entity>/new` URLs used
previously hit the `[id]` route and rendered "not found". */}
<QuickCreateButton
icon={User}
label={`New client "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/new?fullName=${encodeURIComponent(query)}` as any}
href={`/${portSlug}/clients?create=1&prefill_name=${encodeURIComponent(query)}` as any}
/>
<QuickCreateButton
icon={Ship}
label={`New yacht "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/new?name=${encodeURIComponent(query)}` as any}
href={`/${portSlug}/yachts?create=1&prefill_name=${encodeURIComponent(query)}` as any}
/>
<QuickCreateButton
icon={Building2}
label={`New company "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/new?name=${encodeURIComponent(query)}` as any}
href={`/${portSlug}/companies?create=1&prefill_name=${encodeURIComponent(query)}` as any}
/>
</div>
</div>

View File

@@ -1,77 +0,0 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
/**
* Per-user toggle list for dashboard widgets. The dashboard reads the
* same `useDashboardWidgets` hook, so flipping a switch here causes the
* dashboard to reflow on the next visit (or instantly if the user has
* both pages open in different tabs — TanStack Query's optimistic
* update + invalidate handles the cache sync).
*
* Mounted from UserSettings under the id `dashboard` so the dashboard
* "Customize" button can deep-link via `/settings#dashboard`.
*/
export function DashboardWidgetsCard() {
const { allWidgets, visibility, setVisible, setAll, isSaving } = useDashboardWidgets();
const visibleCount = Object.values(visibility).filter(Boolean).length;
const allVisible = visibleCount === allWidgets.length;
const allHidden = visibleCount === 0;
return (
<Card id="dashboard">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Dashboard widgets</CardTitle>
<CardDescription>
Pick which cards show up on your dashboard. Hidden cards leave no empty space the
layout reflows to fill the available width.
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setAll(true)}
disabled={allVisible || isSaving}
>
Show all
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAll(false)}
disabled={allHidden || isSaving}
>
Hide all
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-1">
{allWidgets.map((w) => (
<div
key={w.id}
className="flex items-start justify-between gap-4 rounded-md px-3 py-2 hover:bg-accent/40"
>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{w.label}</div>
<p className="text-xs text-muted-foreground">{w.description}</p>
</div>
<Switch
aria-label={`Show ${w.label}`}
checked={visibility[w.id] ?? false}
disabled={isSaving}
onCheckedChange={(checked) => setVisible(w.id, checked)}
/>
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Save, KeyRound, Globe, Upload } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -16,7 +17,6 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
import { apiFetch } from '@/lib/api/client';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries';
@@ -34,6 +34,7 @@ interface MeResponse {
}
export function UserSettings() {
const router = useRouter();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [displayName, setDisplayName] = useState('');
@@ -155,6 +156,10 @@ export function UserSettings() {
},
});
setMessage('Profile saved');
// Topbar + sidebar `user` props come from the dashboard server
// layout reading userProfiles.displayName — refresh so the new
// name flows back through without a hard reload.
router.refresh();
} catch (err: unknown) {
setMessage(err instanceof Error ? err.message : 'Failed to save');
} finally {
@@ -191,6 +196,7 @@ export function UserSettings() {
await apiFetch('/api/v1/me/email', { method: 'PATCH', body: { email } });
setOriginalEmail(email);
setEmailMsg('Email updated. Use the new address next time you sign in.');
router.refresh();
} catch (err: unknown) {
setEmailMsg(err instanceof Error ? err.message : 'Failed to update email');
} finally {
@@ -369,8 +375,6 @@ export function UserSettings() {
</CardContent>
</Card>
<DashboardWidgetsCard />
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle, X } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
@@ -10,6 +11,8 @@ interface DevFlags {
isDev: boolean;
}
const DISMISS_KEY = 'pn-crm.devBanner.dismissed';
/**
* Single-line warning banner shown across the app whenever a dev-mode
* safety net is active (today: `EMAIL_REDIRECT_TO`). Sticky at the top
@@ -19,18 +22,35 @@ interface DevFlags {
* Production hides the banner entirely because env.ts refuses to boot
* with EMAIL_REDIRECT_TO set when NODE_ENV=production — the flag is
* only ever non-null in dev / staging.
*
* Dismissal is persisted in localStorage keyed by the redirect address —
* changing `EMAIL_REDIRECT_TO` re-shows the banner so the new target
* can't be silently inherited.
*/
export function DevModeBanner() {
const { data } = useQuery<{ data: DevFlags }>({
queryKey: ['internal', 'dev-flags'],
queryFn: () => apiFetch<{ data: DevFlags }>('/api/v1/internal/dev-flags'),
staleTime: 5 * 60_000,
// Don't refetch on focus; the flag changes only on a restart.
refetchOnWindowFocus: false,
});
const redirect = data?.data?.emailRedirectTo;
if (!redirect) return null;
const [overrideDismissed, setOverrideDismissed] = useState(false);
const persistedDismissed =
typeof window !== 'undefined' && !!redirect
? window.localStorage.getItem(DISMISS_KEY) === redirect
: false;
const dismissed = overrideDismissed || persistedDismissed;
if (!redirect || dismissed) return null;
const handleDismiss = () => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(DISMISS_KEY, redirect);
}
setOverrideDismissed(true);
};
return (
<div
@@ -42,6 +62,14 @@ export function DevModeBanner() {
<span>
Dev mode: outbound emails redirected to <code className="font-mono">{redirect}</code>
</span>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss dev mode banner"
className="ml-2 inline-flex size-5 shrink-0 items-center justify-center rounded hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
>
<X className="size-3.5" aria-hidden />
</button>
</div>
);
}

View File

@@ -214,9 +214,13 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
No activity matches the current filters.
</div>
) : (
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
<ol className="relative ml-3 pl-6 space-y-4 py-2">
{groups.map((group, gi) => (
<SessionGroupItem key={`${group.actorKey}-${gi}`} group={group} />
<SessionGroupItem
key={`${group.actorKey}-${gi}`}
group={group}
isLast={gi === groups.length - 1}
/>
))}
</ol>
)}
@@ -224,15 +228,25 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
);
}
function SessionGroupItem({ group }: { group: SessionGroup }) {
function SessionGroupItem({ group, isLast }: { group: SessionGroup; isLast: boolean }) {
const [expanded, setExpanded] = useState(group.rows.length <= 3);
const first = group.rows[0]!;
const created = new Date(first.createdAt);
const ago = formatDistanceToNow(created, { addSuffix: true });
// Vertical connector — runs from below this bubble down to the next item,
// omitted on the last item so the line never trails past the last bubble.
const connector = !isLast ? (
<span
aria-hidden
className="absolute left-[-26px] top-3 bottom-[-1rem] w-px bg-muted-foreground/20"
/>
) : null;
if (group.rows.length === 1) {
return (
<li className="relative">
{connector}
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
<RowBody row={first} actor={group.actorLabel} ago={ago} />
</li>
@@ -241,6 +255,7 @@ function SessionGroupItem({ group }: { group: SessionGroup }) {
return (
<li className="relative">
{connector}
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
<button
type="button"

View File

@@ -1,5 +1,7 @@
import * as React from 'react';
import { TrendingUp, TrendingDown, Minus, Info } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -7,10 +9,19 @@ interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
value: React.ReactNode;
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
delta?: number;
/** Optional suffix appended to the delta value (e.g. '%' for percentage deltas). */
deltaSuffix?: string;
/** 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';
/** Optional explainer rendered in a Popover when the user clicks the (i)
* icon next to the title. Keep it short — one sentence ideally. */
tooltip?: React.ReactNode;
/** For metrics where lower is better (bounce rate, error count). The
* delta number is still shown verbatim, but the colour flips: a
* negative delta renders green (improvement) instead of red. */
lowerIsBetter?: boolean;
}
const ACCENT_STRIPES: Record<NonNullable<KPITileProps['accent']>, string> = {
@@ -26,19 +37,27 @@ export function KPITile({
title,
value,
delta,
deltaSuffix,
sparkline,
accent = 'brand',
tooltip,
lowerIsBetter = false,
className,
...props
}: KPITileProps) {
// Colour logic is independent of the displayed number. For normal
// metrics, positive=good (green); for `lowerIsBetter` metrics, flip so
// a drop renders green.
const isImprovement = typeof delta === 'number' ? (lowerIsBetter ? delta < 0 : delta > 0) : false;
const isRegression = typeof delta === 'number' ? (lowerIsBetter ? delta > 0 : delta < 0) : false;
const deltaClass =
typeof delta === 'number'
? delta > 0
typeof delta !== 'number'
? ''
: isImprovement
? 'text-success'
: delta < 0
: isRegression
? 'text-error'
: 'text-muted-foreground'
: '';
: 'text-muted-foreground';
const deltaPrefix = typeof delta === 'number' ? (delta > 0 ? '+' : '') : '';
return (
@@ -53,18 +72,50 @@ export function KPITile({
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
{title}
<div className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<span>{title}</span>
{tooltip ? (
<Popover>
<PopoverTrigger
type="button"
aria-label={`What is ${title}?`}
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
>
<Info className="size-3" aria-hidden />
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 text-xs leading-relaxed normal-case tracking-normal text-muted-foreground"
>
{tooltip}
</PopoverContent>
</Popover>
) : null}
</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
{value}
<div className="mt-1 flex items-baseline gap-2 sm:mt-2">
<span className="truncate text-lg font-semibold tabular-nums text-foreground sm:text-2xl">
{value}
</span>
{typeof delta === 'number' ? (
<span
className={cn(
'inline-flex shrink-0 items-center gap-0.5 text-xs font-medium',
deltaClass,
)}
>
{delta > 0 ? (
<TrendingUp className="size-3" aria-hidden />
) : delta < 0 ? (
<TrendingDown className="size-3" aria-hidden />
) : (
<Minus className="size-3" aria-hidden />
)}
{deltaPrefix}
{delta}
{deltaSuffix ?? ''}
</span>
) : null}
</div>
{typeof delta === 'number' ? (
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
{deltaPrefix}
{delta}
</div>
) : null}
</div>
{sparkline ? <div className="h-12 w-24 shrink-0 opacity-80">{sparkline}</div> : null}
</div>

View File

@@ -75,6 +75,14 @@ interface YachtFormProps {
* just-spawned yacht in scope.
*/
onCreated?: (yacht: { id: string; name: string }) => void | Promise<void>;
/**
* Optional initial values for the create flow — used by the global
* command-search quick-create ("New yacht 'matthew'" → lands on
* `/yachts?create=1&prefill_name=matthew`). Ignored in edit mode.
*/
prefill?: {
name?: string;
};
}
type YachtStatus = 'active' | 'retired' | 'sold_away';
@@ -86,6 +94,7 @@ export function YachtForm({
initialOwner,
createExtras,
onCreated,
prefill,
}: YachtFormProps) {
const queryClient = useQueryClient();
const isEdit = !!yacht;
@@ -139,14 +148,14 @@ export function YachtForm({
});
} else if (!yacht && open) {
reset({
name: '',
name: prefill?.name ?? '',
status: 'active',
tagIds: [],
...(initialOwner ? { owner: initialOwner } : {}),
});
}
setFormError(null);
}, [yacht, open, reset, initialOwner]);
}, [yacht, open, reset, initialOwner, prefill]);
const mutation = useMutation({
mutationFn: async (data: CreateYachtInput) => {

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -51,6 +51,16 @@ export function YachtList() {
const [createOpen, setCreateOpen] = useState(false);
useCreateFromUrl(() => setCreateOpen(true));
// Quick-create from the global command-search lands here with
// `?create=1&prefill_name=…`. Hydrate the yacht-form's name field so
// the rep doesn't retype.
const searchParams = useSearchParams();
const createPrefill = useMemo(() => {
const name = searchParams?.get('prefill_name');
return name ? { name } : undefined;
}, [searchParams]);
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
@@ -269,7 +279,7 @@ export function YachtList() {
</DialogContent>
</Dialog>
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
<YachtForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
{editYacht && (
<YachtForm