feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SUPPORTED_CURRENCIES } from '@/lib/utils/currency';
|
||||
|
||||
interface Setting {
|
||||
key: string;
|
||||
@@ -218,6 +219,15 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: 'berths_default_currency',
|
||||
label: 'Berths — default currency',
|
||||
description:
|
||||
'Currency applied to newly-created berths when none is specified on the form. Existing berths keep their per-row currency. Defaults to USD.',
|
||||
type: 'select',
|
||||
defaultValue: 'USD',
|
||||
options: SUPPORTED_CURRENCIES.map((c) => ({ value: c.code, label: `${c.code} — ${c.label}` })),
|
||||
},
|
||||
];
|
||||
|
||||
export function SettingsManager() {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AlertRail() {
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
href={portSlug ? (`/${portSlug}/inbox#alerts` as never) : ('/inbox#alerts' as never)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all
|
||||
@@ -53,7 +53,7 @@ export function AlertRail() {
|
||||
))}
|
||||
{overflow > 0 ? (
|
||||
<Link
|
||||
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
|
||||
href={portSlug ? (`/${portSlug}/inbox#alerts` as never) : ('/inbox#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
|
||||
|
||||
@@ -10,7 +10,19 @@ import { AlertCard, AlertCardEmpty } from './alert-card';
|
||||
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
|
||||
import type { AlertStatus } from './types';
|
||||
|
||||
export function AlertsPageShell() {
|
||||
/**
|
||||
* `embedded` mode drops the PageHeader and outer spacing so the shell
|
||||
* can render as a section inside the merged Inbox page without
|
||||
* duplicating chrome. Standalone /alerts route still uses the default
|
||||
* (non-embedded) mode via the redirect — actually, /alerts now redirects
|
||||
* to /inbox#alerts, so non-embedded mode is currently unused but kept
|
||||
* for flexibility.
|
||||
*/
|
||||
interface AlertsPageShellProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {}) {
|
||||
const [tab, setTab] = useState<AlertStatus>('open');
|
||||
const { data: count } = useAlertCount();
|
||||
const { data, isLoading } = useAlertList(tab);
|
||||
@@ -20,19 +32,21 @@ export function AlertsPageShell() {
|
||||
const alerts = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
|
||||
{!embedded ? (
|
||||
<PageHeader
|
||||
title="Alerts"
|
||||
eyebrow="Operational"
|
||||
description="Rules-based signals about pipeline, agreements, expenses, and access"
|
||||
kpiLine={
|
||||
<span>
|
||||
<ShieldAlert className="mr-1 inline h-3 w-3" aria-hidden />
|
||||
{total} active
|
||||
</span>
|
||||
}
|
||||
variant="gradient"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as AlertStatus)}>
|
||||
<TabsList>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Anchor, MapPin, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { Activity, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -45,18 +45,51 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
// already conveyed by the pill below, so the stripe is dock-keyed.
|
||||
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
|
||||
|
||||
// Dimensions string
|
||||
let dimText: string | null = null;
|
||||
if (berth.lengthM || berth.widthM) {
|
||||
const l = berth.lengthM ?? '?';
|
||||
const w = berth.widthM ?? '?';
|
||||
dimText = `${l}m × ${w}m`;
|
||||
// Dimensions string — Length × Width × Draft (each segment is optional).
|
||||
// The avatar already conveys the mooring number, so this becomes the
|
||||
// primary "what is this berth" line.
|
||||
const dimParts: string[] = [];
|
||||
if (berth.lengthM) dimParts.push(`${berth.lengthM}m`);
|
||||
if (berth.widthM) dimParts.push(`${berth.widthM}m`);
|
||||
if (berth.draftM) dimParts.push(`${berth.draftM}m draft`);
|
||||
const dimText = dimParts.length > 0 ? dimParts.join(' × ') : null;
|
||||
|
||||
// Recommended boat size — the most rep-actionable signal in a glance
|
||||
// ("can my client's yacht park here?"). Tenure was previously here but
|
||||
// dropped: tenure is set per EOI/contract, not per berth, so showing
|
||||
// it as a berth property was misleading.
|
||||
let boatCapacityText: string | null = null;
|
||||
if (berth.nominalBoatSizeM) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSizeM}m`;
|
||||
} else if (berth.nominalBoatSize) {
|
||||
boatCapacityText = `Fits up to ${berth.nominalBoatSize}ft`;
|
||||
}
|
||||
|
||||
// Water depth — operational; matters for deep-keel yachts.
|
||||
let waterDepthText: string | null = null;
|
||||
if (berth.waterDepthM) {
|
||||
const prefix = berth.waterDepthIsMinimum ? '≥ ' : '';
|
||||
waterDepthText = `${prefix}${berth.waterDepthM}m deep`;
|
||||
}
|
||||
|
||||
// Power label: combine capacity + voltage when both present.
|
||||
let powerText: string | null = null;
|
||||
if (berth.powerCapacity && berth.voltage) {
|
||||
powerText = `${berth.powerCapacity}A / ${berth.voltage}V`;
|
||||
} else if (berth.powerCapacity) {
|
||||
powerText = `${berth.powerCapacity}A`;
|
||||
} else if (berth.voltage) {
|
||||
powerText = `${berth.voltage}V`;
|
||||
}
|
||||
|
||||
// Secondary meta: boat-capacity · water-depth · price · power. All
|
||||
// optional; order favours the highest-utility scan signals first.
|
||||
const metaParts: string[] = [];
|
||||
if (dimText) metaParts.push(dimText);
|
||||
if (boatCapacityText) metaParts.push(boatCapacityText);
|
||||
if (waterDepthText) metaParts.push(waterDepthText);
|
||||
if (berth.price)
|
||||
metaParts.push(formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 }));
|
||||
if (powerText) metaParts.push(powerText);
|
||||
|
||||
const tags = berth.tags ?? [];
|
||||
|
||||
@@ -101,26 +134,27 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar icon={<Anchor className="h-5 w-5" />} />
|
||||
<div className="flex items-center gap-3">
|
||||
{/* The mooring number IS the avatar — recognisable at a glance
|
||||
(A1, B12, …) and eliminates the duplicate berth-number heading
|
||||
that previously sat to the right of an anchor icon. */}
|
||||
<ListCardAvatar
|
||||
initials={berth.mooringNumber}
|
||||
className="text-base font-bold tracking-tight"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title row + spacer for actions button */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{berth.mooringNumber}
|
||||
</h3>
|
||||
{/* Primary line: dimensions (L × W × Draft). The avatar
|
||||
already carries the area letter, so this slot becomes the
|
||||
"what fits here" answer. Falls back gracefully when
|
||||
dimensions aren't recorded yet. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{dimText ?? <span className="font-normal text-muted-foreground">No dimensions</span>}
|
||||
</p>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Area subtitle */}
|
||||
{berth.area ? (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
|
||||
<span className="truncate">{berth.area}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Dimensions · Price meta line */}
|
||||
{/* Meta line: tenure · price · power. All optional. */}
|
||||
{metaParts.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||
{metaParts.map((part, i) => (
|
||||
@@ -132,8 +166,8 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Status pill */}
|
||||
<div className="mt-1.5">
|
||||
{/* Status pill + tags */}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
|
||||
@@ -142,21 +176,15 @@ export function BerthCard({ berth }: BerthCardProps) {
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{tags.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{tags.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
|
||||
@@ -222,8 +222,9 @@ function StatusChangeDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Picking an interest auto-creates a primary berth link if one doesn't already
|
||||
exist, so the deal timeline + heat scorer attribute the change correctly.
|
||||
Link this status change to the prospect (interest) it relates to. The change will
|
||||
appear on that interest's timeline, and the berth gets attached to the prospect
|
||||
automatically if it wasn't already.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
|
||||
});
|
||||
|
||||
const { setChrome } = useMobileChrome();
|
||||
const titleForChrome: string | null = data?.mooringNumber ?? null;
|
||||
const titleForChrome: string | null = data?.mooringNumber ? `Berth ${data.mooringNumber}` : null;
|
||||
useEffect(() => {
|
||||
setChrome({ title: titleForChrome, showBackButton: true });
|
||||
return () => setChrome({ title: null, showBackButton: false });
|
||||
|
||||
@@ -192,7 +192,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-[480px] sm:w-[540px] overflow-y-auto">
|
||||
<SheetContent className="w-full sm:w-[540px] sm:max-w-none overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { Bookmark } from 'lucide-react';
|
||||
import { PIPELINE_STAGES, stageLabel } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES, stageLabel, formatSource } from '@/lib/constants';
|
||||
import type { InterestRow } from '@/components/interests/interest-columns';
|
||||
|
||||
interface BerthInterestsTabProps {
|
||||
@@ -46,13 +46,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ListResponse {
|
||||
data: InterestRow[];
|
||||
total: number;
|
||||
@@ -179,9 +172,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
{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) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{formatSource(i.source) ?? '-'}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{new Date(i.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -63,11 +64,27 @@ export function BerthList() {
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FilterBar
|
||||
filters={berthFilterDefinitions}
|
||||
// Search is hoisted out of the popover into the inline input
|
||||
// below — keeps the daily "find by mooring/area" lookup one
|
||||
// tap away instead of buried behind the Filters dropdown.
|
||||
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
inputMode="search"
|
||||
placeholder="Search mooring or area…"
|
||||
aria-label="Search berths"
|
||||
value={(filters.search as string | undefined) ?? ''}
|
||||
onChange={(e) => setFilter('search', e.target.value || undefined)}
|
||||
// flex-1 + min-w-0 lets the input expand to fill the row's
|
||||
// remaining width on mobile (where space is at a premium).
|
||||
// sm:max-w-xs caps it at 320px on desktop so it doesn't grow
|
||||
// absurdly wide on a 2k monitor.
|
||||
className="h-8 min-w-0 flex-1 sm:max-w-xs"
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
@@ -101,6 +118,11 @@ export function BerthList() {
|
||||
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
|
||||
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
|
||||
cardRender={(row) => <BerthCard berth={row.original} />}
|
||||
// Group adjacent cards by dock letter (area) on mobile — adds a
|
||||
// dim divider + uppercased label above the first card of each
|
||||
// group. Data is already sorted by mooringNumber (A1, A2, …, B1,
|
||||
// B2, …) so consecutive rows naturally share dock letters.
|
||||
mobileGroupBy={(row) => row.area ?? 'Unassigned'}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Anchor}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { type DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import {
|
||||
BERTH_ACCESS_OPTIONS,
|
||||
BERTH_BOLLARD_CAPACITIES,
|
||||
@@ -64,6 +68,40 @@ type BerthData = {
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact ft/m segmented control for the Specifications card. Two
|
||||
* tappable pills with `min-h-[36px]` for an Apple-HIG-friendly touch
|
||||
* target. The active option gets the brand primary background; the
|
||||
* other reads as muted.
|
||||
*/
|
||||
function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Display unit"
|
||||
className="inline-flex items-center gap-0.5 rounded-md border bg-muted/40 p-0.5 text-xs"
|
||||
>
|
||||
{(['ft', 'm'] as const).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === opt}
|
||||
onClick={() => onChange(opt)}
|
||||
className={cn(
|
||||
'min-h-[28px] min-w-[40px] rounded px-2 font-medium transition-colors',
|
||||
value === opt
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
if (!value && value !== 0 && value !== false) return null;
|
||||
// Mobile-first: stack vertically with label on top so long values
|
||||
@@ -104,6 +142,7 @@ function useBerthPatch(berthId: string) {
|
||||
function EditableSpec({
|
||||
label,
|
||||
value,
|
||||
displayValue,
|
||||
field,
|
||||
patch,
|
||||
numeric = false,
|
||||
@@ -113,6 +152,9 @@ function EditableSpec({
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
/** Optional formatted version for display only (currency, percent,
|
||||
* unit-suffixed). The edit input still works against the raw `value`. */
|
||||
displayValue?: string | null;
|
||||
field: string;
|
||||
patch: ReturnType<typeof useBerthPatch>;
|
||||
numeric?: boolean;
|
||||
@@ -142,6 +184,7 @@ function EditableSpec({
|
||||
) : (
|
||||
<InlineEditableField
|
||||
value={value}
|
||||
displayValue={displayValue}
|
||||
onSave={async (next) => {
|
||||
if (numeric) {
|
||||
if (next === null || next.trim() === '') {
|
||||
@@ -170,30 +213,33 @@ function EditableSpec({
|
||||
);
|
||||
}
|
||||
|
||||
// Conversion factors between feet and meters. 0.3048 is the exact
|
||||
// definition (1 ft = 0.3048 m by international agreement).
|
||||
const FT_TO_M = 0.3048;
|
||||
const M_TO_FT = 1 / FT_TO_M;
|
||||
|
||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
const patch = useBerthPatch(berth.id);
|
||||
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
|
||||
const fmt = (v: string | null, fractionDigits = 2): string | null => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
return n.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
};
|
||||
// User-selected display unit for dimensions. Persisted in localStorage
|
||||
// so reps' preferred unit sticks across navigations + sessions.
|
||||
const [units, setUnits] = useState<'ft' | 'm'>('ft');
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('berth-overview-units');
|
||||
if (stored === 'ft' || stored === 'm') setUnits(stored);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('berth-overview-units', units);
|
||||
}, [units]);
|
||||
|
||||
// Read-only display helper for the metric column on dimensions —
|
||||
// mirrors the pre-edit "X ft / Y m" rendering for fields where only
|
||||
// the foot value is editable today.
|
||||
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
|
||||
const ftFmt = fmt(ft, 0);
|
||||
const mFmt = fmt(m);
|
||||
const parts: string[] = [];
|
||||
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||
if (mFmt) parts.push(`${mFmt} m`);
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
const u = units;
|
||||
// For each dimension, pick the column matching the selected unit and
|
||||
// point linkedUnit at the opposite column so edits keep both in sync.
|
||||
const dim = (ftField: string, mField: string) =>
|
||||
units === 'ft'
|
||||
? { field: ftField, linkedUnit: { field: mField, multiplier: FT_TO_M } }
|
||||
: { field: mField, linkedUnit: { field: ftField, multiplier: M_TO_FT } };
|
||||
const dimValue = (ftValue: string | null, mValue: string | null) =>
|
||||
units === 'ft' ? ftValue : mValue;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -204,62 +250,50 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Specifications */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-3">
|
||||
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
|
||||
<UnitToggle value={units} onChange={setUnits} />
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 divide-y">
|
||||
<EditableSpec
|
||||
label="Length (ft)"
|
||||
value={berth.lengthFt}
|
||||
field="lengthFt"
|
||||
label={`Length (${u})`}
|
||||
value={dimValue(berth.lengthFt, berth.lengthM)}
|
||||
{...dim('lengthFt', 'lengthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'lengthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Width (ft)"
|
||||
value={berth.widthFt}
|
||||
field="widthFt"
|
||||
label={`Width (${u})`}
|
||||
value={dimValue(berth.widthFt, berth.widthM)}
|
||||
{...dim('widthFt', 'widthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'widthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Draft (ft)"
|
||||
value={berth.draftFt}
|
||||
field="draftFt"
|
||||
label={`Draft (${u})`}
|
||||
value={dimValue(berth.draftFt, berth.draftM)}
|
||||
{...dim('draftFt', 'draftM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'draftM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Nominal Boat Size (ft)"
|
||||
value={berth.nominalBoatSize}
|
||||
field="nominalBoatSize"
|
||||
label={`Nominal Boat Size (${u})`}
|
||||
value={dimValue(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||
{...dim('nominalBoatSize', 'nominalBoatSizeM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'nominalBoatSizeM', multiplier: 0.3048 }}
|
||||
/>
|
||||
<SpecRow
|
||||
label="Nominal Boat Size (m)"
|
||||
value={
|
||||
formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)?.split(
|
||||
' / ',
|
||||
)[1] ?? null
|
||||
}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Water Depth (ft)"
|
||||
value={berth.waterDepth}
|
||||
field="waterDepth"
|
||||
label={`Water Depth (${u})`}
|
||||
value={dimValue(berth.waterDepth, berth.waterDepthM)}
|
||||
{...dim('waterDepth', 'waterDepthM')}
|
||||
patch={patch}
|
||||
numeric
|
||||
suffix="ft"
|
||||
linkedUnit={{ field: 'waterDepthM', multiplier: 0.3048 }}
|
||||
suffix={u}
|
||||
/>
|
||||
<EditableSpec
|
||||
label="Mooring Type"
|
||||
@@ -371,6 +405,11 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
<EditableSpec
|
||||
label={`Price (${berth.priceCurrency || 'USD'})`}
|
||||
value={berth.price}
|
||||
displayValue={
|
||||
berth.price
|
||||
? formatCurrency(berth.price, berth.priceCurrency, { maxFractionDigits: 0 })
|
||||
: null
|
||||
}
|
||||
field="price"
|
||||
patch={patch}
|
||||
numeric
|
||||
|
||||
@@ -164,7 +164,7 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
|
||||
reservations, leave invoices/Documenso envelopes alone. Yachts stay on the
|
||||
reservations, leave invoices/signing envelopes alone. Yachts stay on the
|
||||
archived client. To customise per-client, archive that client individually
|
||||
instead.
|
||||
</div>
|
||||
|
||||
@@ -17,16 +17,9 @@ import {
|
||||
deriveInitials,
|
||||
} from '@/components/shared/list-card';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
import { stageBadgeClass, stageLabel, formatSource } from '@/lib/constants';
|
||||
import type { ClientRow } from './client-columns';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ClientCardProps {
|
||||
client: ClientRow;
|
||||
portSlug: string;
|
||||
@@ -38,7 +31,7 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
|
||||
// Card display: prefer email, fall back to phone.
|
||||
const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null;
|
||||
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||
const sourceLabel = formatSource(client.source);
|
||||
const tags = client.tags ?? [];
|
||||
|
||||
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { stageDotClass, stageLabel } from '@/lib/constants';
|
||||
import { stageDotClass, stageLabel, formatSource } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ColumnPickerOption } from '@/components/shared/column-picker';
|
||||
|
||||
@@ -81,13 +81,6 @@ export const CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
|
||||
*/
|
||||
export const CLIENT_DEFAULT_HIDDEN: string[] = ['latestStage'];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
@@ -191,10 +184,11 @@ export function getClientColumns({
|
||||
header: 'Source',
|
||||
cell: ({ getValue }) => {
|
||||
const source = getValue() as string | null;
|
||||
if (!source) return <span className="text-muted-foreground">-</span>;
|
||||
const label = formatSource(source);
|
||||
if (!label) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isArchived && client.clientPortalEnabled !== false ? (
|
||||
{!isArchived && client.clientPortalEnabled === true ? (
|
||||
<div className="hidden sm:inline-flex">
|
||||
<PortalInviteButton
|
||||
clientId={client.id}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const clientFilterDefinitions: FilterDefinition[] = [
|
||||
key: 'nationality',
|
||||
label: 'Country',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by nationality...',
|
||||
placeholder: 'Filter by country...',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||
|
||||
@@ -188,8 +189,8 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Full Name *</Label>
|
||||
<Input {...register('fullName')} placeholder="John Smith" />
|
||||
{errors.fullName && (
|
||||
@@ -198,7 +199,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox
|
||||
value={watch('nationalityIso')}
|
||||
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
|
||||
@@ -235,102 +236,107 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30"
|
||||
className="space-y-3 p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(
|
||||
`contacts.${index}.channel`,
|
||||
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
|
||||
<div className="space-y-1 sm:col-span-3">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(
|
||||
`contacts.${index}.channel`,
|
||||
v as 'email' | 'phone' | 'whatsapp' | 'other',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 sm:h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
{(() => {
|
||||
const channel = watch(`contacts.${index}.channel`);
|
||||
if (channel === 'phone' || channel === 'whatsapp') {
|
||||
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
||||
const country =
|
||||
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
||||
undefined;
|
||||
<div className="space-y-1 sm:col-span-5">
|
||||
<Label className="text-xs">Value</Label>
|
||||
{(() => {
|
||||
const channel = watch(`contacts.${index}.channel`);
|
||||
if (channel === 'phone' || channel === 'whatsapp') {
|
||||
const e164 = watch(`contacts.${index}.valueE164`) ?? null;
|
||||
const country =
|
||||
(watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ??
|
||||
undefined;
|
||||
return (
|
||||
<PhoneInput
|
||||
value={
|
||||
e164 || country
|
||||
? {
|
||||
e164: e164 ?? null,
|
||||
country: country ?? 'US',
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
setValue(`contacts.${index}.value`, v.e164 ?? '');
|
||||
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
|
||||
setValue(`contacts.${index}.valueCountry`, v.country);
|
||||
}}
|
||||
data-testid={`contact-${index}-phone`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PhoneInput
|
||||
value={
|
||||
e164 || country
|
||||
? {
|
||||
e164: e164 ?? null,
|
||||
country: country ?? 'US',
|
||||
}
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
setValue(`contacts.${index}.value`, v.e164 ?? '');
|
||||
setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined);
|
||||
setValue(`contacts.${index}.valueCountry`, v.country);
|
||||
}}
|
||||
data-testid={`contact-${index}-phone`}
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-9 sm:h-8"
|
||||
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-8"
|
||||
placeholder={channel === 'email' ? 'email@example.com' : 'value'}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:col-span-4">
|
||||
<Label className="text-xs">
|
||||
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
|
||||
</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.label`)}
|
||||
className="h-9 sm:h-8"
|
||||
placeholder={
|
||||
watch(`contacts.${index}.channel`) === 'other'
|
||||
? 'e.g. Telegram, Signal'
|
||||
: 'work'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">
|
||||
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
|
||||
</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.label`)}
|
||||
className="h-8"
|
||||
placeholder={
|
||||
watch(`contacts.${index}.channel`) === 'other'
|
||||
? 'e.g. Telegram, Signal'
|
||||
: 'work'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center gap-1 pb-1">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
|
||||
/>
|
||||
<Label className="text-xs">Primary</Label>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex justify-end pb-1">
|
||||
{/* Bottom strip: Primary toggle left, delete right. Sits on
|
||||
its own row on every breakpoint so neither control gets
|
||||
squashed by the field columns above. */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) => setValue(`contacts.${index}.isPrimary`, !!v)}
|
||||
/>
|
||||
<span className="font-medium">Primary contact</span>
|
||||
</label>
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -346,7 +352,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Source & Preferences
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
@@ -359,11 +365,11 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -394,7 +400,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
data-testid="client-timezone"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
<Input {...register('sourceDetails')} placeholder="Referred by John Doe" />
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type ClientRow,
|
||||
} from '@/components/clients/client-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
@@ -49,6 +50,7 @@ export function ClientList() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editClient, setEditClient] = useState<ClientRow | null>(null);
|
||||
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -141,17 +143,9 @@ export function ClientList() {
|
||||
title="Clients"
|
||||
description="Manage your client records"
|
||||
variant="gradient"
|
||||
actions={
|
||||
<PermissionGate resource="clients" action="create">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Client
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterBar
|
||||
filters={clientFilterDefinitions}
|
||||
values={filters}
|
||||
@@ -171,6 +165,16 @@ export function ClientList() {
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
{/* New Client moved out of PageHeader actions and into the
|
||||
filter row. Saves a row on mobile (no more dedicated
|
||||
actions strip). ml-auto keeps the primary action at the
|
||||
far-right edge, which is where reps look first. */}
|
||||
<PermissionGate resource="clients" action="create">
|
||||
<Button size="sm" className="ml-auto" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Client
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
<SaveViewDialog
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ContactsEditor } from '@/components/clients/contacts-editor';
|
||||
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
|
||||
type ClientPatchField =
|
||||
| 'fullName'
|
||||
@@ -31,13 +32,7 @@ type ClientPatchField =
|
||||
| 'source'
|
||||
| 'sourceDetails';
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
const CONTACT_METHOD_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
@@ -289,10 +284,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
||||
badge: client.noteCount,
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -477,12 +477,12 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* In-flight Documenso envelopes */}
|
||||
{/* In-flight signing envelopes */}
|
||||
{dossier.documents.filter((d) => d.isInFlight).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> In-flight Documenso envelopes
|
||||
<FileText className="h-4 w-4" /> In-flight signing envelopes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -502,7 +502,7 @@ export function SmartArchiveDialog({ open, onOpenChange, clientId, clientName, o
|
||||
}
|
||||
>
|
||||
<option value="leave">Leave envelope pending</option>
|
||||
<option value="void_documenso">Void in Documenso</option>
|
||||
<option value="void_documenso">Void the signing envelope</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -42,6 +42,19 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
dissolved: 'Dissolved',
|
||||
};
|
||||
|
||||
export const COMPANY_COLUMN_OPTIONS = [
|
||||
{ id: 'name', label: 'Name', alwaysVisible: true },
|
||||
{ id: 'legalName', label: 'Legal Name' },
|
||||
{ id: 'taxId', label: 'Tax ID' },
|
||||
{ id: 'memberCount', label: 'Members' },
|
||||
{ id: 'yachtCount', label: 'Yachts' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'actions', label: 'Actions', alwaysVisible: true },
|
||||
];
|
||||
|
||||
/** Hidden by default — keep the table dense; opt-in to longer columns. */
|
||||
export const COMPANY_DEFAULT_HIDDEN: string[] = ['legalName', 'taxId'];
|
||||
|
||||
interface GetCompanyColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (company: CompanyRow) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,9 +27,16 @@ import {
|
||||
import { CompanyCard } from '@/components/companies/company-card';
|
||||
import { CompanyForm } from '@/components/companies/company-form';
|
||||
import { companyFilterDefinitions } from '@/components/companies/company-filters';
|
||||
import { getCompanyColumns, type CompanyRow } from '@/components/companies/company-columns';
|
||||
import {
|
||||
getCompanyColumns,
|
||||
COMPANY_COLUMN_OPTIONS,
|
||||
COMPANY_DEFAULT_HIDDEN,
|
||||
type CompanyRow,
|
||||
} from '@/components/companies/company-columns';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export function CompanyList() {
|
||||
@@ -37,6 +45,7 @@ export function CompanyList() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
|
||||
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -102,6 +111,10 @@ export function CompanyList() {
|
||||
onArchive: (company) => setArchiveCompany(company),
|
||||
});
|
||||
|
||||
// Persisted column visibility — same pattern as ClientList / BerthList.
|
||||
const { hidden, setHidden } = useTablePreferences('companies', COMPANY_DEFAULT_HIDDEN);
|
||||
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -125,13 +138,16 @@ export function CompanyList() {
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={COMPANY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -145,6 +161,7 @@ export function CompanyList() {
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columnVisibility={columnVisibility}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function CompanyPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -147,12 +147,13 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
</EditableRow>
|
||||
<EditableRow label="Incorporation Date">
|
||||
<InlineEditableField
|
||||
variant="date"
|
||||
// The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
|
||||
// because Postgres `date` columns are serialized through JSON. Strip
|
||||
// the time portion so the read-only state shows just YYYY-MM-DD,
|
||||
// which is also the format the user types when editing.
|
||||
// which is also the format the date input expects.
|
||||
value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null}
|
||||
placeholder="YYYY-MM-DD"
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onSave={save('incorporationDate')}
|
||||
/>
|
||||
</EditableRow>
|
||||
@@ -226,10 +227,10 @@ export function getCompanyTabs({
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="companies"
|
||||
entityId={companyId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
68
src/components/dashboard/active-deals-tile.tsx
Normal file
68
src/components/dashboard/active-deals-tile.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface KpiResponse {
|
||||
totalClients: number;
|
||||
activeInterests: number;
|
||||
pipelineValueUsd: number;
|
||||
occupancyRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact rail-sized KPI tile — single number, label, and a click-
|
||||
* through to the interests pipeline. Reuses the existing dashboard KPIs
|
||||
* endpoint so we don't pay an extra round-trip.
|
||||
*/
|
||||
export function ActiveDealsTile() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<KpiResponse>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
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">
|
||||
<TrendingUp 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">
|
||||
Active deals
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-12" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold leading-tight text-foreground">
|
||||
{data?.activeInterests ?? 0}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
// Next typedRoutes can't infer dynamic-segment routes from a template
|
||||
// literal — cast through unknown rather than `any` so the lint rule
|
||||
// is satisfied while the runtime href is still correct.
|
||||
href={`/${portSlug}/interests` as unknown as Route}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,103 @@ interface ActivityItem {
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string | null;
|
||||
/** Server-resolved human label (client name, yacht name, …) when the
|
||||
* underlying entity still exists. Falls back to the id prefix in the UI. */
|
||||
label: string | null;
|
||||
userId: string | null;
|
||||
fieldChanged: string | null;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** camelCase / snake_case field name → "Title Case" so the audit log
|
||||
* reads naturally ("fullName" → "Full Name", "phone_number" → "Phone
|
||||
* Number"). Single-word fields stay capitalized. */
|
||||
function humanizeFieldName(name: string): string {
|
||||
return name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Render a JSON-ish value as a short, single-line preview. Strings come
|
||||
* through as-is; objects flatten to "k: v, k: v"; arrays compress to a
|
||||
* count; nulls / empty render as em-dash. */
|
||||
function shortValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`;
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) return '—';
|
||||
return entries
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(', ');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Build a "Field: old → new" diff string for the activity row's second
|
||||
* line. Returns null when there's nothing useful to show.
|
||||
*
|
||||
* Audit logs for updates store the per-field diff inside `oldValue` as
|
||||
* `{ field: { old, new }, … }` (see entity-diff.ts), so that's the
|
||||
* shape we pattern-match first. Falls back to a fieldChanged/old→new
|
||||
* pair when those are present, and finally to a key-by-key compare of
|
||||
* two flat objects in `oldValue` vs `newValue`. */
|
||||
function buildDiffLine(item: ActivityItem): string | null {
|
||||
// Shape A: oldValue = { field: { old, new }, … }
|
||||
if (
|
||||
item.action === 'update' &&
|
||||
item.oldValue &&
|
||||
typeof item.oldValue === 'object' &&
|
||||
!Array.isArray(item.oldValue)
|
||||
) {
|
||||
const diffMap = item.oldValue as Record<string, unknown>;
|
||||
const entries = Object.entries(diffMap).filter(([, v]) => {
|
||||
return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object);
|
||||
});
|
||||
if (entries.length > 0) {
|
||||
return entries
|
||||
.slice(0, 2)
|
||||
.map(([field, v]) => {
|
||||
const { old, new: nextValue } = v as { old: unknown; new: unknown };
|
||||
return `${humanizeFieldName(field)}: ${shortValue(old)} → ${shortValue(nextValue)}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
// Shape B: single-field change with explicit columns.
|
||||
if (item.fieldChanged) {
|
||||
return `${humanizeFieldName(item.fieldChanged)}: ${shortValue(item.oldValue)} → ${shortValue(item.newValue)}`;
|
||||
}
|
||||
|
||||
// Shape C: flat oldValue vs flat newValue.
|
||||
if (
|
||||
item.action === 'update' &&
|
||||
item.oldValue &&
|
||||
typeof item.oldValue === 'object' &&
|
||||
item.newValue &&
|
||||
typeof item.newValue === 'object'
|
||||
) {
|
||||
const oldObj = item.oldValue as Record<string, unknown>;
|
||||
const newObj = item.newValue as Record<string, unknown>;
|
||||
const keys = Object.keys(oldObj).filter((k) => k in newObj);
|
||||
if (keys.length === 0) return null;
|
||||
return keys
|
||||
.slice(0, 2)
|
||||
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k])} → ${shortValue(newObj[k])}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
create: 'default',
|
||||
update: 'secondary',
|
||||
@@ -63,27 +155,49 @@ function ActivityFeedInner() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<ActionBadge action={item.action} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-foreground">
|
||||
<span className="font-medium capitalize">{item.entityType}</span>
|
||||
{item.entityId && (
|
||||
<span className="ml-1 text-muted-foreground font-mono text-xs">
|
||||
{item.entityId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const diffLine = buildDiffLine(item);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
|
||||
>
|
||||
<ActionBadge action={item.action} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-foreground">
|
||||
{item.label ? (
|
||||
<>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="ml-1.5 text-muted-foreground text-xs capitalize">
|
||||
{item.entityType}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium capitalize">{item.entityType}</span>
|
||||
{item.entityId && (
|
||||
<span className="ml-1 text-muted-foreground font-mono text-xs">
|
||||
{item.entityId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{diffLine ? (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground mt-0.5"
|
||||
title={diffLine}
|
||||
>
|
||||
{diffLine}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-muted-foreground/80 mt-0.5">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
101
src/components/dashboard/berth-status-chart.tsx
Normal file
101
src/components/dashboard/berth-status-chart.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface BerthStatusResponse {
|
||||
data: {
|
||||
total: number;
|
||||
available: number;
|
||||
underOffer: number;
|
||||
sold: number;
|
||||
maintenance: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Brand-aligned palette. Order matches the legend reading order
|
||||
// (positive → in-progress → closed → exception).
|
||||
const SEGMENTS = [
|
||||
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
|
||||
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
|
||||
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
|
||||
{ key: 'maintenance', label: 'Maintenance', color: 'hsl(228 10% 60%)' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Donut visualisation of the port's berth status mix. Sized to fit a
|
||||
* single chart column (~360px wide) with a generous legend; degrades
|
||||
* cleanly when a status has zero berths (segment is omitted, legend
|
||||
* still hints at its absence).
|
||||
*/
|
||||
export function BerthStatusChart() {
|
||||
const { data, isLoading } = useQuery<BerthStatusResponse>({
|
||||
queryKey: ['dashboard', 'berth_status'],
|
||||
queryFn: () => apiFetch<BerthStatusResponse>('/api/v1/dashboard/berth-status'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const stats = data?.data;
|
||||
const chartData = stats
|
||||
? SEGMENTS.map((s) => ({ ...s, value: stats[s.key] })).filter((s) => s.value > 0)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Berth status</CardTitle>
|
||||
<CardDescription>
|
||||
{stats
|
||||
? `${stats.sold} sold · ${stats.underOffer} under offer · ${stats.available} available`
|
||||
: 'Distribution across the port'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-[240px] w-full" />
|
||||
) : chartData.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No berths yet.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={85}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{chartData.map((d) => (
|
||||
<Cell key={d.key} fill={d.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value, _name, payload) => {
|
||||
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
const total = stats?.total ?? 0;
|
||||
const pct = total > 0 ? Math.round((numeric / total) * 100) : 0;
|
||||
const label = (payload as { payload?: { label?: string } } | undefined)
|
||||
?.payload?.label;
|
||||
return [`${numeric} (${pct}%)`, label ?? ''];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
iconType="circle"
|
||||
wrapperStyle={{ fontSize: 12 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -24,14 +24,19 @@ interface ChartCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
/**
|
||||
* Match the pattern used elsewhere in the codebase (see
|
||||
* `src/app/(dashboard)/[portSlug]/expenses/page.tsx`, `client-files-tab.tsx`,
|
||||
* `backup-admin-panel.tsx`). All four reduce to the same dead-simple shape
|
||||
* and they all work — Chrome honours the `download` attribute and the
|
||||
* file lands with the right name.
|
||||
*/
|
||||
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -44,31 +49,28 @@ async function exportContainerAsPng(container: HTMLElement, filename: string) {
|
||||
clone.setAttribute('height', String(height));
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const svgBlob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}`;
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Failed to load chart for export'));
|
||||
img.src = url;
|
||||
img.src = svgDataUrl;
|
||||
});
|
||||
const canvas = document.createElement('canvas');
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) downloadBlob(blob, filename);
|
||||
}, 'image/png');
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), 'image/png'),
|
||||
);
|
||||
if (!blob) return;
|
||||
triggerBlobDownload(blob, filename);
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
@@ -84,7 +86,10 @@ export function ChartCard({
|
||||
function onDownloadCsv() {
|
||||
const csv = toCsv?.();
|
||||
if (!csv) return;
|
||||
downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `${exportFilename}.csv`);
|
||||
triggerBlobDownload(
|
||||
new Blob([csv], { type: 'text/csv;charset=utf-8' }),
|
||||
`${exportFilename}.csv`,
|
||||
);
|
||||
}
|
||||
|
||||
function onDownloadPng() {
|
||||
|
||||
129
src/components/dashboard/customize-widgets-menu.tsx
Normal file
129
src/components/dashboard/customize-widgets-menu.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
|
||||
/**
|
||||
* Modal widget picker for the dashboard header. Replaced the original
|
||||
* dropdown menu because 13 widgets + 3 footer buttons made the dropdown
|
||||
* cramped and hid the descriptions reps need to know what each card
|
||||
* actually shows.
|
||||
*
|
||||
* Backed by the same `useDashboardWidgets` hook that drives the
|
||||
* Settings card — toggles update both surfaces optimistically.
|
||||
*/
|
||||
export function CustomizeWidgetsMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { allWidgets, visibility, setVisible, setAll, resetToDefaults, isSaving } =
|
||||
useDashboardWidgets();
|
||||
|
||||
const visibleCount = Object.values(visibility).filter(Boolean).length;
|
||||
const allVisible = visibleCount === allWidgets.length;
|
||||
const allHidden = visibleCount === 0;
|
||||
// Reset is a no-op when state already matches the registry defaults —
|
||||
// disable in that case to avoid pointless API round-trips.
|
||||
const matchesDefaults = allWidgets.every(
|
||||
(w) => (visibility[w.id] ?? false) === w.defaultVisible,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Customize
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty
|
||||
space — the layout reflows to fill the available width.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toggle list. Capped at ~60vh with internal scroll so the modal
|
||||
doesn't push the action footer off-screen on shorter viewports. */}
|
||||
<div className="max-h-[60vh] -mx-2 overflow-y-auto px-2">
|
||||
<div className="space-y-1 py-1">
|
||||
{allWidgets.map((w) => (
|
||||
<label
|
||||
key={w.id}
|
||||
className="flex cursor-pointer items-start justify-between gap-4 rounded-md px-3 py-2.5 hover:bg-accent/40"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer: stacks vertically on mobile (counter row, secondary
|
||||
buttons row, full-width primary "Done") so no button gets
|
||||
orphaned beneath the others. Reverts to single inline row at
|
||||
sm+ where there's space. */}
|
||||
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<span className="text-xs text-muted-foreground sm:order-first">
|
||||
{visibleCount} of {allWidgets.length} visible
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:flex-nowrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={matchesDefaults || isSaving}
|
||||
onClick={resetToDefaults}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={allHidden || isSaving}
|
||||
onClick={() => setAll(false)}
|
||||
>
|
||||
Hide all
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={allVisible || isSaving}
|
||||
onClick={() => setAll(true)}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,13 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { usePortContext } from '@/providers/port-provider';
|
||||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||||
import { DateRangePicker } from './date-range-picker';
|
||||
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DashboardWidget } from './widget-registry';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||
@@ -43,8 +37,10 @@ function rangeLabel(range: DateRange): string {
|
||||
|
||||
interface MeData {
|
||||
data?: {
|
||||
firstName?: string | null;
|
||||
displayName?: string | null;
|
||||
profile?: {
|
||||
firstName?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,8 +54,14 @@ function timeOfDayGreeting(): string {
|
||||
|
||||
export function DashboardShell() {
|
||||
const [range, setRange] = useState<DateRange>('30d');
|
||||
const { currentPort } = usePortContext();
|
||||
const portName = currentPort?.name ?? 'this port';
|
||||
|
||||
const { visibleWidgets } = useDashboardWidgets();
|
||||
|
||||
// Bucket once so the JSX stays readable. Registry order is preserved
|
||||
// inside each bucket, so reordering the registry reorders the render.
|
||||
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
||||
const rails = visibleWidgets.filter((w) => w.group === 'rail');
|
||||
const feed = visibleWidgets.filter((w) => w.group === 'feed');
|
||||
|
||||
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
|
||||
// useTablePreferences elsewhere — usually a cache hit, so no extra
|
||||
@@ -70,7 +72,7 @@ export function DashboardShell() {
|
||||
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const firstName = me.data?.data?.firstName?.trim();
|
||||
const firstName = me.data?.data?.profile?.firstName?.trim();
|
||||
// Time-aware greeting line, falls back to a generic "Welcome back" when
|
||||
// we don't know the user's first name yet (e.g. profile not filled out).
|
||||
const greeting = firstName ? `${timeOfDayGreeting()}, ${firstName}` : 'Welcome back';
|
||||
@@ -96,51 +98,103 @@ export function DashboardShell() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Mobile-only greeting strip. The shared PageHeader is hidden
|
||||
below `sm` (its title is normally duplicated by the topbar),
|
||||
so we render the welcome message inline here for mobile —
|
||||
keeps the personalized touch from desktop without polluting
|
||||
the topbar (which stays "Dashboard" for wayfinding). */}
|
||||
<div className="sm:hidden">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Dashboard</p>
|
||||
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
title={greeting}
|
||||
eyebrow="Dashboard"
|
||||
description={`Live snapshot of ${portName} activity`}
|
||||
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||
// The date-range subtitle only means something when at least
|
||||
// one widget is on the page to consume the range; if everything
|
||||
// is hidden it just reads as an orphaned line.
|
||||
kpiLine={visibleWidgets.length > 0 ? <span>{rangeLabel(range)}</span> : undefined}
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangePicker value={range} onChange={setRange} />
|
||||
<CustomizeWidgetsMenu />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* `items-start` is critical: without it, the right-column aside is
|
||||
{/* Charts + rails sit side-by-side at xl+. Each side is an auto-fit
|
||||
grid, so hiding a card causes the remaining ones to widen.
|
||||
`items-start` is critical: without it, the right-column aside is
|
||||
stretched to match the chart column's row height, which forces
|
||||
MyRemindersRail (or any other child with `h-full`) to push later
|
||||
children out of the aside's box and into the rows below where
|
||||
ActivityFeed renders. */}
|
||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
<WidgetErrorBoundary>
|
||||
<PipelineFunnelChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<OccupancyTimelineChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<RevenueBreakdownChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<LeadSourceChart range={range} />
|
||||
</WidgetErrorBoundary>
|
||||
children out of the aside's box. */}
|
||||
{/* Charts + rails. Layout adapts to which regions have content so
|
||||
we never leave a 320px stripe of dead space when only one side
|
||||
is populated:
|
||||
both → main 1fr column + 320px rail (the original layout)
|
||||
charts only → single full-width auto-fit chart grid
|
||||
rails only → rails widen into an auto-fit grid (no fixed 320)
|
||||
neither → nothing renders
|
||||
The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
|
||||
the row; the rails-only grid uses a slightly tighter `280px`
|
||||
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
||||
instead of stretching to 600px+ each. */}
|
||||
{charts.length > 0 && rails.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||
{charts.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</div>
|
||||
<aside className="min-w-0 space-y-4">
|
||||
{rails.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
<aside className="min-w-0 space-y-4">
|
||||
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
|
||||
isn't configured for this port. */}
|
||||
<WidgetErrorBoundary>
|
||||
<WebsiteGlanceTile />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<MyRemindersRail />
|
||||
</WidgetErrorBoundary>
|
||||
<WidgetErrorBoundary>
|
||||
<AlertRail />
|
||||
</WidgetErrorBoundary>
|
||||
</aside>
|
||||
</div>
|
||||
) : charts.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||||
{charts.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</div>
|
||||
) : rails.length > 0 ? (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{rails.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ActivityFeed />
|
||||
{feed.map((w) => (
|
||||
<WidgetCell key={w.id} widget={w} range={range} />
|
||||
))}
|
||||
|
||||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder shown when the rep has hidden every widget. Without this,
|
||||
* the dashboard collapses to just the gradient header strip and looks
|
||||
* like a broken page — this hints at the "Customize" button to bring
|
||||
* widgets back.
|
||||
*/
|
||||
function EmptyDashboardHint() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-6 py-16 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No widgets on your dashboard yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Click <span className="font-medium text-foreground">Customize</span> above to pick which
|
||||
analytics cards appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
|
||||
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,15 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
{isCustom ? formatCustom(value) : 'Custom'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[260px] p-3">
|
||||
<PopoverContent
|
||||
align="end"
|
||||
// min() caps the popover at "viewport minus 16px" on narrow
|
||||
// phones so it never overflows; otherwise sits at a compact
|
||||
// 260px. Date inputs inside use w-auto so iOS's intrinsic
|
||||
// date-input width (which ignores parent constraints) sizes
|
||||
// to its own content rather than overflowing.
|
||||
className="w-[min(260px,calc(100vw-1rem))] p-3"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Custom range
|
||||
@@ -141,7 +149,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
empty result, and not understand why. */
|
||||
max={draftTo && draftTo < today ? draftTo : today}
|
||||
onChange={(e) => setDraftFrom(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
@@ -152,7 +160,7 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
||||
min={draftFrom || undefined}
|
||||
max={today}
|
||||
onChange={(e) => setDraftTo(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
className="w-auto max-w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
|
||||
108
src/components/dashboard/hot-deals-card.tsx
Normal file
108
src/components/dashboard/hot-deals-card.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface HotDeal {
|
||||
id: string;
|
||||
stage: string;
|
||||
clientName: string;
|
||||
mooringNumber: string | null;
|
||||
lastContact: string | null;
|
||||
}
|
||||
|
||||
interface HotDealsResponse {
|
||||
data: HotDeal[];
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
contract_signed: 'Contract Signed',
|
||||
contract_sent: 'Contract Sent',
|
||||
deposit_10: 'Deposit 10%',
|
||||
eoi_signed: 'EOI Signed',
|
||||
eoi_sent: 'EOI Sent',
|
||||
in_comms: 'In Comms',
|
||||
details_sent: 'Details Sent',
|
||||
open: 'Open',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Top 5 in-flight interests closest to closing. Ranked server-side by
|
||||
* pipeline stage (the further along, the closer to signing) with most-
|
||||
* recent activity as a tiebreaker. Gives reps a "what should I be
|
||||
* chasing this week" view without opening the full pipeline board.
|
||||
*/
|
||||
export function HotDealsCard() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<HotDealsResponse>({
|
||||
queryKey: ['dashboard', 'hot_deals'],
|
||||
queryFn: () => apiFetch<HotDealsResponse>('/api/v1/dashboard/hot-deals'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deals = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
<Flame className="size-4 text-orange-500" aria-hidden />
|
||||
Hot deals
|
||||
</CardTitle>
|
||||
<CardDescription>Active interests closest to closing.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : deals.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No active deals to chase. New leads will surface here once they advance past Open.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{deals.map((d) => (
|
||||
<li key={d.id}>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/interests/${d.id}` as any}
|
||||
className="-mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{d.clientName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{d.mooringNumber ? `Berth ${d.mooringNumber}` : 'No berth linked'}
|
||||
{d.lastContact ? (
|
||||
<>
|
||||
{' · '}
|
||||
last touched {formatDistanceToNow(new Date(d.lastContact))} ago
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
{STAGE_LABELS[d.stage] ?? d.stage}
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useLeadSource } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
import { SOURCE_LABELS as CANONICAL_SOURCE_LABELS } from '@/lib/constants';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -20,10 +22,11 @@ const COLORS = [
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
// Extend the canonical source labels with the analytics-specific buckets the
|
||||
// API returns (`unspecified` for null sources, legacy `social`). Renames to the
|
||||
// canonical set in /lib/constants stay in sync via the spread.
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
referral: 'Referral',
|
||||
manual: 'Manual',
|
||||
...CANONICAL_SOURCE_LABELS,
|
||||
social: 'Social',
|
||||
unspecified: 'Unspecified',
|
||||
};
|
||||
@@ -48,7 +51,7 @@ export function LeadSourceChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Lead Source Attribution"
|
||||
description="Where new interests came from"
|
||||
exportFilename={`lead-source-${range}`}
|
||||
exportFilename={`lead-source-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -87,7 +87,7 @@ export function MyRemindersRail() {
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${portSlug}/reminders` as never}
|
||||
href={`/${portSlug}/inbox#reminders` as never}
|
||||
className="text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useOccupancy } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -41,7 +42,7 @@ export function OccupancyTimelineChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Occupancy Timeline"
|
||||
description="Daily berth occupancy across the range"
|
||||
exportFilename={`occupancy-timeline-${range}`}
|
||||
exportFilename={`occupancy-timeline-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { STAGE_SHORT_LABELS, safeStage, stageLabel } from '@/lib/constants';
|
||||
import { ChartCard } from './chart-card';
|
||||
import { useFunnel } from './use-analytics';
|
||||
import type { DateRange } from '@/lib/services/analytics.service';
|
||||
import { rangeToSlug } from '@/lib/analytics/range';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
@@ -38,7 +39,7 @@ export function PipelineFunnelChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Pipeline Funnel"
|
||||
description="Interests by stage with conversion rate vs. open"
|
||||
exportFilename={`pipeline-funnel-${range}`}
|
||||
exportFilename={`pipeline-funnel-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
54
src/components/dashboard/pipeline-value-tile.tsx
Normal file
54
src/components/dashboard/pipeline-value-tile.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
|
||||
interface KpiResponse {
|
||||
pipelineValueUsd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total pipeline value for active interests, USD-denominated. Sourced
|
||||
* from the same KPIs endpoint as the active-deals tile so the two
|
||||
* share a cache entry and render in lockstep.
|
||||
*/
|
||||
export function PipelineValueTile() {
|
||||
const { data, isLoading } = useQuery<KpiResponse>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
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" />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(data?.pipelineValueUsd ?? 0, 'USD')}
|
||||
>
|
||||
{formatCurrency(data?.pipelineValueUsd ?? 0, 'USD', { maxFractionDigits: 0 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -42,7 +43,7 @@ export function RevenueBreakdownChart({ range }: Props) {
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${range}`}
|
||||
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
91
src/components/dashboard/source-conversion-chart.tsx
Normal file
91
src/components/dashboard/source-conversion-chart.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface SourceRow {
|
||||
source: string;
|
||||
total: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
interface SourceConversionResponse {
|
||||
data: SourceRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bar list of lead-source conversion rates. Complements the
|
||||
* existing Lead Source Attribution donut: that one shows where leads
|
||||
* COME from, this shows which sources actually CONVERT. Lets marketing
|
||||
* spend follow the buyers, not the tire-kickers.
|
||||
*
|
||||
* Renders only sources with at least one lead; uses a compact bar-in-
|
||||
* row layout so 5-8 sources fit comfortably without scrolling.
|
||||
*/
|
||||
export function SourceConversionChart() {
|
||||
const { data, isLoading } = useQuery<SourceConversionResponse>({
|
||||
queryKey: ['dashboard', 'source_conversion'],
|
||||
queryFn: () => apiFetch<SourceConversionResponse>('/api/v1/dashboard/source-conversion'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Source conversion</CardTitle>
|
||||
<CardDescription>Won deals as a percentage of leads per source.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
Once interests have a source assigned, conversion rates will appear here.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{rows.map((r) => {
|
||||
const pct = Math.round(r.conversionRate * 100);
|
||||
const label = r.source
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (
|
||||
<li key={r.source} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{pct}%</span>
|
||||
<span className="ml-1.5">
|
||||
({r.won} won · {r.total} total)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Inline bar — keeps the widget compact and lets eight
|
||||
rows share the same vertical space a Recharts plot
|
||||
would use for two. */}
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.max(pct, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
198
src/components/dashboard/widget-registry.tsx
Normal file
198
src/components/dashboard/widget-registry.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Dashboard widget registry — the single source of truth for which
|
||||
* widgets exist, what they're called, where they live, and what they
|
||||
* default to. The DashboardShell loops over this; the settings UI also
|
||||
* loops over this. Adding a new widget = adding one entry here.
|
||||
*
|
||||
* Widget visibility is persisted per-user in
|
||||
* `user_profiles.preferences.dashboardWidgets` as `{ [id]: boolean }`.
|
||||
* Missing entries default to `defaultVisible`, so a brand-new widget
|
||||
* surfaces for existing users automatically.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ActiveDealsTile } from './active-deals-tile';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
import { BerthStatusChart } from './berth-status-chart';
|
||||
import { HotDealsCard } from './hot-deals-card';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { PipelineValueTile } from './pipeline-value-tile';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { SourceConversionChart } from './source-conversion-chart';
|
||||
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||
import { MyRemindersRail } from './my-reminders-rail';
|
||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||
import type { DateRange } from '@/lib/analytics/range';
|
||||
|
||||
/**
|
||||
* Where a widget lives on the dashboard. The shell renders three
|
||||
* separate auto-fit regions so charts and rails don't compete for the
|
||||
* same horizontal slots (preserves the visual hierarchy the team has
|
||||
* gotten used to).
|
||||
*
|
||||
* - 'chart' → main analytics region (wider min-col)
|
||||
* - 'rail' → side-rail region (narrower min-col)
|
||||
* - 'feed' → full-width row underneath everything else
|
||||
*/
|
||||
export type WidgetGroup = 'chart' | 'rail' | 'feed';
|
||||
|
||||
/**
|
||||
* External integrations a widget can depend on. When the corresponding
|
||||
* integration isn't connected for the active port, the widget is hidden
|
||||
* from the picker AND from the rendered dashboard so reps can't toggle
|
||||
* something that would render nothing. Wire new integrations through
|
||||
* `useDashboardIntegrations()`.
|
||||
*/
|
||||
export type WidgetIntegration = 'umami' | 'documenso';
|
||||
|
||||
export interface DashboardWidget {
|
||||
/** Stable persistence key. Don't rename — old preferences would break. */
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/**
|
||||
* Renders the widget. Receives the active date-range so chart widgets
|
||||
* can react; non-chart widgets simply ignore it. Keeping this a
|
||||
* function instead of a `ComponentType` lets each widget pick its own
|
||||
* prop shape without leaking the union into the registry type.
|
||||
*/
|
||||
render: (range: DateRange) => ReactNode;
|
||||
group: WidgetGroup;
|
||||
defaultVisible: boolean;
|
||||
/**
|
||||
* Some widgets self-gate (e.g. WebsiteGlanceTile renders null when
|
||||
* Umami isn't configured). When `true`, the settings UI still shows
|
||||
* the toggle so admins can enable it once the integration is wired —
|
||||
* but the widget itself decides whether to render content.
|
||||
*/
|
||||
selfGates?: boolean;
|
||||
/**
|
||||
* Names the external integration this widget depends on. When the
|
||||
* integration isn't connected for the active port, the widget is
|
||||
* filtered out of both the picker and the rendered dashboard.
|
||||
*/
|
||||
requires?: WidgetIntegration;
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
// ── KPI tiles (rail) ────────────────────────────────────────────────
|
||||
// Off by default — keep the existing dashboard layout unchanged for
|
||||
// users on first paint after the upgrade; reps can flip them on from
|
||||
// the Customize menu.
|
||||
{
|
||||
id: 'kpi_active_deals',
|
||||
label: 'Active Deals',
|
||||
description: 'Compact tile: count of in-flight interests.',
|
||||
render: () => <ActiveDealsTile />,
|
||||
group: 'rail',
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
id: 'kpi_pipeline_value',
|
||||
label: 'Pipeline Value',
|
||||
description: 'Compact tile: total berth value of active deals (USD).',
|
||||
render: () => <PipelineValueTile />,
|
||||
group: 'rail',
|
||||
defaultVisible: false,
|
||||
},
|
||||
|
||||
// ── Charts (main area) ──────────────────────────────────────────────
|
||||
{
|
||||
id: 'pipeline_funnel',
|
||||
label: 'Pipeline Funnel',
|
||||
description: 'Interests by stage with conversion-rate vs open.',
|
||||
render: (range) => <PipelineFunnelChart range={range} />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'occupancy_timeline',
|
||||
label: 'Occupancy Timeline',
|
||||
description: 'Daily berth occupancy across the range.',
|
||||
render: (range) => <OccupancyTimelineChart range={range} />,
|
||||
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',
|
||||
description: 'Where new interests came from.',
|
||||
render: (range) => <LeadSourceChart range={range} />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'berth_status',
|
||||
label: 'Berth Status',
|
||||
description: 'Donut: available / under offer / sold split.',
|
||||
render: () => <BerthStatusChart />,
|
||||
group: 'chart',
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
id: 'source_conversion',
|
||||
label: 'Source Conversion',
|
||||
description: 'Win rate per lead source — which channels deliver buyers, not just leads.',
|
||||
render: () => <SourceConversionChart />,
|
||||
group: 'chart',
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
id: 'website_analytics',
|
||||
label: 'Website Analytics',
|
||||
description: 'Quick glance at marketing site traffic. Requires Umami.',
|
||||
render: () => <WebsiteGlanceTile />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'umami',
|
||||
},
|
||||
{
|
||||
id: 'my_reminders',
|
||||
label: 'My Reminders',
|
||||
description: 'Your upcoming and overdue reminders.',
|
||||
render: () => <MyRemindersRail />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'alerts',
|
||||
label: 'Alerts',
|
||||
description: 'System-flagged action items.',
|
||||
render: () => <AlertRail />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
},
|
||||
{
|
||||
id: 'hot_deals',
|
||||
label: 'Hot Deals',
|
||||
description: 'Top 5 active interests closest to closing.',
|
||||
render: () => <HotDealsCard />,
|
||||
group: 'rail',
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
id: 'activity_feed',
|
||||
label: 'Recent Activity',
|
||||
description: 'Audit log of changes across the port.',
|
||||
render: () => <ActivityFeed />,
|
||||
group: 'feed',
|
||||
defaultVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Lookup helper so consumers don't have to scan the array. */
|
||||
export const WIDGETS_BY_ID: Record<string, DashboardWidget> = Object.fromEntries(
|
||||
DASHBOARD_WIDGETS.map((w) => [w.id, w]),
|
||||
);
|
||||
@@ -230,8 +230,8 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="documenso-template">Documenso renders + signs</SelectItem>
|
||||
<SelectItem value="inapp">Render in CRM, sign via Documenso</SelectItem>
|
||||
<SelectItem value="documenso-template">Generated EOI — rendered + signed externally</SelectItem>
|
||||
<SelectItem value="inapp">Manual EOI — rendered in CRM, sent for e-signature</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('Cancel this document? This voids it in Documenso and cannot be undone.')) return;
|
||||
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return;
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DocumentTemplatePicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -6,10 +6,18 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
@@ -219,6 +227,8 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -364,19 +374,48 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
||||
<EmptyState
|
||||
icon={<FileText className="h-7 w-7" />}
|
||||
title="No documents in this folder"
|
||||
body="Create a document or move existing ones here."
|
||||
body="Upload a file, generate a signing flow, or move existing documents here."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New document
|
||||
</Link>
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
Upload file
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Generate for signing
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
||||
)}
|
||||
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload file</DialogTitle>
|
||||
<DialogDescription>
|
||||
{folderId === null
|
||||
? 'File will be added to the root.'
|
||||
: 'File will be added to the current folder.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FileUploadZone
|
||||
folderId={folderId}
|
||||
onUploadComplete={(file) => {
|
||||
if (!file) {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
||||
setUploadOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export function EoiGenerateDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
|
||||
Documenso Standard EOI (recommended)
|
||||
Standard EOI — sent for e-signature (recommended)
|
||||
</SelectItem>
|
||||
{inAppTemplates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
|
||||
@@ -77,83 +77,88 @@ export function FolderActionsMenu({ selectedFolderId, onAfterDelete }: FolderAct
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Folder actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setName('');
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder {isFolderSelected ? 'inside this' : 'at root'}
|
||||
</DropdownMenuItem>
|
||||
{isFolderSelected ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onClick={() => {
|
||||
if (isSystem) return;
|
||||
setName(currentName);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
title="Delete folder?"
|
||||
description="Subfolders and documents inside will move up to the parent. The folder itself is removed."
|
||||
confirmLabel="Delete folder"
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(selectedFolderId as string);
|
||||
toast.success('Folder deleted; contents moved to parent.');
|
||||
onAfterDelete?.();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => {
|
||||
setName('');
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder {isFolderSelected ? 'inside this' : 'at root'}
|
||||
</Button>
|
||||
{isFolderSelected ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More folder actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onClick={() => {
|
||||
if (isSystem) return;
|
||||
setName(currentName);
|
||||
setRenameOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be renamed.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={isSystem}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be deleted.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
title="Delete folder?"
|
||||
description="Subfolders and documents inside will move up to the parent. The folder itself is removed."
|
||||
confirmLabel="Delete folder"
|
||||
loading={deleteMutation.isPending}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(selectedFolderId as string);
|
||||
toast.success('Folder deleted; contents moved to parent.');
|
||||
onAfterDelete?.();
|
||||
} catch (err) {
|
||||
toastError(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isSystem ? (
|
||||
<TooltipContent>System folders can't be deleted.</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -100,7 +100,7 @@ function TreeBody({
|
||||
onClick={() => onSelect(undefined)}
|
||||
/>
|
||||
<PseudoRow
|
||||
label="Root (no folder)"
|
||||
label="Root"
|
||||
icon={Folder}
|
||||
active={selectedFolderId === null}
|
||||
onClick={() => onSelect(null)}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function NewDocumentMenu({
|
||||
<div className="flex flex-col">
|
||||
<span>Generate document for signing</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
EOI, contract, or custom — sent via Documenso
|
||||
EOI, contract, or custom — sent for e-signature
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function TripLabelCombobox({
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
153
src/components/inbox/inbox-page-shell.tsx
Normal file
153
src/components/inbox/inbox-page-shell.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, ChevronDown, ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
|
||||
import { ReminderList } from '@/components/reminders/reminder-list';
|
||||
import { useAlertCount } from '@/components/alerts/use-alerts';
|
||||
|
||||
/**
|
||||
* Merged "Inbox" surface — replaces the previously-separate /alerts and
|
||||
* /reminders pages. Two stacked sections (Alerts first, Reminders second)
|
||||
* preserve the source distinction (system-flagged vs user-set) while
|
||||
* giving reps a single "things demanding my attention" surface.
|
||||
*
|
||||
* Sections are collapsible; collapsed state persists in localStorage per
|
||||
* section so reps can default to the layout they prefer.
|
||||
*
|
||||
* URL anchors:
|
||||
* /inbox#alerts → ensures Alerts section is expanded + scrolls to it
|
||||
* /inbox#reminders → ensures Reminders section is expanded + scrolls to it
|
||||
*
|
||||
* The legacy /alerts and /reminders routes redirect here with the
|
||||
* appropriate hash, so old bookmarks land in the right place.
|
||||
*/
|
||||
export function InboxPageShell() {
|
||||
const [alertsOpen, setAlertsOpen] = useState(true);
|
||||
const [remindersOpen, setRemindersOpen] = useState(true);
|
||||
const { data: alertCount } = useAlertCount();
|
||||
|
||||
// Hydrate collapsed state from localStorage on mount. Stored as
|
||||
// 'true'/'false' strings; missing keys default to expanded.
|
||||
useEffect(() => {
|
||||
const a = localStorage.getItem('inbox.alerts.open');
|
||||
const r = localStorage.getItem('inbox.reminders.open');
|
||||
if (a === 'false') setAlertsOpen(false);
|
||||
if (r === 'false') setRemindersOpen(false);
|
||||
}, []);
|
||||
|
||||
// Honor URL hash: ensure the targeted section is expanded then scroll.
|
||||
// Runs once on mount AND on hashchange so deep-linking from another tab
|
||||
// / page works the same as initial navigation.
|
||||
useEffect(() => {
|
||||
function applyHash() {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
if (hash === 'alerts') {
|
||||
setAlertsOpen(true);
|
||||
document.getElementById('inbox-section-alerts')?.scrollIntoView({ behavior: 'smooth' });
|
||||
} else if (hash === 'reminders') {
|
||||
setRemindersOpen(true);
|
||||
document.getElementById('inbox-section-reminders')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
applyHash();
|
||||
window.addEventListener('hashchange', applyHash);
|
||||
return () => window.removeEventListener('hashchange', applyHash);
|
||||
}, []);
|
||||
|
||||
function toggleAlerts() {
|
||||
const next = !alertsOpen;
|
||||
setAlertsOpen(next);
|
||||
localStorage.setItem('inbox.alerts.open', String(next));
|
||||
}
|
||||
function toggleReminders() {
|
||||
const next = !remindersOpen;
|
||||
setRemindersOpen(next);
|
||||
localStorage.setItem('inbox.reminders.open', String(next));
|
||||
}
|
||||
|
||||
const activeAlerts = alertCount?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Alerts & Reminders"
|
||||
eyebrow="Action items"
|
||||
description="Alerts the system has flagged plus your scheduled follow-ups, in one place."
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section id="inbox-section-reminders" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<Bell className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Reminders"
|
||||
open={remindersOpen}
|
||||
onToggle={toggleReminders}
|
||||
/>
|
||||
{remindersOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<ReminderList embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count?: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 px-4 py-3 text-left',
|
||||
'min-h-[48px] hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||
{count !== undefined && count > 0 ? (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn('size-4 text-muted-foreground transition-transform', open && 'rotate-180')}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload externally-signed EOI</DialogTitle>
|
||||
<DialogDescription>
|
||||
For EOIs signed outside Documenso (paper, in person, alternate e-sign vendor). The
|
||||
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). The
|
||||
uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI
|
||||
Signed.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
deriveInitials,
|
||||
} from '@/components/shared/list-card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { stageBadgeClass, stageDotClass, stageLabel as toStageLabel } from '@/lib/constants';
|
||||
import {
|
||||
stageBadgeClass,
|
||||
stageDotClass,
|
||||
stageLabel as toStageLabel,
|
||||
formatSource,
|
||||
} from '@/lib/constants';
|
||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||
import type { InterestRow } from './interest-columns';
|
||||
|
||||
@@ -28,13 +33,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
hot_lead: 'Hot lead',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface InterestCardProps {
|
||||
interest: InterestRow;
|
||||
portSlug: string;
|
||||
@@ -48,7 +46,7 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
||||
const accentClass = stageDotClass(interest.pipelineStage);
|
||||
const isHotLead = interest.leadCategory === 'hot_lead';
|
||||
const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null;
|
||||
const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null;
|
||||
const sourceLabel = formatSource(interest.source);
|
||||
const tags = interest.tags ?? [];
|
||||
const notesCount = interest.notesCount ?? 0;
|
||||
const urgencyBadges = computeUrgencyBadges(interest);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -391,14 +392,47 @@ function ComposeDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-followup">Follow up by (optional — creates a reminder)</Label>
|
||||
<Input
|
||||
id="cl-followup"
|
||||
type="datetime-local"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm font-medium cursor-pointer select-none"
|
||||
htmlFor="cl-followup-toggle"
|
||||
>
|
||||
<Checkbox
|
||||
id="cl-followup-toggle"
|
||||
checked={!!followUpAt}
|
||||
onCheckedChange={(v) => {
|
||||
if (v) {
|
||||
// Default to a week from now @ 09:00 local so reps get a
|
||||
// usable cadence without having to type a date.
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 7);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
const tz = d.getTimezoneOffset() * 60_000;
|
||||
setFollowUpAt(new Date(d.getTime() - tz).toISOString().slice(0, 16));
|
||||
} else {
|
||||
setFollowUpAt('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Add follow-up reminder?
|
||||
</label>
|
||||
{followUpAt ? (
|
||||
<div className="space-y-1 pl-6">
|
||||
<Label htmlFor="cl-followup" className="text-xs text-muted-foreground">
|
||||
Remind me on
|
||||
</Label>
|
||||
<Input
|
||||
id="cl-followup"
|
||||
type="datetime-local"
|
||||
value={followUpAt}
|
||||
onChange={(e) => setFollowUpAt(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
A reminder is created on this interest for the time above.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -278,7 +278,7 @@ function ActiveContractCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -341,7 +341,7 @@ function EmptyContractState({
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled
|
||||
externally, or upload the draft PDF and send for e-signing via Documenso.
|
||||
externally, or upload the draft PDF and send for e-signing.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
|
||||
|
||||
@@ -33,6 +33,7 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||
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: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' },
|
||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
||||
};
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ function ActiveEoiCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -329,7 +329,7 @@ function EmptyEoiState({
|
||||
No EOI in flight for this interest
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Generate the EOI to send it for signing — Documenso handles the signing chain. You can also
|
||||
Generate the EOI to send it for signing — the signing service handles the signing chain. You can also
|
||||
upload a paper-signed copy if it was signed outside the system.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
@@ -30,12 +40,13 @@ import {
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
|
||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES } from '@/lib/constants';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -77,14 +88,14 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
const [clientOpen, setClientOpen] = useState(false);
|
||||
const [berthOpen, setBerthOpen] = useState(false);
|
||||
const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft');
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm<CreateInterestInput>({
|
||||
resolver: zodResolver(createInterestSchema),
|
||||
defaultValues: {
|
||||
@@ -102,6 +113,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedBerthId = watch('berthId');
|
||||
const selectedYachtId = watch('yachtId');
|
||||
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
|
||||
function requestClose() {
|
||||
if (isDirty && !isSubmitting && !mutation.isPending) {
|
||||
setDiscardConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
// Fetch the selected client's company memberships so the YachtPicker can
|
||||
// include yachts owned by companies the client belongs to (e.g. a
|
||||
@@ -200,7 +220,16 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
requestClose();
|
||||
}}
|
||||
>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||
@@ -215,7 +244,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Client *</Label>
|
||||
<Popover open={clientOpen} onOpenChange={setClientOpen}>
|
||||
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -231,8 +260,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
|
||||
{/* shouldFilter={false}: server-side search via setClientSearch
|
||||
drives the result set. Without this, cmdk's default filter
|
||||
matches the user's typed text against CommandItem.value
|
||||
(the client UUID) and silently drops every result that
|
||||
doesn't contain the typed substring in its id. */}
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search clients..." onValueChange={setClientSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
@@ -269,7 +303,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Berth (optional)</Label>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -284,8 +318,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search berths..." onValueChange={setBerthSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
@@ -431,10 +465,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -444,48 +479,43 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
<Separator />
|
||||
|
||||
{/* Desired berth dimensions (recommender inputs) */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Berth size desired
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Imperial. Optional - the recommender treats blank fields as no constraint on that
|
||||
axis.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desiredLengthFt">Length (ft)</Label>
|
||||
<Input
|
||||
id="desiredLengthFt"
|
||||
{...register('desiredLengthFt')}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="e.g. 60"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desiredWidthFt">Width (ft)</Label>
|
||||
<Input
|
||||
id="desiredWidthFt"
|
||||
{...register('desiredWidthFt')}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="e.g. 18"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desiredDraftFt">Draft (ft)</Label>
|
||||
<Input
|
||||
id="desiredDraftFt"
|
||||
{...register('desiredDraftFt')}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="e.g. 6"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Berth size desired
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Optional - the recommender treats blank fields as no constraint on that axis.
|
||||
</p>
|
||||
</div>
|
||||
<UnitToggle value={desiredUnit} onChange={setDesiredUnit} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<DimensionInput
|
||||
htmlId="desiredLengthFt"
|
||||
label="Length"
|
||||
placeholder={desiredUnit === 'ft' ? 'e.g. 60' : 'e.g. 18.29'}
|
||||
unit={desiredUnit}
|
||||
ftValue={watch('desiredLengthFt') as string | undefined}
|
||||
onChangeFt={(v) => setValue('desiredLengthFt', v, { shouldDirty: true })}
|
||||
/>
|
||||
<DimensionInput
|
||||
htmlId="desiredWidthFt"
|
||||
label="Width"
|
||||
placeholder={desiredUnit === 'ft' ? 'e.g. 18' : 'e.g. 5.49'}
|
||||
unit={desiredUnit}
|
||||
ftValue={watch('desiredWidthFt') as string | undefined}
|
||||
onChangeFt={(v) => setValue('desiredWidthFt', v, { shouldDirty: true })}
|
||||
/>
|
||||
<DimensionInput
|
||||
htmlId="desiredDraftFt"
|
||||
label="Draft"
|
||||
placeholder={desiredUnit === 'ft' ? 'e.g. 6' : 'e.g. 1.83'}
|
||||
unit={desiredUnit}
|
||||
ftValue={watch('desiredDraftFt') as string | undefined}
|
||||
onChangeFt={(v) => setValue('desiredDraftFt', v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -506,12 +536,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
{reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Label>Reminder Days</Label>
|
||||
<Input
|
||||
{...register('reminderDays', { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 7"
|
||||
<Label htmlFor="reminderDays">Reminder cadence</Label>
|
||||
<ReminderDaysInput
|
||||
id="reminderDays"
|
||||
value={watch('reminderDays') ?? null}
|
||||
onChange={(v) => setValue('reminderDays', v ?? undefined)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -526,7 +555,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
@@ -537,6 +566,29 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
|
||||
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You've filled in some fields. Closing now will lose them.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setDiscardConfirmOpen(false);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive"
|
||||
>
|
||||
Discard
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SheetContent>
|
||||
{createYachtOpen && selectedClientId && (
|
||||
<YachtForm
|
||||
@@ -548,3 +600,140 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers for the "Berth size desired" section ──────────────────────────────
|
||||
|
||||
const FT_PER_M = 1 / 0.3048;
|
||||
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) {
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-md border bg-muted/30 p-0.5 text-xs"
|
||||
role="radiogroup"
|
||||
aria-label="Display unit"
|
||||
>
|
||||
{(['ft', 'm'] as const).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === u}
|
||||
onClick={() => onChange(u)}
|
||||
className={cn(
|
||||
'h-7 rounded px-3 font-medium transition-colors',
|
||||
value === u
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{u}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DimensionInputProps {
|
||||
htmlId: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
unit: 'ft' | 'm';
|
||||
ftValue: string | number | undefined;
|
||||
onChangeFt: (next: string | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single dimension input bound to a form value stored in feet. Renders the
|
||||
* value in the rep's chosen display unit and converts back on edit. The form
|
||||
* state stays canonical ft so the recommender (which queries `b.length_ft`
|
||||
* etc.) sees the same number regardless of which unit the rep typed in.
|
||||
*
|
||||
* Local `display` state preserves mid-typing strings like "18." that would
|
||||
* otherwise be lost to round-tripping through Number().
|
||||
*/
|
||||
function DimensionInput({
|
||||
htmlId,
|
||||
label,
|
||||
placeholder,
|
||||
unit,
|
||||
ftValue,
|
||||
onChangeFt,
|
||||
}: DimensionInputProps) {
|
||||
const focusedRef = useRef(false);
|
||||
const [display, setDisplay] = useState<string>(() => computeDisplay(ftValue, unit));
|
||||
|
||||
// Re-sync from the canonical ft value when it changes externally (form
|
||||
// reset, unit toggle). Skip while focused so we don't fight keystrokes.
|
||||
useEffect(() => {
|
||||
if (focusedRef.current) return;
|
||||
setDisplay(computeDisplay(ftValue, unit));
|
||||
}, [ftValue, unit]);
|
||||
|
||||
const altValue = computeAltDisplay(ftValue, unit);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={htmlId}>
|
||||
{label} <span className="text-muted-foreground">({unit})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={htmlId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder={placeholder}
|
||||
value={display}
|
||||
onFocus={() => {
|
||||
focusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
focusedRef.current = false;
|
||||
// Canonicalize the display from the ft source-of-truth on blur so
|
||||
// any mid-typed garbage clears.
|
||||
setDisplay(computeDisplay(ftValue, unit));
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setDisplay(raw);
|
||||
if (raw === '') {
|
||||
onChangeFt(undefined);
|
||||
return;
|
||||
}
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
onChangeFt(undefined);
|
||||
return;
|
||||
}
|
||||
const ft = unit === 'ft' ? n : n * FT_PER_M;
|
||||
onChangeFt(String(round2(ft)));
|
||||
}}
|
||||
/>
|
||||
{altValue ? (
|
||||
<p className="text-[11px] leading-tight text-muted-foreground">≈ {altValue}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string {
|
||||
if (ftValue === undefined || ftValue === null || ftValue === '') return '';
|
||||
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
|
||||
if (!Number.isFinite(ft)) return '';
|
||||
const v = unit === 'ft' ? ft : ft * 0.3048;
|
||||
return String(round2(v));
|
||||
}
|
||||
|
||||
function computeAltDisplay(
|
||||
ftValue: string | number | undefined,
|
||||
unit: 'ft' | 'm',
|
||||
): string | null {
|
||||
if (ftValue === undefined || ftValue === null || ftValue === '') return null;
|
||||
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
|
||||
if (!Number.isFinite(ft) || ft <= 0) return null;
|
||||
return unit === 'ft' ? `${round2(ft * 0.3048)} m` : `${round2(ft)} ft`;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '@/components/interests/interest-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
import { InterestCard } from '@/components/interests/interest-card';
|
||||
import { StageLegend } from '@/components/interests/stage-legend';
|
||||
@@ -72,6 +73,7 @@ export function InterestList() {
|
||||
}, [viewMode, setViewMode]);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
|
||||
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
|
||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||
|
||||
@@ -29,6 +29,7 @@ const OUTCOME_LABELS: Record<InterestOutcome, string> = {
|
||||
lost_other_marina: 'Lost - went to another marina',
|
||||
lost_unqualified: 'Lost - unqualified',
|
||||
lost_no_response: 'Lost - no response',
|
||||
lost_other: 'Lost - other',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
@@ -36,6 +37,7 @@ const LOST_OUTCOMES: InterestOutcome[] = [
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export function InterestPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -281,7 +281,7 @@ function ActiveReservationCard({
|
||||
</div>
|
||||
) : signers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Documenso hasn't reported signers yet — check back in a moment.
|
||||
The signing service hasn't reported signers yet — check back in a moment.
|
||||
</p>
|
||||
) : (
|
||||
<SigningProgress documentId={doc.id} signers={signers} />
|
||||
@@ -344,7 +344,7 @@ function EmptyReservationState({
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you
|
||||
handled externally, or upload the draft PDF and send for e-signing via Documenso.
|
||||
handled externally, or upload the draft PDF and send for e-signing.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
@@ -20,6 +22,7 @@ import { InterestDocumentsTab } from '@/components/interests/interest-documents-
|
||||
import {
|
||||
LEAD_CATEGORIES,
|
||||
PIPELINE_STAGES,
|
||||
SOURCES,
|
||||
canTransitionStage,
|
||||
type PipelineStage,
|
||||
} from '@/lib/constants';
|
||||
@@ -111,14 +114,17 @@ function useStageMutation(interestId: string) {
|
||||
stage,
|
||||
reason,
|
||||
override,
|
||||
milestoneDate,
|
||||
}: {
|
||||
stage: string;
|
||||
reason?: string;
|
||||
override?: boolean;
|
||||
/** Optional ISO date for the milestone column (instead of "now"). */
|
||||
milestoneDate?: string;
|
||||
}) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: stage, reason, override },
|
||||
body: { pipelineStage: stage, reason, override, milestoneDate },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
@@ -173,7 +179,7 @@ interface MilestoneSectionProps {
|
||||
hideAutoButton?: boolean;
|
||||
}>;
|
||||
status: string | null;
|
||||
onAdvance: (stage: string) => void;
|
||||
onAdvance: (stage: string, milestoneDate?: 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.
|
||||
@@ -196,6 +202,87 @@ interface MilestoneSectionProps {
|
||||
* (Documenso webhook, paid invoice → deposit, etc.), they patch the same
|
||||
* stage endpoint and these checkmarks light up automatically.
|
||||
*/
|
||||
/**
|
||||
* Button that opens a date-picker popover before advancing a milestone. The
|
||||
* default is today, but the rep can back-date the event (e.g. "deposit
|
||||
* landed yesterday") so the stamped milestone column reflects the real date
|
||||
* rather than the click time.
|
||||
*/
|
||||
function MilestoneAdvanceButton({
|
||||
label,
|
||||
variant,
|
||||
disabled,
|
||||
onConfirm,
|
||||
}: {
|
||||
label: string;
|
||||
variant: 'default' | 'outline' | 'ghostLink';
|
||||
disabled?: boolean;
|
||||
onConfirm: (milestoneDate: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState<string>(() => new Date().toISOString().slice(0, 10));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{variant === 'ghostLink' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="mt-2 h-7 px-2.5 text-xs"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 space-y-2 p-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium" htmlFor="milestone-date">
|
||||
Date completed
|
||||
</label>
|
||||
<Input
|
||||
id="milestone-date"
|
||||
type="date"
|
||||
value={date}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Defaults to today — back-date if the event happened earlier.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!date || disabled}
|
||||
onClick={() => {
|
||||
onConfirm(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function MilestoneSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
@@ -282,16 +369,12 @@ function MilestoneSection({
|
||||
) : null}
|
||||
</div>
|
||||
{isNext && step.advanceStage && !step.hideAutoButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
<MilestoneAdvanceButton
|
||||
label={step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
|
||||
variant={isActive ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() => onAdvance(step.advanceStage!)}
|
||||
className="mt-2 h-7 px-2.5 text-xs"
|
||||
>
|
||||
{step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
|
||||
</Button>
|
||||
onConfirm={(date) => onAdvance(step.advanceStage!, date)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
@@ -392,7 +475,7 @@ function OverviewTab({
|
||||
* skip-ahead pattern from the inline stage picker so audit trails
|
||||
* stay consistent regardless of which surface the rep used.
|
||||
*/
|
||||
const advance = (stage: string) => {
|
||||
const advance = (stage: string, milestoneDate?: string) => {
|
||||
const fromStage = interest.pipelineStage as PipelineStage;
|
||||
const toStage = stage as PipelineStage;
|
||||
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
|
||||
@@ -409,6 +492,7 @@ function OverviewTab({
|
||||
stage,
|
||||
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
|
||||
override: isOverride || undefined,
|
||||
milestoneDate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -566,14 +650,12 @@ function OverviewTab({
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advance('deposit_10pct')}
|
||||
<MilestoneAdvanceButton
|
||||
label="Mark received manually"
|
||||
variant="ghostLink"
|
||||
disabled={stageMutation.isPending}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Mark received manually
|
||||
</button>
|
||||
onConfirm={(date) => advance('deposit_10pct', date)}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: interest.dateDepositReceived
|
||||
@@ -682,7 +764,12 @@ function OverviewTab({
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Source">
|
||||
<InlineEditableField value={interest.source} onSave={save('source')} />
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
value={interest.source}
|
||||
onSave={save('source')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ const LOST_OUTCOMES = new Set([
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ import { Label } from '@/components/ui/label';
|
||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -303,53 +310,96 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||
flexed to the far right via justify-between — when the
|
||||
column is wide, justify-between created a confusing visual
|
||||
gulf between the action and what it controls. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`specific-${row.berthId}`}
|
||||
checked={row.isSpecificInterest}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`specific-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Specifically pitching
|
||||
</Label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
{/* Switch sits next to its label (gap-2.5) instead of being
|
||||
flexed to the far right via justify-between — when the
|
||||
column is wide, justify-between created a confusing visual
|
||||
gulf between the action and what it controls. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`specific-${row.berthId}`}
|
||||
checked={row.isSpecificInterest}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate(row.berthId, { isSpecificInterest: checked })
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`specific-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Specifically pitching
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
aria-label="What does Specifically pitching do?"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
|
||||
Mark this berth as one your client is actively considering. When on, the berth
|
||||
appears as <strong>Under Offer</strong> on the public map and counts toward the
|
||||
recommender's "heat" score. Turn off if the link is legal/EOI-only.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`bundle-${row.berthId}`}
|
||||
checked={row.isInEoiBundle}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
|
||||
/>
|
||||
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
|
||||
Mark in EOI bundle
|
||||
</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
id={`bundle-${row.berthId}`}
|
||||
checked={row.isInEoiBundle}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`bundle-${row.berthId}`}
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Mark in EOI bundle
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
aria-label="What does Mark in EOI bundle do?"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
|
||||
Include this berth in the EOI's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's
|
||||
<strong> Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
to keep the link without legal coverage.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s EOI signature.'
|
||||
: 'Not covered by the EOI bundle.'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.isInEoiBundle
|
||||
? 'Covered by the interest’s EOI signature.'
|
||||
: 'Not covered by the EOI bundle.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{showBypassControl ? (
|
||||
<div className="mt-3 flex flex-wrap items-start justify-between gap-2 border-t pt-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
// float far-right while the toggles above are anchored left.
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">Bypass EOI for this berth</p>
|
||||
{row.eoiBypassReason ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
|
||||
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -12,35 +12,26 @@ type TabSpec = {
|
||||
segment: string; // route segment after /[portSlug]/
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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, …)
|
||||
//
|
||||
// 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.
|
||||
// Yachts stays out for the same reason as before: it's an asset record
|
||||
// most often reached from inside an interest or client, not browsed.
|
||||
const TABS: TabSpec[] = [
|
||||
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
|
||||
// Search occupies the center slot. Documents demoted to the MoreSheet —
|
||||
// reps reach docs less often than berths during a walking inventory check,
|
||||
// and pinned-to-client documents are accessed via the client detail anyway.
|
||||
const TABS_LEFT: TabSpec[] = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
];
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const TABS_RIGHT: TabSpec[] = [
|
||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||
];
|
||||
|
||||
// Derive the active port slug from the URL so tab links always target the
|
||||
// current port, even after a port-switch. The dashboard route shape is
|
||||
// /[portSlug]/<rest>, so the slug is the first non-empty path segment.
|
||||
interface MobileBottomTabsProps {
|
||||
onMoreClick: () => void;
|
||||
onSearchClick: () => void;
|
||||
}
|
||||
|
||||
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
|
||||
const pathname = usePathname();
|
||||
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
|
||||
|
||||
function isActive(segment: string): boolean {
|
||||
@@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
className={cn(
|
||||
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
|
||||
'pb-safe-bottom',
|
||||
'grid grid-cols-5',
|
||||
// 5 equal-flex slots.
|
||||
'flex items-end',
|
||||
)}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const active = isActive(tab.segment);
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Link
|
||||
key={tab.segment}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${tab.segment}` as any}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{/* Subtle pill background behind the icon when active. Keeps the
|
||||
tab grid alignment intact while giving the eye an anchor. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute top-1.5 h-7 w-12 rounded-full transition-all',
|
||||
active ? 'bg-primary/10' : 'bg-transparent',
|
||||
)}
|
||||
/>
|
||||
<Icon className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{TABS_LEFT.map((tab) => (
|
||||
<NavTab
|
||||
key={tab.segment}
|
||||
tab={tab}
|
||||
portSlug={portSlug}
|
||||
active={isActive(tab.segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Search button — styled identically to the other navbar tabs. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchClick}
|
||||
className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
|
||||
>
|
||||
<Search className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">Search</span>
|
||||
</button>
|
||||
|
||||
{TABS_RIGHT.map((tab) => (
|
||||
<NavTab
|
||||
key={tab.segment}
|
||||
tab={tab}
|
||||
portSlug={portSlug}
|
||||
active={isActive(tab.segment)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreClick}
|
||||
className="relative flex flex-col items-center justify-center gap-0.5 h-14 text-xs text-muted-foreground transition-colors"
|
||||
className="relative flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-xs text-muted-foreground transition-colors"
|
||||
>
|
||||
<Menu className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">More</span>
|
||||
@@ -95,3 +87,41 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavTab({
|
||||
tab,
|
||||
portSlug,
|
||||
active,
|
||||
}: {
|
||||
tab: TabSpec;
|
||||
portSlug: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${tab.segment}` as any}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'relative flex flex-1 flex-col items-center justify-center gap-0.5 h-14 text-xs transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{/* iOS-native active indicator: a 2px accent bar at the top of
|
||||
the active tab. Cleaner than a colored pill — relies on the
|
||||
icon + label color change (text-primary above) to do the
|
||||
primary signaling, with this bar adding just enough visual
|
||||
anchor to read as "selected". */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-0 mx-auto h-[2px] w-8 rounded-full transition-opacity',
|
||||
active ? 'bg-primary opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Icon className="relative size-5" aria-hidden />
|
||||
<span className="relative font-medium">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MobileLayoutProvider } from './mobile-layout-provider';
|
||||
import { MobileTopbar } from './mobile-topbar';
|
||||
import { MobileBottomTabs } from './mobile-bottom-tabs';
|
||||
import { MoreSheet } from './more-sheet';
|
||||
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
||||
|
||||
/**
|
||||
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
|
||||
@@ -17,6 +18,7 @@ import { MoreSheet } from './more-sheet';
|
||||
*/
|
||||
export function MobileLayout({ children }: { children: ReactNode }) {
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div data-shell="mobile" className="min-h-screen bg-background">
|
||||
@@ -33,8 +35,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<MobileBottomTabs onMoreClick={() => setMoreOpen(true)} />
|
||||
<MobileBottomTabs
|
||||
onMoreClick={() => setMoreOpen(true)}
|
||||
onSearchClick={() => setSearchOpen(true)}
|
||||
/>
|
||||
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
|
||||
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
</MobileLayoutProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Anchor,
|
||||
BarChart3,
|
||||
Bell,
|
||||
BellRing,
|
||||
Bookmark,
|
||||
Building2,
|
||||
FileSignature,
|
||||
Globe,
|
||||
Home,
|
||||
Inbox,
|
||||
Receipt,
|
||||
Settings,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Ship,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
DrawerClose,
|
||||
} from '@/components/shared/drawer';
|
||||
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
type MoreItem = {
|
||||
label: string;
|
||||
@@ -33,32 +35,50 @@ type MoreItem = {
|
||||
segment: string;
|
||||
};
|
||||
|
||||
// Order: most-likely overflow targets first. Interests is here (rather
|
||||
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
|
||||
// reps reach the active deals via the Interests tab on a client detail
|
||||
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
|
||||
// best reached contextually from inside an interest or client.
|
||||
type MoreGroup = {
|
||||
label: string;
|
||||
items: MoreItem[];
|
||||
};
|
||||
|
||||
// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable
|
||||
// spatial layout — reps' muscle memory survives — while making the
|
||||
// "kind of thing" each tile is explicit. Three sections:
|
||||
// - Records: entity lists (people, vessels, properties)
|
||||
// - Operations: daily-use action surfaces
|
||||
// - Configuration: port-level setup, hidden from most reps
|
||||
//
|
||||
// Inbox is intentionally absent — the email/threading inbox feature was
|
||||
// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring
|
||||
// + Google OAuth review are done. Website analytics is filtered below
|
||||
// when Umami isn't configured for this port.
|
||||
const MORE_ITEMS: MoreItem[] = [
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
// Notifications themselves live on the topbar bell — this entry deep-links
|
||||
// to the notification panel inside user-settings (collapsed in 2026-05-09).
|
||||
{ label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
|
||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
{ label: 'Reminders', icon: Bell, segment: 'reminders' },
|
||||
{ label: 'Settings', icon: Settings, segment: 'settings' },
|
||||
{ label: 'Admin', icon: Shield, segment: 'admin' },
|
||||
// Interests stays here (not bottom nav) to dodge the Clients-vs-
|
||||
// Interests UX confusion. Inbox replaces the previously-separate
|
||||
// Alerts + Reminders entries (merged 2026-05-11). Website analytics
|
||||
// and Reservations are filtered out below when not applicable.
|
||||
const MORE_GROUPS: MoreGroup[] = [
|
||||
{
|
||||
label: 'Records',
|
||||
items: [
|
||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
items: [
|
||||
{ label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
|
||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
|
||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Configuration',
|
||||
items: [
|
||||
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
|
||||
{ label: 'Settings', icon: Settings, segment: 'settings' },
|
||||
{ label: 'Admin', icon: Shield, segment: 'admin' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function MoreSheet({
|
||||
@@ -74,10 +94,30 @@ export function MoreSheet({
|
||||
// Hide "Website analytics" if Umami isn't wired up for this port — the
|
||||
// dedicated tile on the dashboard already does the same.
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
const items = MORE_ITEMS.filter(
|
||||
(item) => item.segment !== 'website-analytics' || umamiConfigured,
|
||||
);
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Hide "Reservations" until at least one exists for this port — until the
|
||||
// marina has confirmed bookings, the page is empty and surfaces nothing
|
||||
// useful. Cheap count via pageSize=1; cached 5 min so opening the sheet
|
||||
// repeatedly doesn't refetch.
|
||||
const reservations = useQuery<{ pagination?: { total: number } }>({
|
||||
queryKey: ['berth-reservations', 'sheet-count'],
|
||||
queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
const hasReservations =
|
||||
!reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0;
|
||||
|
||||
// Per-group filter: keep only the items relevant to this port's state.
|
||||
const groups = MORE_GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((item) => {
|
||||
if (item.segment === 'website-analytics') return umamiConfigured;
|
||||
if (item.segment === 'berth-reservations') return hasReservations;
|
||||
return true;
|
||||
}),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
@@ -85,28 +125,36 @@ export function MoreSheet({
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>More</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<ul className="grid grid-cols-3 gap-2 px-3 pb-4">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.segment}>
|
||||
<DrawerClose asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${item.segment}` as any}
|
||||
// min-h-[88px] guarantees a 44pt vertical touch target
|
||||
// (Apple HIG); icon + label centered. The grid gap is
|
||||
// 8px so each cell still has clearance from neighbours.
|
||||
className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-xs text-foreground hover:bg-accent active:bg-accent/80"
|
||||
>
|
||||
<Icon className="size-7 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="space-y-4 px-3 pb-4">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<h3 className="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
<ul className="grid grid-cols-3 gap-2">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.segment}>
|
||||
<DrawerClose asChild>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/${item.segment}` as any}
|
||||
// min-h-[88px] guarantees a 44pt vertical touch
|
||||
// target (Apple HIG); icon + label centered.
|
||||
className="flex min-h-[88px] flex-col items-center justify-center gap-1.5 rounded-md py-3 px-2 text-center text-xs text-foreground hover:bg-accent active:bg-accent/80"
|
||||
>
|
||||
<Icon className="size-7 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium leading-tight">{item.label}</span>
|
||||
</Link>
|
||||
</DrawerClose>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Building2,
|
||||
Receipt,
|
||||
FileText,
|
||||
Bell,
|
||||
Inbox,
|
||||
Camera,
|
||||
Globe,
|
||||
Settings,
|
||||
@@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
||||
title: 'Communication',
|
||||
marinaRequired: true,
|
||||
items: [
|
||||
// Email tab removed: we deferred building a full inbox/threading
|
||||
// Email tab removed: deferred building a full inbox/threading
|
||||
// feature (would require Google OAuth + scope review + IMAP
|
||||
// syncing infra). Reminders stays since it's already wired up.
|
||||
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
|
||||
// syncing infra). This entry routes to the merged
|
||||
// Alerts + Reminders surface (2026-05-11) — explicit name so
|
||||
// reps don't mistake it for an email inbox.
|
||||
{ href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,8 +190,8 @@ function NavItemLink({
|
||||
href={item.href as any}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
|
||||
'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
|
||||
active && 'text-white pl-[14px]',
|
||||
'text-slate-700 hover:bg-accent hover:text-foreground',
|
||||
active && 'bg-accent text-foreground pl-[14px]',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
>
|
||||
@@ -202,7 +204,7 @@ function NavItemLink({
|
||||
<item.icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]',
|
||||
active ? 'text-[#3a7bc8]' : 'text-slate-500',
|
||||
collapsed ? 'w-5 h-5' : 'w-4 h-4',
|
||||
)}
|
||||
/>
|
||||
@@ -252,7 +254,7 @@ function SidebarContent({
|
||||
const [adminExpanded, setAdminExpanded] = useState(true);
|
||||
const sections = buildNavSections(portSlug);
|
||||
const umami = useUmamiActive('today');
|
||||
const umamiConfigured = umami.data?.error !== 'umami_not_configured';
|
||||
const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
|
||||
|
||||
// Small label under the user identity when the user has access to more
|
||||
// than one port — disambiguates which port is currently active without
|
||||
@@ -283,12 +285,13 @@ function SidebarContent({
|
||||
// compete with the logo for attention.
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className="flex flex-col h-full bg-[#1e2844]">
|
||||
{/* Brand header - logo centered (large when expanded, smaller when
|
||||
collapsed). Collapse toggle floats top-right as a tiny chevron. */}
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Brand header - logo centered. Soft hairline below echoes the
|
||||
inter-section separators in the nav so the eye reads the logo
|
||||
as a distinct top-row, not a floating element. */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center border-b border-[#474e66]',
|
||||
'relative flex items-center justify-center border-b border-slate-200',
|
||||
collapsed ? 'h-16 px-2' : 'h-24 px-4',
|
||||
)}
|
||||
>
|
||||
@@ -297,7 +300,7 @@ function SidebarContent({
|
||||
alt="Port Nimara"
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-md ring-2 ring-white/20"
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
@@ -307,7 +310,7 @@ function SidebarContent({
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={cn(
|
||||
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
|
||||
'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors',
|
||||
collapsed ? 'top-1' : 'top-2',
|
||||
)}
|
||||
>
|
||||
@@ -333,13 +336,13 @@ function SidebarContent({
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<div className="flex items-center justify-between px-1 mb-1">
|
||||
<span className="text-[#83aab1] text-[10px] font-semibold uppercase tracking-[0.12em]">
|
||||
<span className="text-slate-500 text-[10px] font-semibold uppercase tracking-[0.12em]">
|
||||
{section.title}
|
||||
</span>
|
||||
{section.adminRequired && (
|
||||
<button
|
||||
onClick={() => setAdminExpanded((v) => !v)}
|
||||
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
|
||||
className="text-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
{adminExpanded ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
@@ -363,7 +366,7 @@ function SidebarContent({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Separator className="mt-3 bg-[#474e66]/50" />
|
||||
<Separator className="mt-3 bg-slate-200" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -374,7 +377,7 @@ function SidebarContent({
|
||||
user can click their name/avatar to access Profile / Settings /
|
||||
port-switcher / sign-out. The same UserMenu component drives the
|
||||
top-right avatar dropdown, so the menu items stay consistent. */}
|
||||
<div className={cn('border-t border-[#474e66] p-2', collapsed && 'flex justify-center')}>
|
||||
<div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>
|
||||
{collapsed ? (
|
||||
<UserMenu
|
||||
align="start"
|
||||
@@ -384,7 +387,7 @@ function SidebarContent({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open user menu"
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
>
|
||||
<Avatar className="w-8 h-8 cursor-pointer">
|
||||
<AvatarImage src={undefined} />
|
||||
@@ -404,26 +407,26 @@ function SidebarContent({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open user menu"
|
||||
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-[#171f35] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1e2844]"
|
||||
className="flex w-full items-center gap-3 rounded-md p-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3a7bc8] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
>
|
||||
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-white/30">
|
||||
<Avatar className="w-8 h-8 shrink-0 shadow-sm ring-2 ring-slate-200">
|
||||
<AvatarImage src={undefined} />
|
||||
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
|
||||
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">
|
||||
<p className="text-foreground text-sm font-medium truncate">
|
||||
{user?.name ?? 'User'}
|
||||
</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
|
||||
className="text-[10px] px-1.5 py-0 text-slate-500 border-slate-300 mt-0.5"
|
||||
>
|
||||
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
|
||||
</Badge>
|
||||
{currentPortName && (
|
||||
<p className="mt-1 text-[10px] text-[#71768a] truncate">{currentPortName}</p>
|
||||
<p className="mt-1 text-[10px] text-slate-400 truncate">{currentPortName}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'relative hidden md:flex flex-col h-screen border-r border-[#474e66] transition-all duration-200 ease-in-out shrink-0',
|
||||
'relative hidden md:flex flex-col h-screen border-r border-slate-200 transition-all duration-200 ease-in-out shrink-0 bg-white',
|
||||
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
|
||||
)}
|
||||
style={{ backgroundColor: '#1e2844' }}
|
||||
>
|
||||
<SidebarContent
|
||||
collapsed={sidebarCollapsed}
|
||||
|
||||
@@ -95,23 +95,40 @@ export function Topbar({ ports, user }: TopbarProps) {
|
||||
<span className="hidden sm:inline">New</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Each item routes to the list page with ?create=1 so the
|
||||
relevant create sheet pops automatically (see
|
||||
useCreateFromUrl). The legacy `/clients/new`-style routes
|
||||
this menu used to push to landed on the dynamic detail
|
||||
page with id="new" and silently 404'd. */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/clients?create=1` as any)}>
|
||||
New Client
|
||||
</DropdownMenuItem>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/interests/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/yachts?create=1` as any)}>
|
||||
New Yacht
|
||||
</DropdownMenuItem>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/companies?create=1` as any)}>
|
||||
New Company
|
||||
</DropdownMenuItem>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/interests?create=1` as any)}>
|
||||
New Interest
|
||||
</DropdownMenuItem>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/expenses/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/expenses?create=1` as any)}>
|
||||
New Expense
|
||||
</DropdownMenuItem>
|
||||
{/* /reminders 301s to /inbox#reminders (the merged page) and
|
||||
the server redirect strips the query string, so point
|
||||
straight at the new path. The Reminders section's
|
||||
useCreateFromUrl handler still picks up ?create=1. */}
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}>
|
||||
<DropdownMenuItem onClick={() => router.push(`${base}/inbox?create=1#reminders` as any)}>
|
||||
New Reminder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface ReminderPrefs {
|
||||
interface UserPrefsResponse {
|
||||
reminders?: ReminderPrefs;
|
||||
timezone?: string;
|
||||
portReminderDigestEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
@@ -96,6 +97,10 @@ export function ReminderDigestForm() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.portReminderDigestEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||
import { ClientPicker } from '@/components/shared/client-picker';
|
||||
import { InterestPicker } from '@/components/shared/interest-picker';
|
||||
import { BerthPicker } from '@/components/shared/berth-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
|
||||
@@ -172,7 +175,9 @@ export function ReminderForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 2fr/1fr split — the datetime-local control needs more room
|
||||
for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
|
||||
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||
<Input
|
||||
@@ -202,13 +207,18 @@ export function ReminderForm({
|
||||
|
||||
{canAssignOthers && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-assign">Assign To</Label>
|
||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||
<Label htmlFor="reminder-assign">Assign to user</Label>
|
||||
<Select
|
||||
value={assignedTo === '' ? '__self__' : assignedTo}
|
||||
onValueChange={(v) => setAssignedTo(v === '__self__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger id="reminder-assign">
|
||||
<SelectValue placeholder="Myself" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Myself</SelectItem>
|
||||
{/* Radix Select forbids empty-string values, so use a
|
||||
sentinel here and map back to '' in the handler. */}
|
||||
<SelectItem value="__self__">Myself</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.displayName}
|
||||
@@ -220,27 +230,36 @@ export function ReminderForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Link to Entity (optional - paste UUIDs, or leave blank)
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Link to entity (optional)
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a client first to scope the interest and berth dropdowns to that
|
||||
client's deals.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Input
|
||||
placeholder="Client ID"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
className="text-xs"
|
||||
<ClientPicker
|
||||
value={clientId || null}
|
||||
onChange={(id) => {
|
||||
setClientId(id ?? '');
|
||||
// Clearing the client also clears scoped selections so a
|
||||
// stale interest/berth from a different client doesn't
|
||||
// silently submit alongside the new client.
|
||||
if (!id) {
|
||||
setInterestId('');
|
||||
setBerthId('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Interest ID"
|
||||
value={interestId}
|
||||
onChange={(e) => setInterestId(e.target.value)}
|
||||
className="text-xs"
|
||||
<InterestPicker
|
||||
value={interestId || null}
|
||||
onChange={(id) => setInterestId(id ?? '')}
|
||||
clientId={clientId || null}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Berth ID"
|
||||
value={berthId}
|
||||
onChange={(e) => setBerthId(e.target.value)}
|
||||
className="text-xs"
|
||||
<BerthPicker
|
||||
value={berthId || null}
|
||||
onChange={(id) => setBerthId(id ?? '')}
|
||||
clientId={clientId || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
|
||||
import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
@@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,6 +25,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { ReminderCard } from './reminder-card';
|
||||
import { ReminderForm } from './reminder-form';
|
||||
@@ -59,10 +66,20 @@ const STATUS_CONFIG = {
|
||||
dismissed: { label: 'Dismissed', icon: XCircle },
|
||||
} as const;
|
||||
|
||||
export function ReminderList() {
|
||||
interface ReminderListProps {
|
||||
/**
|
||||
* Embedded mode (used by the Inbox page) drops the PageHeader and
|
||||
* surfaces the "New Reminder" button inline so the section can render
|
||||
* alongside the Alerts section without duplicating page chrome.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function ReminderList({ embedded = false }: ReminderListProps = {}) {
|
||||
const [reminders, setReminders] = useState<Reminder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
useCreateFromUrl(() => setFormOpen(true));
|
||||
const [editingReminder, setEditingReminder] = useState<Reminder | null>(null);
|
||||
const [snoozingId, setSnoozingId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
|
||||
@@ -203,41 +220,97 @@ export function ReminderList() {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
onClick={() => handleComplete(row.original.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSnoozingId(row.original.id)}>
|
||||
<Clock className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleDismiss(row.original.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Mark complete"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
onClick={() => handleComplete(row.original.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mark complete</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Snooze"
|
||||
onClick={() => setSnoozingId(row.original.id)}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Snooze</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Edit reminder"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setEditingReminder(row.original);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Dismiss"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleDismiss(row.original.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dismiss</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 120,
|
||||
size: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
description={`${total} reminder${total !== 1 ? 's' : ''}`}
|
||||
actions={
|
||||
{!embedded ? (
|
||||
<PageHeader
|
||||
title="Reminders"
|
||||
description={`${total} reminder${total !== 1 ? 's' : ''}`}
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingReminder(null);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Reminder
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="mb-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingReminder(null);
|
||||
setFormOpen(true);
|
||||
@@ -246,8 +319,8 @@ export function ReminderList() {
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Reminder
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wrap on phone widths so the priority filter doesn't get pushed
|
||||
off-screen by the My/All tabs + status filter taking the full row. */}
|
||||
|
||||
@@ -17,11 +17,49 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { RequestReportInput } from '@/lib/validators/reports';
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
pipeline: 'Pipeline Summary',
|
||||
revenue: 'Revenue Report',
|
||||
activity: 'Activity Log',
|
||||
occupancy: 'Berth Occupancy',
|
||||
interface ReportTypeMeta {
|
||||
label: string;
|
||||
subtitle: string;
|
||||
contents: string[];
|
||||
}
|
||||
|
||||
const REPORT_TYPES: Record<string, ReportTypeMeta> = {
|
||||
pipeline: {
|
||||
label: 'Pipeline Summary',
|
||||
subtitle: 'Interest counts by stage and conversion rates',
|
||||
contents: [
|
||||
'Active (non-archived) interests grouped by pipeline stage',
|
||||
'Stage-to-stage drop-off counts',
|
||||
'Open vs. won vs. lost roll-up at the bottom',
|
||||
],
|
||||
},
|
||||
revenue: {
|
||||
label: 'Revenue Report',
|
||||
subtitle: 'Berth-price totals rolled up by pipeline stage',
|
||||
contents: [
|
||||
'Sum of primary-berth prices grouped by stage',
|
||||
'Pulled from each interest’s primary berth link (non-primary junctions ignored)',
|
||||
'Sold-stage total reflects realised revenue; earlier stages are forecast',
|
||||
],
|
||||
},
|
||||
activity: {
|
||||
label: 'Activity Log',
|
||||
subtitle: 'Audit events across the port for a date range',
|
||||
contents: [
|
||||
'Audit log entries (create / update / delete) per entity',
|
||||
'Filtered to the selected date range — defaults to last 30 days',
|
||||
'Includes actor name, entity type, and action verb',
|
||||
],
|
||||
},
|
||||
occupancy: {
|
||||
label: 'Berth Occupancy',
|
||||
subtitle: 'Berth counts by status',
|
||||
contents: [
|
||||
'Berths grouped by status: Available, Under Offer, Sold',
|
||||
'Per-dock breakdown using the mooring-letter prefix',
|
||||
'Total port utilisation percentage at the top',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function GenerateReportForm() {
|
||||
@@ -74,13 +112,26 @@ export function GenerateReportForm() {
|
||||
<SelectValue placeholder="Select a report type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
{Object.entries(REPORT_TYPES).map(([value, meta]) => (
|
||||
<SelectItem key={value} value={value} className="py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{meta.subtitle}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{reportType && REPORT_TYPES[reportType] ? (
|
||||
<div className="mt-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{REPORT_TYPES[reportType].subtitle}</p>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-4">
|
||||
{REPORT_TYPES[reportType].contents.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@@ -94,7 +145,7 @@ export function GenerateReportForm() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dateFrom">Date From (optional)</Label>
|
||||
<Input
|
||||
@@ -102,6 +153,7 @@ export function GenerateReportForm() {
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -111,6 +163,7 @@ export function GenerateReportForm() {
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
interface ResidentialInterestSummary {
|
||||
@@ -62,13 +63,7 @@ const CONTACT_OPTIONS = [
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
];
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -117,10 +112,10 @@ export function getResidentialClientTabs({
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
aggregate
|
||||
entityType="residential_clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
aggregate
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { SOURCES } from '@/lib/constants';
|
||||
|
||||
interface ResidentialInterest {
|
||||
id: string;
|
||||
@@ -25,13 +26,7 @@ interface Args {
|
||||
stageOptions: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Anchor,
|
||||
Bell,
|
||||
@@ -100,6 +101,7 @@ export function CommandSearch() {
|
||||
const [focusIndex, setFocusIndex] = useState<number>(-1);
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -113,8 +115,32 @@ export function CommandSearch() {
|
||||
limit: activeBucket === 'all' ? 5 : 15,
|
||||
});
|
||||
|
||||
// Persist the totals from the last "all" query so the filter chips stay
|
||||
// populated when the user narrows to a single bucket. Without this, the
|
||||
// narrowed query only returns counts for the active bucket and every
|
||||
// other chip would vanish — making it impossible to swap between
|
||||
// filters without clearing back to "All" first.
|
||||
const lastAllTotalsRef = useRef<SearchResults['totals'] | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeBucket === 'all' && results?.totals) {
|
||||
lastAllTotalsRef.current = results.totals;
|
||||
}
|
||||
}, [activeBucket, results]);
|
||||
const chipTotals: SearchResults['totals'] | undefined =
|
||||
activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
|
||||
|
||||
const showDropdown = focused;
|
||||
|
||||
// CommandSearch lives in the header and persists across navigations,
|
||||
// so its React Query cache never sees a remount. Invalidate the
|
||||
// recently-viewed + recent-terms queries whenever the dropdown opens
|
||||
// so the user sees fresh data after navigating around the app.
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
|
||||
}, [showDropdown, queryClient]);
|
||||
|
||||
// Cmd/Ctrl+K focuses the input from anywhere on the page.
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: globalThis.KeyboardEvent) {
|
||||
@@ -287,7 +313,7 @@ export function CommandSearch() {
|
||||
>
|
||||
{/* Filter chip row — always visible while the dropdown is open. */}
|
||||
<FilterChipRow
|
||||
results={results}
|
||||
totals={chipTotals}
|
||||
active={activeBucket}
|
||||
onChange={setActiveBucket}
|
||||
disabled={query.length < 2}
|
||||
@@ -337,18 +363,19 @@ export function CommandSearch() {
|
||||
// ─── Filter chips ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChipRow({
|
||||
results,
|
||||
totals,
|
||||
active,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
results: SearchResults | undefined;
|
||||
/** Counts from the last "all" query, persisted so chips stay visible
|
||||
* when the user narrows to a single bucket. Falls back to the current
|
||||
* results.totals when no "all" snapshot exists yet. */
|
||||
totals: SearchResults['totals'] | undefined;
|
||||
active: BucketType | 'all';
|
||||
onChange: (b: BucketType | 'all') => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// Show a chip for every bucket so the user can browse the search
|
||||
// surface even with no query; counts only render when results exist.
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
@@ -364,10 +391,10 @@ function FilterChipRow({
|
||||
All
|
||||
</ChipButton>
|
||||
{BUCKETS.map((b) => {
|
||||
const count = results?.totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets the current user can't see (count === 0
|
||||
// when the bucket query was permission-skipped) — but only after
|
||||
// a query has run, otherwise we'd hide every chip on first paint.
|
||||
const count = totals?.[b.type] ?? 0;
|
||||
// Hide chips for buckets with zero matches in the last "all"
|
||||
// snapshot — keeps the row tight and avoids dead-end clicks.
|
||||
// Always show the active chip + every chip before a query has run.
|
||||
if (!disabled && count === 0 && active !== b.type) return null;
|
||||
return (
|
||||
<ChipButton
|
||||
@@ -701,6 +728,11 @@ function ResultRow({
|
||||
<HighlightMatch text={row.sub} query={query} />
|
||||
</div>
|
||||
)}
|
||||
{row.relatedVia && (
|
||||
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
|
||||
via {row.relatedVia.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -759,9 +791,9 @@ function BucketSection({
|
||||
|
||||
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
|
||||
|
||||
type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
|
||||
|
||||
type FlatRow =
|
||||
export type FlatRow =
|
||||
| {
|
||||
kind: 'recent-view';
|
||||
key: string;
|
||||
@@ -783,6 +815,9 @@ type FlatRow =
|
||||
sub: string | null;
|
||||
href: string;
|
||||
badges?: ResultBadge[];
|
||||
/** Provenance hint when the row was surfaced via graph expansion.
|
||||
* Rendered as a subtle "via Berth A10" line below the sub. */
|
||||
relatedVia?: { type: string; label: string } | null;
|
||||
}
|
||||
| {
|
||||
kind: 'other-port';
|
||||
@@ -791,7 +826,7 @@ type FlatRow =
|
||||
href: string;
|
||||
};
|
||||
|
||||
interface BuildFlatRowsArgs {
|
||||
export interface BuildFlatRowsArgs {
|
||||
query: string;
|
||||
results: SearchResults | undefined;
|
||||
recentlyViewed: RecentlyViewedItem[];
|
||||
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
|
||||
portSlug: string | null;
|
||||
}
|
||||
|
||||
function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
|
||||
const rows: FlatRow[] = [];
|
||||
|
||||
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: c.matchedContact ?? null,
|
||||
href: `/${portSlug}/clients/${c.id}`,
|
||||
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
|
||||
relatedVia: c.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: y.name,
|
||||
sub,
|
||||
href: `/${portSlug}/yachts/${y.id}`,
|
||||
relatedVia: y.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
label: co.name,
|
||||
sub,
|
||||
href: `/${portSlug}/companies/${co.id}`,
|
||||
relatedVia: co.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub: i.berthMooringNumber,
|
||||
href: `/${portSlug}/interests/${i.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: i.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
sub,
|
||||
href: `/${portSlug}/berths/${b.id}`,
|
||||
badges: badges.length > 0 ? badges : undefined,
|
||||
relatedVia: b.relatedVia ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
607
src/components/search/mobile-search-overlay.tsx
Normal file
607
src/components/search/mobile-search-overlay.tsx
Normal file
@@ -0,0 +1,607 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Drawer as VaulDrawer } from 'vaul';
|
||||
import { Clock, History, Search, X } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSearch, type BucketType, type SearchResults } from '@/hooks/use-search';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { buildFlatRows, type FlatRow } from './command-search';
|
||||
import { HighlightMatch } from './highlight-match';
|
||||
|
||||
// Match the desktop bucket order — feels consistent when reps switch contexts.
|
||||
const BUCKETS: { type: BucketType; label: string }[] = [
|
||||
{ type: 'clients', label: 'Clients' },
|
||||
{ type: 'yachts', label: 'Yachts' },
|
||||
{ type: 'companies', label: 'Companies' },
|
||||
{ type: 'interests', label: 'Interests' },
|
||||
{ type: 'berths', label: 'Berths' },
|
||||
{ type: 'documents', label: 'Documents' },
|
||||
{ type: 'invoices', label: 'Invoices' },
|
||||
{ type: 'reminders', label: 'Reminders' },
|
||||
];
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const INVOICE_RE = /^INV-\d{6}-\d+$/i;
|
||||
function looksLikePastedId(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed);
|
||||
}
|
||||
|
||||
const BADGE_TONE: Record<'neutral' | 'warning' | 'success' | 'danger', string> = {
|
||||
neutral: 'bg-muted text-muted-foreground',
|
||||
warning: 'bg-amber-100 text-amber-900',
|
||||
success: 'bg-emerald-100 text-emerald-900',
|
||||
danger: 'bg-red-100 text-red-900',
|
||||
};
|
||||
|
||||
interface MobileSearchOverlayProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeBucket, setActiveBucket] = useState<BucketType | 'all'>('all');
|
||||
// Tracks the visible-above-keyboard height. iOS Safari ignores
|
||||
// keyboard area in `dvh`, so we use the visualViewport API directly:
|
||||
// visualViewport.height is the actual visible area in CSS pixels,
|
||||
// updates in real time as the keyboard rises/falls.
|
||||
const [visibleHeight, setVisibleHeight] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The overlay is mounted once at the layout root, so the recently-
|
||||
// viewed query won't refetch via the usual mount path. Bump it every
|
||||
// time the drawer opens — the user is about to look at it, and the
|
||||
// staleTime cache may have missed an entity view that happened in a
|
||||
// route that doesn't render <TrackEntityView>.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
|
||||
}, [open, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setVisibleHeight(null);
|
||||
return;
|
||||
}
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => setVisibleHeight(vv.height);
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, {
|
||||
type: activeBucket === 'all' ? undefined : activeBucket,
|
||||
limit: activeBucket === 'all' ? 5 : 25,
|
||||
});
|
||||
|
||||
// Persist counts from the last "all" query so chip counts stay visible
|
||||
// when the user narrows to a single bucket. Narrowed queries only
|
||||
// return counts for the active bucket, which would otherwise wipe the
|
||||
// counts off every other chip the moment the user taps one.
|
||||
const lastAllTotalsRef = useRef<SearchResults['totals'] | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeBucket === 'all' && results?.totals) {
|
||||
lastAllTotalsRef.current = results.totals;
|
||||
}
|
||||
}, [activeBucket, results]);
|
||||
const chipTotals: SearchResults['totals'] | undefined =
|
||||
activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
|
||||
|
||||
// Auto-focus is delegated to Vaul's `autoFocus` + the input's
|
||||
// `autoFocus` attribute (synchronous in-gesture, which iOS Safari
|
||||
// requires before it'll pop the keyboard on programmatic focus).
|
||||
// A useEffect setTimeout was the previous approach but broke the
|
||||
// user-gesture chain — input was focused, keyboard stayed hidden.
|
||||
|
||||
// Body scroll lock is delegated to Vaul (modal=true + noBodyStyles=false
|
||||
// defaults). Manual position:fixed locking caused a visible scroll-then-
|
||||
// jump on iOS Safari because the body briefly snaps to scrollY=0 after
|
||||
// being taken out of flow, before the negative-top compensation paints.
|
||||
// Vaul handles the lock natively via overflow:hidden which doesn't
|
||||
// remove the body from flow. The trick to avoid Vaul's iOS scroll-lock
|
||||
// race is `repositionInputs={false}` on the Drawer.Root (set below).
|
||||
|
||||
// Reset query when the drawer closes. Without this, reopening the
|
||||
// overlay would flash stale results before the empty state renders.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
setActiveBucket('all');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
inputRef.current?.blur();
|
||||
}, [onOpenChange]);
|
||||
|
||||
const navigate = useCallback(
|
||||
(path: string) => {
|
||||
close();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
router.push(path as any);
|
||||
},
|
||||
[close, router],
|
||||
);
|
||||
|
||||
// Paste a UUID or invoice number → jump straight to the entity.
|
||||
const onPaste = useCallback(
|
||||
async (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pasted = e.clipboardData.getData('text').trim();
|
||||
if (!looksLikePastedId(pasted)) return;
|
||||
try {
|
||||
const res = await apiFetch<{ found: boolean; href: string | null }>(
|
||||
`/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`,
|
||||
);
|
||||
if (res.found && res.href) {
|
||||
e.preventDefault();
|
||||
navigate(res.href);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — fall through to text search.
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const rows = useMemo<FlatRow[]>(
|
||||
() =>
|
||||
buildFlatRows({
|
||||
query,
|
||||
results,
|
||||
recentlyViewed,
|
||||
recentSearches,
|
||||
activeBucket,
|
||||
portSlug,
|
||||
}),
|
||||
[query, results, recentlyViewed, recentSearches, activeBucket, portSlug],
|
||||
);
|
||||
|
||||
const showingEmptyHints = query.length < 2;
|
||||
const noResults = !showingEmptyHints && rows.length === 0 && !isFetching;
|
||||
|
||||
return (
|
||||
<VaulDrawer.Root
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
// iOS Safari fluidity recipe sourced from:
|
||||
// - github.com/shadcn-ui/ui/issues/4321 (page reflow on open)
|
||||
// - gracefullight.dev fix-ios-safari-scroll-issue-with-vaul-drawer
|
||||
//
|
||||
// - shouldScaleBackground=false: page doesn't shrink behind us;
|
||||
// feels in-app rather than card-over-page.
|
||||
// - repositionInputs=false: don't let Vaul jiggle the viewport
|
||||
// when the input autofocuses and the keyboard appears — that
|
||||
// was the source of the "scroll then jump back" we were seeing.
|
||||
// Vaul still locks scroll via its modal=true default.
|
||||
// - autoFocus=true: Vaul focuses the input synchronously inside
|
||||
// the user-gesture frame, which is the only way iOS Safari
|
||||
// will pop the keyboard on programmatic focus. The input has
|
||||
// `autoFocus` set below so Vaul picks it as the target.
|
||||
shouldScaleBackground={false}
|
||||
repositionInputs={false}
|
||||
>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
|
||||
<VaulDrawer.Content
|
||||
// Anchor by top + explicit height (not bottom: 0). iOS treats
|
||||
// `bottom: 0` on position:fixed inconsistently when the
|
||||
// keyboard is up (sometimes layout viewport, sometimes visual);
|
||||
// anchoring by top + height removes that ambiguity. Height
|
||||
// comes from visualViewport.height — the only iOS-reliable
|
||||
// source for "visible area above keyboard". 12px gap at the
|
||||
// top keeps a strip of backdrop visible.
|
||||
style={
|
||||
visibleHeight != null
|
||||
? { top: '12px', bottom: 'auto', height: `${Math.max(0, visibleHeight - 12)}px` }
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'fixed inset-x-0 z-50 flex flex-col rounded-t-2xl',
|
||||
// Fallback when visibleHeight hasn't measured yet (first
|
||||
// frame, SSR): top+bottom CSS-only sizing.
|
||||
visibleHeight == null && 'top-3 bottom-0',
|
||||
'border-t bg-background shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.25)]',
|
||||
// Respect the bottom safe-area so the home indicator never
|
||||
// overlaps the scroll region.
|
||||
'pb-safe-bottom',
|
||||
)}
|
||||
>
|
||||
{/* Visually-hidden title for screen readers. Radix Dialog (which
|
||||
Vaul wraps) requires a DialogTitle in the accessibility tree;
|
||||
without this, the console throws an a11y violation. */}
|
||||
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
|
||||
|
||||
{/* Drag handle — Vaul reads this as a swipe target. Centered grip
|
||||
+ a small label below feels iOS-native. */}
|
||||
<div className="flex flex-col items-center pt-2.5 pb-1.5">
|
||||
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
|
||||
</div>
|
||||
|
||||
{/* Sticky header: input + Cancel. The Cancel slides in from the
|
||||
right when the input has focus, otherwise it sits flat. */}
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<label className="relative flex h-11 flex-1 items-center rounded-xl bg-muted/70 px-3 transition-colors focus-within:bg-muted">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPaste={onPaste}
|
||||
placeholder="Search clients, yachts, interests…"
|
||||
aria-label="Search"
|
||||
inputMode="search"
|
||||
enterKeyHint="search"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
'ml-2 h-full w-full min-w-0 bg-transparent text-base outline-none',
|
||||
'placeholder:text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
{query.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground active:bg-foreground/10"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="text-sm font-medium text-primary active:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bucket chips: horizontally scrollable so all buckets fit no
|
||||
matter the phone width. "All" is sticky-left so it's always
|
||||
one tap away when the user is deep in a bucket. */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="flex gap-1.5 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<BucketChip
|
||||
label="All"
|
||||
active={activeBucket === 'all'}
|
||||
onClick={() => setActiveBucket('all')}
|
||||
/>
|
||||
{BUCKETS.map((b) => {
|
||||
const count = chipTotals?.[b.type] ?? 0;
|
||||
// Hide chips with zero matches in the last "all" snapshot,
|
||||
// unless this is the currently active chip. Always show all
|
||||
// before a query has run (chipTotals undefined → count 0
|
||||
// and active 'all' means none get hidden).
|
||||
if (query.length >= 2 && count === 0 && activeBucket !== b.type) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BucketChip
|
||||
key={b.type}
|
||||
label={b.label}
|
||||
count={count > 0 ? count : undefined}
|
||||
active={activeBucket === b.type}
|
||||
onClick={() => setActiveBucket(b.type)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results scroll region. overscroll-contain prevents the body
|
||||
from rubber-banding when the user scrolls past the bottom. */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-2 pb-4 pt-1">
|
||||
{showingEmptyHints && rows.length === 0 ? (
|
||||
<EmptyHint />
|
||||
) : showingEmptyHints ? (
|
||||
<RowList rows={rows} query={query} onSelect={navigate} variant="empty" />
|
||||
) : noResults ? (
|
||||
<NoResults query={query} />
|
||||
) : (
|
||||
<RowList rows={rows} query={query} onSelect={navigate} variant="results" />
|
||||
)}
|
||||
</div>
|
||||
</VaulDrawer.Content>
|
||||
</VaulDrawer.Portal>
|
||||
</VaulDrawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketChip({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-muted-foreground active:bg-accent active:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{typeof count === 'number' && <span className="ml-1 opacity-70">({count})</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center gap-3 px-6 pb-12 pt-28 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Search className="size-7" aria-hidden />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search clients, yachts, interests, berths, invoices, documents — paste a UUID or
|
||||
invoice number to jump directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoResults({ query }: { query: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 pb-12 text-center">
|
||||
<p className="text-sm font-medium text-foreground">No matches for “{query}”</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Try a different spelling, or switch buckets above.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowList({
|
||||
rows,
|
||||
query,
|
||||
onSelect,
|
||||
variant,
|
||||
}: {
|
||||
rows: FlatRow[];
|
||||
query: string;
|
||||
onSelect: (href: string) => void;
|
||||
variant: 'empty' | 'results';
|
||||
}) {
|
||||
// Split rows by section header — "Recently viewed", "Recent searches",
|
||||
// "Results". Headers live inside the row list so they scroll with their
|
||||
// content (instead of sticky-positioning, which adds visual noise).
|
||||
const recentViews = rows.filter((r) => r.kind === 'recent-view');
|
||||
const recentTerms = rows.filter((r) => r.kind === 'recent-term');
|
||||
const results = rows.filter((r) => r.kind === 'result' || r.kind === 'other-port');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{variant === 'empty' && recentViews.length > 0 ? (
|
||||
<Section icon={<Clock className="size-3.5" />} label="Recently viewed">
|
||||
{recentViews.map((row) =>
|
||||
row.kind === 'recent-view' ? (
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => onSelect(row.href)}
|
||||
label={row.item.label}
|
||||
sub={row.item.sub}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{variant === 'empty' && recentTerms.length > 0 ? (
|
||||
<Section icon={<History className="size-3.5" />} label="Recent searches">
|
||||
<div className="flex flex-wrap gap-1.5 px-2 py-1">
|
||||
{recentTerms.map((row) =>
|
||||
row.kind === 'recent-term' ? (
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Recent-term taps populate the input rather than
|
||||
// navigating — the rep usually wants to refine, not
|
||||
// jump straight back to the previous result.
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Search"]',
|
||||
);
|
||||
if (input) {
|
||||
input.value = row.term;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.focus();
|
||||
}
|
||||
}}
|
||||
className="rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground active:bg-accent active:text-accent-foreground"
|
||||
>
|
||||
{row.term}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the flat result rows, inserting a small section header above the
|
||||
* first row of each bucket so reps know exactly what kind of entity
|
||||
* each result points to ("CLIENTS", "INTERESTS", "BERTHS", …). Bucket
|
||||
* order follows `buildFlatRows`'s ordering — most-likely matches first.
|
||||
*/
|
||||
function renderResultRows(
|
||||
rows: FlatRow[],
|
||||
query: string,
|
||||
onSelect: (path: string) => void,
|
||||
): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastBucket: BucketType | null = null;
|
||||
rows.forEach((row, i) => {
|
||||
if (row.kind === 'result' && row.bucket !== lastBucket) {
|
||||
nodes.push(
|
||||
<div
|
||||
key={`__bucket_${row.bucket}_${i}`}
|
||||
className="px-3 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{BUCKET_LABELS[row.bucket] ?? row.bucket}
|
||||
</div>,
|
||||
);
|
||||
lastBucket = row.bucket;
|
||||
} else if (row.kind === 'other-port' && lastBucket !== null) {
|
||||
// Reset bucket tracker so re-grouping works on subsequent results.
|
||||
lastBucket = null;
|
||||
}
|
||||
|
||||
if (row.kind === 'result') {
|
||||
const Icon = row.icon;
|
||||
const subContent = (
|
||||
<>
|
||||
{row.sub ? <HighlightMatch text={row.sub} query={query} /> : null}
|
||||
{row.relatedVia ? (
|
||||
<span className="block text-[11px] italic text-muted-foreground/80">
|
||||
via {row.relatedVia.label}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
nodes.push(
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => onSelect(row.href)}
|
||||
label={<HighlightMatch text={row.label} query={query} />}
|
||||
sub={row.sub || row.relatedVia ? subContent : null}
|
||||
icon={<Icon className="size-4 text-muted-foreground" aria-hidden />}
|
||||
badges={row.badges}
|
||||
/>,
|
||||
);
|
||||
} else if (row.kind === 'other-port') {
|
||||
nodes.push(
|
||||
<Row
|
||||
key={row.key}
|
||||
onSelect={() => onSelect(row.href)}
|
||||
label={row.item.label}
|
||||
sub={`${row.item.portName} · other port`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Human-readable bucket labels for the section-header rows. */
|
||||
const BUCKET_LABELS: Record<BucketType, string> = {
|
||||
clients: 'Clients',
|
||||
residentialClients: 'Residential clients',
|
||||
yachts: 'Yachts',
|
||||
companies: 'Companies',
|
||||
interests: 'Interests',
|
||||
residentialInterests: 'Residential interests',
|
||||
berths: 'Berths',
|
||||
invoices: 'Invoices',
|
||||
expenses: 'Expenses',
|
||||
documents: 'Documents',
|
||||
files: 'Files',
|
||||
reminders: 'Reminders',
|
||||
brochures: 'Brochures',
|
||||
tags: 'Tags',
|
||||
navigation: 'Settings & navigation',
|
||||
notes: 'Notes',
|
||||
};
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
onSelect,
|
||||
label,
|
||||
sub,
|
||||
icon,
|
||||
badges,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
label: React.ReactNode;
|
||||
sub?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
badges?: { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }[];
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
|
||||
'min-h-[52px] active:bg-accent',
|
||||
)}
|
||||
>
|
||||
{icon ? <span className="shrink-0">{icon}</span> : null}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">{label}</span>
|
||||
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
|
||||
</span>
|
||||
{badges?.length ? (
|
||||
<span className="flex shrink-0 gap-1">
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b.label}
|
||||
className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
BADGE_TONE[b.tone],
|
||||
)}
|
||||
>
|
||||
{b.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
77
src/components/settings/dashboard-widgets-card.tsx
Normal file
77
src/components/settings/dashboard-widgets-card.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -186,7 +187,7 @@ export function UserSettings() {
|
||||
<div>
|
||||
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
|
||||
|
||||
<div className="mt-6 space-y-6 max-w-2xl">
|
||||
<div className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
@@ -318,6 +319,8 @@ export function UserSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DashboardWidgetsCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
|
||||
187
src/components/shared/berth-picker.tsx
Normal file
187
src/components/shared/berth-picker.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BerthOption {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface BerthPickerProps {
|
||||
value: string | null;
|
||||
onChange: (berthId: string | null) => void;
|
||||
/** When set, the dropdown is scoped to berths linked through any of
|
||||
* this client's interests (via interest_berths.primary). Other berths
|
||||
* are hidden so the picker mirrors the relationship the rep is
|
||||
* already building. */
|
||||
clientId?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable berth picker. Free-text search when no client is selected;
|
||||
* scoped to a client's primary-berth set when `clientId` is provided.
|
||||
*
|
||||
* The scoped query fetches the client's interests (limit 25) and
|
||||
* intersects on `berthId`, which mirrors the relationship semantics the
|
||||
* rest of the CRM uses ("berths that show up on this client's deals").
|
||||
*/
|
||||
export function BerthPicker({
|
||||
value,
|
||||
onChange,
|
||||
clientId,
|
||||
placeholder = 'Select berth...',
|
||||
disabled,
|
||||
}: BerthPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const debounced = useDebounce(search, 300);
|
||||
|
||||
// Free-text search path — used when there's no clientId scope.
|
||||
const { data: searchData } = useQuery<{ data: BerthOption[] }>({
|
||||
queryKey: ['berth-picker', 'search', debounced],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: '1', limit: '10', order: 'asc' });
|
||||
// The list endpoint doesn't accept `search`, so we filter
|
||||
// client-side; pulling a larger page lets the typeahead feel
|
||||
// responsive without round-tripping per keystroke.
|
||||
params.set('limit', '50');
|
||||
return apiFetch(`/api/v1/berths?${params.toString()}`);
|
||||
},
|
||||
enabled: open && !clientId,
|
||||
});
|
||||
|
||||
// Scoped path — pull this client's interests (with their primary
|
||||
// berth) and dedupe the berth set.
|
||||
const { data: clientInterests } = useQuery<{
|
||||
data: Array<{ berthId: string | null; berthMooringNumber: string | null }>;
|
||||
}>({
|
||||
queryKey: ['berth-picker', 'client', clientId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '25',
|
||||
order: 'desc',
|
||||
includeArchived: 'false',
|
||||
clientId: clientId!,
|
||||
});
|
||||
return apiFetch(`/api/v1/interests?${params.toString()}`);
|
||||
},
|
||||
enabled: open && !!clientId,
|
||||
});
|
||||
|
||||
const options: BerthOption[] = useMemo(() => {
|
||||
if (clientId) {
|
||||
const rows = clientInterests?.data ?? [];
|
||||
const seen = new Set<string>();
|
||||
const out: BerthOption[] = [];
|
||||
for (const r of rows) {
|
||||
if (!r.berthId || seen.has(r.berthId)) continue;
|
||||
seen.add(r.berthId);
|
||||
out.push({
|
||||
id: r.berthId,
|
||||
mooringNumber: r.berthMooringNumber ?? '',
|
||||
area: null,
|
||||
status: '',
|
||||
});
|
||||
}
|
||||
if (!debounced) return out;
|
||||
const q = debounced.toLowerCase();
|
||||
return out.filter((b) => b.mooringNumber.toLowerCase().includes(q));
|
||||
}
|
||||
const rows = searchData?.data ?? [];
|
||||
if (!debounced) return rows;
|
||||
const q = debounced.toLowerCase();
|
||||
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
|
||||
}, [clientId, clientInterests, searchData, debounced]);
|
||||
|
||||
const labelFor = (o: BerthOption) =>
|
||||
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
const match = options.find((o) => o.id === value);
|
||||
return match ? labelFor(match) : `Berth ${value.slice(0, 8)}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
// `modal` is required when this picker is rendered inside a Sheet /
|
||||
// Dialog — without it the CommandInput stays focus-blocked by the
|
||||
// outer Sheet's focus trap and clicks/typing are silently dropped.
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={clientId ? "Search this client's berths…" : 'Search berths…'}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{clientId ? 'No berths linked to this client.' : 'No berths found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{value ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={o.id}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{labelFor(o)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,10 @@ export function ClientPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
// `modal` is required when this picker is rendered inside a Sheet /
|
||||
// Dialog — without it the CommandInput stays focus-blocked by the
|
||||
// outer Sheet's focus trap and clicks/typing are silently dropped.
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -76,6 +79,18 @@ export function ClientPicker({
|
||||
<CommandList>
|
||||
<CommandEmpty>No clients found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{value ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((c) => (
|
||||
<CommandItem
|
||||
key={c.id}
|
||||
|
||||
@@ -87,7 +87,11 @@ export function CountryCombobox({
|
||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
// modal: required when this combobox is nested inside a Sheet
|
||||
// (Radix Dialog). Without it, the parent Dialog's pointer-events
|
||||
// handling swallows the trigger's tap on iOS Safari — same fix
|
||||
// pattern as TimezoneCombobox.
|
||||
<Popover modal open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -19,19 +19,76 @@ interface CurrencyInputProps extends Omit<
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const groupFormatter = new Intl.NumberFormat('en-US', {
|
||||
maximumFractionDigits: 2,
|
||||
useGrouping: true,
|
||||
});
|
||||
|
||||
function formatGrouped(value: number | string): string {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return groupFormatter.format(n);
|
||||
}
|
||||
|
||||
function parseTyped(raw: string): { display: string; numeric: number | null } {
|
||||
// Strip everything except digits, '.', '-'. Commas are formatting noise from
|
||||
// our own display and are removed before re-grouping. (Locale note: this
|
||||
// assumes '.' as decimal separator, matching the en-US formatter below.)
|
||||
let cleaned = raw.replace(/[^\d.-]/g, '');
|
||||
// Keep only the first '.' (additional dots are dropped).
|
||||
const firstDot = cleaned.indexOf('.');
|
||||
if (firstDot !== -1) {
|
||||
cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '');
|
||||
}
|
||||
// Sign: only honour a leading '-'; strip any others.
|
||||
const negative = cleaned.startsWith('-');
|
||||
cleaned = (negative ? '-' : '') + cleaned.replace(/-/g, '');
|
||||
|
||||
if (cleaned === '' || cleaned === '-') return { display: cleaned, numeric: null };
|
||||
|
||||
const dot = cleaned.indexOf('.');
|
||||
const intPart = dot === -1 ? cleaned : cleaned.slice(0, dot);
|
||||
const fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
|
||||
const intDigitsOnly = intPart.replace('-', '');
|
||||
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly);
|
||||
const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
|
||||
|
||||
const intDisplay =
|
||||
intDigitsOnly === ''
|
||||
? (negative ? '-' : '')
|
||||
: (negative ? '-' : '') + groupFormatter.format(intNumeric);
|
||||
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`;
|
||||
|
||||
return { display, numeric: Number.isFinite(numeric) ? numeric : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric input pre-decorated with a currency symbol. The display
|
||||
* value is the raw number the user typed (we don't fight the keystroke
|
||||
* cadence by re-formatting on every key) — formatted display lives in
|
||||
* read-only contexts via `formatCurrency()`. This keeps form behaviour
|
||||
* predictable while still scoping the input to a money field via the
|
||||
* symbol prefix and the `decimal` inputMode.
|
||||
* Numeric input pre-decorated with a currency symbol and thousand-separator
|
||||
* grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"`
|
||||
* so we can render commas (HTML `type="number"` strips them) while still
|
||||
* surfacing the decimal keypad on iOS/Android. The parent receives a raw
|
||||
* number via `onChange`; the formatted string is local UI state.
|
||||
*/
|
||||
export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
||||
({ value, onChange, currency = 'USD', className, ...props }, ref) => {
|
||||
({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => {
|
||||
const symbol = currencySymbol(currency);
|
||||
|
||||
const display = value === null || value === undefined || value === '' ? '' : String(value);
|
||||
const [display, setDisplay] = React.useState<string>(() =>
|
||||
value === null || value === undefined || value === '' ? '' : formatGrouped(value),
|
||||
);
|
||||
const focusedRef = React.useRef(false);
|
||||
|
||||
// Re-sync the display when the controlled value changes externally (form
|
||||
// reset, parent-driven update). Skip while the input is focused so we
|
||||
// don't fight the user's keystrokes.
|
||||
React.useEffect(() => {
|
||||
if (focusedRef.current) return;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
setDisplay('');
|
||||
} else {
|
||||
setDisplay(formatGrouped(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
|
||||
</span>
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
autoComplete="off"
|
||||
value={display}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
const { display: nextDisplay, numeric } = parseTyped(e.target.value);
|
||||
setDisplay(nextDisplay);
|
||||
onChange(numeric);
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
focusedRef.current = true;
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
focusedRef.current = false;
|
||||
// On blur, canonicalize to a clean grouped representation so the
|
||||
// user sees the final value rather than any half-typed state.
|
||||
if (value === null || value === undefined || value === '') {
|
||||
setDisplay('');
|
||||
} else {
|
||||
setDisplay(formatGrouped(value));
|
||||
}
|
||||
const n = Number(raw);
|
||||
onChange(Number.isFinite(n) ? n : null);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
className={cn('pl-9 tabular-nums', className)}
|
||||
{...props}
|
||||
|
||||
@@ -65,6 +65,15 @@ interface DataTableProps<TData> {
|
||||
* sort, and selection stay in sync across the breakpoint.
|
||||
*/
|
||||
cardRender?: (row: Row<TData>) => React.ReactNode;
|
||||
/**
|
||||
* Optional grouping key for the mobile card list. When set, consecutive
|
||||
* rows that share the same returned key are visually grouped under a
|
||||
* header showing the key. Rendered only on mobile (next to cardRender);
|
||||
* the desktop table is unaffected. Useful for berths-by-area,
|
||||
* documents-by-folder, etc. — pre-sort the data on the same key so
|
||||
* adjacent rows already share groups.
|
||||
*/
|
||||
mobileGroupBy?: (row: TData) => string | null | undefined;
|
||||
/**
|
||||
* Per-column visibility map. Keys are column IDs, values mean
|
||||
* "currently visible". Columns absent from the map are visible by
|
||||
@@ -90,6 +99,7 @@ export function DataTable<TData>({
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
cardRender,
|
||||
mobileGroupBy,
|
||||
columnVisibility,
|
||||
}: DataTableProps<TData>) {
|
||||
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
||||
@@ -259,7 +269,30 @@ export function DataTable<TData>({
|
||||
{emptyState ?? 'No results.'}
|
||||
</li>
|
||||
) : (
|
||||
rows.map((row) => <li key={row.id}>{cardRender(row)}</li>)
|
||||
(() => {
|
||||
// Walk rows once, emitting a section header <li> every time
|
||||
// the groupBy key changes. Keeps the existing flex-col gap-2
|
||||
// rhythm; the header sits above the first card of each group
|
||||
// with a faint top divider for visual rest between blocks.
|
||||
let lastGroup: string | null | undefined;
|
||||
const nodes: React.ReactNode[] = [];
|
||||
rows.forEach((row, i) => {
|
||||
const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined;
|
||||
if (mobileGroupBy && group !== lastGroup) {
|
||||
nodes.push(
|
||||
<li key={`__group_${group ?? '_none'}_${i}`} className="px-1 pt-3">
|
||||
<div className="flex items-center gap-3 text-base font-bold tracking-tight text-foreground">
|
||||
<span>{group ?? 'Other'}</span>
|
||||
<span aria-hidden className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</li>,
|
||||
);
|
||||
lastGroup = group;
|
||||
}
|
||||
nodes.push(<li key={row.id}>{cardRender(row)}</li>);
|
||||
});
|
||||
return nodes;
|
||||
})()
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,27 @@ import { Drawer as VaulDrawer } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Default `shouldScaleBackground` to FALSE for smoother drag animations.
|
||||
// Scaling the underlying page during the swipe rasterises a heavy DOM
|
||||
// (dashboard widgets, charts, queries firing) into a composited layer
|
||||
// every frame, which stutters on mid-tier phones. The bg-black/60
|
||||
// overlay alone provides enough depth signal. Individual call sites can
|
||||
// still opt back in if they have a lightweight page underneath.
|
||||
//
|
||||
// Also default `repositionInputs={false}` — when the drawer has form
|
||||
// inputs, Vaul's viewport repositioning logic conflicts with iOS's
|
||||
// keyboard handling and produces the visible scroll-then-jump we hit
|
||||
// in the search overlay.
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
shouldScaleBackground = false,
|
||||
repositionInputs = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof VaulDrawer.Root>) => (
|
||||
<VaulDrawer.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||||
<VaulDrawer.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
repositionInputs={repositionInputs}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = 'Drawer';
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ interface SelectOption {
|
||||
|
||||
interface BaseProps {
|
||||
value: string | null | undefined;
|
||||
/**
|
||||
* Optional formatted version shown in display mode only. The edit
|
||||
* input still works against the raw `value` (so the input shows the
|
||||
* editable raw number, not the formatted string). Useful for
|
||||
* currency, percentages, etc.
|
||||
*/
|
||||
displayValue?: string | null;
|
||||
onSave: (next: string | null) => Promise<void>;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
@@ -43,7 +50,15 @@ interface TextareaProps extends BaseProps {
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
|
||||
interface DateProps extends BaseProps {
|
||||
variant: 'date';
|
||||
/** Optional min/max bounds in YYYY-MM-DD form (e.g. for incorporation dates that
|
||||
* can't be in the future). */
|
||||
min?: string;
|
||||
max?: string;
|
||||
}
|
||||
|
||||
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps | DateProps;
|
||||
|
||||
/**
|
||||
* Click-to-edit field used in detail panels. Shows the value as plain text
|
||||
@@ -51,7 +66,15 @@ 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,
|
||||
displayValue,
|
||||
onSave,
|
||||
placeholder,
|
||||
emptyText = '-',
|
||||
className,
|
||||
disabled,
|
||||
} = props;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -131,11 +154,42 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === 'date') {
|
||||
// Native date input: the browser provides the calendar UI, ISO-formatted
|
||||
// value (YYYY-MM-DD) keeps the backend payload uniform. Saves on change
|
||||
// (no extra blur tap on mobile) and on Enter; Escape reverts.
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<Input
|
||||
type="date"
|
||||
value={draft}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setDraft(next);
|
||||
void commit(next);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
disabled={saving || disabled}
|
||||
className="h-8 text-sm w-auto"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === 'textarea') {
|
||||
if (!editing) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
displayValue={displayValue}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -178,6 +232,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
return (
|
||||
<ReadButton
|
||||
value={value || null}
|
||||
displayValue={displayValue}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
onClick={() => setEditing(true)}
|
||||
@@ -216,6 +271,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
|
||||
|
||||
function ReadButton({
|
||||
value,
|
||||
displayValue,
|
||||
emptyText,
|
||||
disabled,
|
||||
onClick,
|
||||
@@ -224,6 +280,8 @@ function ReadButton({
|
||||
className,
|
||||
}: {
|
||||
value: string | null;
|
||||
/** Optional formatted version for display only (currency, percent, etc.) */
|
||||
displayValue?: string | null;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
@@ -258,7 +316,7 @@ function ReadButton({
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{value ?? emptyText}
|
||||
{value ? (displayValue ?? value) : emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Icon
|
||||
|
||||
@@ -146,7 +146,7 @@ export function InlinePhoneField({
|
||||
{display ?? emptyText}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<Pencil className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-50" />
|
||||
<Pencil className="h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
143
src/components/shared/interest-picker.tsx
Normal file
143
src/components/shared/interest-picker.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface InterestOption {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
}
|
||||
|
||||
interface InterestPickerProps {
|
||||
value: string | null;
|
||||
onChange: (interestId: string | null) => void;
|
||||
/** When set, only this client's interests are listed. */
|
||||
clientId?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable interest picker. Mirrors ClientPicker. When `clientId` is
|
||||
* provided the dropdown scopes to that client — so picking the client
|
||||
* first naturally narrows the interest options.
|
||||
*/
|
||||
export function InterestPicker({
|
||||
value,
|
||||
onChange,
|
||||
clientId,
|
||||
placeholder = 'Select interest...',
|
||||
disabled,
|
||||
}: InterestPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const debounced = useDebounce(search, 300);
|
||||
|
||||
const { data } = useQuery<{ data: InterestOption[] }>({
|
||||
queryKey: ['interest-picker', clientId ?? null, debounced],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '10',
|
||||
order: 'desc',
|
||||
includeArchived: 'false',
|
||||
});
|
||||
if (debounced) params.set('search', debounced);
|
||||
if (clientId) params.set('clientId', clientId);
|
||||
return apiFetch(`/api/v1/interests?${params.toString()}`);
|
||||
},
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const options = data?.data ?? [];
|
||||
|
||||
const labelFor = (o: InterestOption) => {
|
||||
const parts = [o.clientName ?? 'Unknown client'];
|
||||
if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`);
|
||||
parts.push(o.pipelineStage.replace(/_/g, ' '));
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
const match = options.find((o) => o.id === value);
|
||||
return match ? labelFor(match) : `Interest ${value.slice(0, 8)}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
// `modal` is required when this picker is rendered inside a Sheet /
|
||||
// Dialog — without it the CommandInput stays focus-blocked by the
|
||||
// outer Sheet's focus trap and clicks/typing are silently dropped.
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={clientId ? "Search this client's interests…" : 'Search interests…'}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No interests found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{value ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={o.id}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{labelFor(o)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,8 @@ interface ListCardProps {
|
||||
* Shared shell for every mobile list card. Wraps the body in a Link to the
|
||||
* detail page, paints an optional status accent bar on the left edge, and
|
||||
* exposes a top-right slot for an actions menu. Touch/hover feedback comes
|
||||
* from a soft `hover:bg-muted/30` + `active:bg-muted/50` tint, no shadow
|
||||
* shifts (which feel jittery on mobile).
|
||||
* from a soft brand-blue tint via `hover:bg-accent/40` + `active:bg-accent`,
|
||||
* no shadow shifts (which feel jittery on mobile).
|
||||
*/
|
||||
export function ListCard({
|
||||
href,
|
||||
@@ -52,7 +52,7 @@ export function ListCard({
|
||||
<article
|
||||
className={cn(
|
||||
'group relative overflow-hidden rounded-lg border bg-card shadow-xs',
|
||||
'transition-colors hover:bg-muted/30 active:bg-muted/50',
|
||||
'transition-colors hover:bg-accent/40 active:bg-accent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -64,10 +64,35 @@ export function OwnerPicker({
|
||||
|
||||
const options = data?.data ?? [];
|
||||
|
||||
// Selected display label - show entity's name from current options if
|
||||
// available, otherwise a truncated id fallback.
|
||||
// Resolve the current value's display name even before the picker is opened.
|
||||
// Without this primer query the trigger button rendered "Client <8-char-id>"
|
||||
// on first paint and only filled in the real name after the user opened the
|
||||
// dropdown (which kicked the list query). The lookup hits a per-id endpoint
|
||||
// when possible and falls back to scanning the cached options array.
|
||||
const valueLookupEndpoint = value
|
||||
? value.type === 'client'
|
||||
? `/api/v1/clients/${value.id}`
|
||||
: `/api/v1/companies/${value.id}`
|
||||
: null;
|
||||
|
||||
const { data: valueDetail } = useQuery<{
|
||||
data: { id: string; name?: string | null; fullName?: string | null };
|
||||
}>({
|
||||
queryKey: ['owner-picker-resolve', value?.type, value?.id],
|
||||
queryFn: () => apiFetch(valueLookupEndpoint!),
|
||||
enabled: !!value && !!valueLookupEndpoint,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Selected display label - prefer the resolved entity name; fall back to a
|
||||
// truncated id only when both the primer query and the options list miss.
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
if (valueDetail?.data) {
|
||||
const name =
|
||||
value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
|
||||
if (name) return name;
|
||||
}
|
||||
const match = options.find((o) => o.id === value.id);
|
||||
if (match) {
|
||||
return type === 'client'
|
||||
@@ -80,7 +105,7 @@ export function OwnerPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
104
src/components/shared/reminder-days-input.tsx
Normal file
104
src/components/shared/reminder-days-input.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Common follow-up cadences reps reach for, in days. */
|
||||
const PRESETS = [1, 3, 7, 14, 30] as const;
|
||||
|
||||
interface ReminderDaysInputProps {
|
||||
value: number | null | undefined;
|
||||
onChange: (value: number | null) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
/** Custom-input placeholder, defaults to "e.g. 21". */
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Days-from-now reminder picker. Quick-pick chips for the four or five
|
||||
* cadences reps actually use (1 day, 3, 1 week, 2 weeks, 1 month) plus a
|
||||
* custom integer input for everything else. Clearer than a raw number field
|
||||
* and surfaces the common cases without the rep having to type.
|
||||
*
|
||||
* Storage is still a single integer (number of days), so callers can keep
|
||||
* their existing form/zod shape unchanged.
|
||||
*/
|
||||
export function ReminderDaysInput({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
placeholder = 'e.g. 21',
|
||||
className,
|
||||
}: ReminderDaysInputProps) {
|
||||
const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value);
|
||||
const [customStr, setCustomStr] = React.useState<string>(() =>
|
||||
!isPreset && typeof value === 'number' ? String(value) : '',
|
||||
);
|
||||
|
||||
// Sync external value → custom input when it changes to a non-preset.
|
||||
React.useEffect(() => {
|
||||
if (typeof value === 'number' && !(PRESETS as readonly number[]).includes(value)) {
|
||||
setCustomStr(String(value));
|
||||
} else if (value == null) {
|
||||
setCustomStr('');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PRESETS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(days)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === days
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border bg-background text-foreground hover:bg-accent',
|
||||
disabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
aria-pressed={value === days}
|
||||
>
|
||||
{labelFor(days)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
value={customStr}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setCustomStr(raw);
|
||||
if (raw === '') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n > 0) onChange(n);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function labelFor(days: number): string {
|
||||
if (days === 1) return '1 day';
|
||||
if (days === 7) return '1 week';
|
||||
if (days === 14) return '2 weeks';
|
||||
if (days === 30) return '1 month';
|
||||
return `${days} days`;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function SubdivisionCombobox({
|
||||
else triggerLabel = placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -37,6 +37,13 @@ export function TagPicker({
|
||||
// Extend options to include color
|
||||
const tagOptions = options as Array<{ value: string; label: string; color?: string }>;
|
||||
|
||||
// If the port has no tags configured AND the rep also hasn't selected any
|
||||
// (e.g. a tag was deleted after selection), don't render the picker at all —
|
||||
// the affordance is noise until tags are set up under Admin → Tags.
|
||||
if (!isLoading && tagOptions.length === 0 && selectedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
if (selectedIds.includes(tagId)) {
|
||||
onChange(selectedIds.filter((id) => id !== tagId));
|
||||
@@ -53,7 +60,7 @@ export function TagPicker({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -77,7 +77,12 @@ export function TimezoneCombobox({
|
||||
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
// `modal` is critical for iOS Safari when this combobox is nested
|
||||
// inside a Sheet (Radix Dialog). Without it, the parent Dialog's
|
||||
// pointer-events handling can swallow the trigger's touch event,
|
||||
// so tapping the button does nothing on iPhone. modal=true makes
|
||||
// Radix isolate the Popover's pointer context from the parent.
|
||||
<Popover modal open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
|
||||
@@ -78,7 +78,10 @@ SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
className={cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-2 sm:space-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,11 @@ const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
// Strip only the bottom border on the last row (the visual table-divider).
|
||||
// The shorter `border-0` form also nuked left/right borders, which broke
|
||||
// the colored left-accent (e.g. mooring-letter tone in berth-list) — that
|
||||
// accent is added via row className and counts on the left border surviving.
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-b-0', className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = 'TableBody';
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { YachtCard } from '@/components/yachts/yacht-card';
|
||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||
import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters';
|
||||
import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
@@ -37,6 +38,7 @@ export function YachtList() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
|
||||
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
|
||||
@@ -79,7 +79,7 @@ export function YachtPicker({
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
Reference in New Issue
Block a user