Compare commits
3 Commits
feat/mobil
...
feat/berth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21868ee5fc | ||
|
|
c7ab816c99 | ||
|
|
e40b6c3d99 |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -28,19 +28,9 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# Ad-hoc screenshots / scratch artifacts at repo root
|
# Ad-hoc screenshots / scratch artifacts at repo root
|
||||||
/*.png
|
/*.png
|
||||||
/*.jpg
|
|
||||||
|
|
||||||
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
# Legacy Nuxt portal — kept on disk for reference, not tracked here
|
||||||
/client-portal/
|
/client-portal/
|
||||||
|
|
||||||
# Sister marketing site — separate Nuxt project, not part of CRM tracking
|
|
||||||
/website/
|
|
||||||
|
|
||||||
# Mobile audit screenshots — generated locally, regenerable
|
# Mobile audit screenshots — generated locally, regenerable
|
||||||
/.audit/
|
/.audit/
|
||||||
/.audit-screenshots/
|
|
||||||
|
|
||||||
# Tool caches / runtime state
|
|
||||||
/.claude/
|
|
||||||
/.serena/
|
|
||||||
/ruvector.db
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { headers } from 'next/headers';
|
|||||||
import { Inter, JetBrains_Mono } from 'next/font/google';
|
import { Inter, JetBrains_Mono } from 'next/font/google';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { classifyFormFactor } from '@/lib/form-factor';
|
import { classifyFormFactor } from '@/lib/form-factor';
|
||||||
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
|
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -67,7 +66,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Toaster richColors position="top-right" />
|
<Toaster richColors position="top-right" />
|
||||||
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,11 +22,7 @@ export function AlertRail() {
|
|||||||
<section
|
<section
|
||||||
data-testid="alert-rail"
|
data-testid="alert-rail"
|
||||||
aria-label="Active alerts"
|
aria-label="Active alerts"
|
||||||
// `h-full` is intentional only at xl: where the parent dashboard grid
|
className="flex h-full flex-col gap-3"
|
||||||
// gives this rail a sibling column whose height it should match. On
|
|
||||||
// mobile (single-column stack) there's no fixed-height context, so
|
|
||||||
// forcing 100% height makes the section overflow / look stretched.
|
|
||||||
className="flex flex-col gap-3 xl:h-full"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2>
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ type BerthDetailData = {
|
|||||||
draftFt: string | null;
|
draftFt: string | null;
|
||||||
draftM: string | null;
|
draftM: string | null;
|
||||||
widthIsMinimum: boolean | null;
|
widthIsMinimum: boolean | null;
|
||||||
|
nominalBoatSize: string | null;
|
||||||
|
nominalBoatSizeM: string | null;
|
||||||
|
waterDepth: string | null;
|
||||||
|
waterDepthM: string | null;
|
||||||
|
waterDepthIsMinimum: boolean | null;
|
||||||
|
sidePontoon: string | null;
|
||||||
|
cleatType: string | null;
|
||||||
|
cleatCapacity: string | null;
|
||||||
|
bollardType: string | null;
|
||||||
|
bollardCapacity: string | null;
|
||||||
|
bowFacing: string | null;
|
||||||
price: string | null;
|
price: string | null;
|
||||||
priceCurrency: string;
|
priceCurrency: string;
|
||||||
tenureType: string;
|
tenureType: string;
|
||||||
|
|||||||
@@ -16,18 +16,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetFooter,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
|
||||||
|
import {
|
||||||
|
BERTH_AREAS,
|
||||||
|
BERTH_SIDE_PONTOON_OPTIONS,
|
||||||
|
BERTH_MOORING_TYPES,
|
||||||
|
BERTH_CLEAT_TYPES,
|
||||||
|
BERTH_CLEAT_CAPACITIES,
|
||||||
|
BERTH_BOLLARD_TYPES,
|
||||||
|
BERTH_BOLLARD_CAPACITIES,
|
||||||
|
BERTH_ACCESS_OPTIONS,
|
||||||
|
} from '@/lib/constants';
|
||||||
|
|
||||||
interface BerthFormProps {
|
interface BerthFormProps {
|
||||||
berth: {
|
berth: {
|
||||||
@@ -42,16 +46,27 @@ interface BerthFormProps {
|
|||||||
draftFt: string | null;
|
draftFt: string | null;
|
||||||
draftM: string | null;
|
draftM: string | null;
|
||||||
widthIsMinimum: boolean | null;
|
widthIsMinimum: boolean | null;
|
||||||
|
nominalBoatSize: string | null;
|
||||||
|
nominalBoatSizeM: string | null;
|
||||||
|
waterDepth: string | null;
|
||||||
|
waterDepthM: string | null;
|
||||||
|
waterDepthIsMinimum: boolean | null;
|
||||||
|
sidePontoon: string | null;
|
||||||
|
powerCapacity: string | null;
|
||||||
|
voltage: string | null;
|
||||||
|
mooringType: string | null;
|
||||||
|
cleatType: string | null;
|
||||||
|
cleatCapacity: string | null;
|
||||||
|
bollardType: string | null;
|
||||||
|
bollardCapacity: string | null;
|
||||||
|
access: string | null;
|
||||||
|
bowFacing: string | null;
|
||||||
price: string | null;
|
price: string | null;
|
||||||
priceCurrency: string;
|
priceCurrency: string;
|
||||||
tenureType: string;
|
tenureType: string;
|
||||||
tenureYears: number | null;
|
tenureYears: number | null;
|
||||||
tenureStartDate: string | null;
|
tenureStartDate: string | null;
|
||||||
tenureEndDate: string | null;
|
tenureEndDate: string | null;
|
||||||
powerCapacity: string | null;
|
|
||||||
voltage: string | null;
|
|
||||||
mooringType: string | null;
|
|
||||||
access: string | null;
|
|
||||||
berthApproved: boolean | null;
|
berthApproved: boolean | null;
|
||||||
tags: Array<{ id: string; name: string; color: string }>;
|
tags: Array<{ id: string; name: string; color: string }>;
|
||||||
};
|
};
|
||||||
@@ -59,10 +74,42 @@ interface BerthFormProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optional select that allows clearing back to "no value". */
|
||||||
|
function SelectOrEmpty({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = 'Select…',
|
||||||
|
}: {
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (next: string | undefined) => void;
|
||||||
|
options: readonly string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const NONE = '__none';
|
||||||
|
return (
|
||||||
|
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>—</SelectItem>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
|
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
|
||||||
|
|
||||||
|
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
resolver: zodResolver(updateBerthSchema),
|
resolver: zodResolver(updateBerthSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
area: berth.area ?? undefined,
|
area: berth.area ?? undefined,
|
||||||
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
|
lengthFt: numOrUndef(berth.lengthFt),
|
||||||
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
|
lengthM: numOrUndef(berth.lengthM),
|
||||||
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
|
widthFt: numOrUndef(berth.widthFt),
|
||||||
widthM: berth.widthM ? Number(berth.widthM) : undefined,
|
widthM: numOrUndef(berth.widthM),
|
||||||
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
|
draftFt: numOrUndef(berth.draftFt),
|
||||||
draftM: berth.draftM ? Number(berth.draftM) : undefined,
|
draftM: numOrUndef(berth.draftM),
|
||||||
widthIsMinimum: berth.widthIsMinimum ?? false,
|
widthIsMinimum: berth.widthIsMinimum ?? false,
|
||||||
price: berth.price ? Number(berth.price) : undefined,
|
nominalBoatSize: numOrUndef(berth.nominalBoatSize),
|
||||||
|
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
|
||||||
|
waterDepth: numOrUndef(berth.waterDepth),
|
||||||
|
waterDepthM: numOrUndef(berth.waterDepthM),
|
||||||
|
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
|
||||||
|
sidePontoon: berth.sidePontoon ?? undefined,
|
||||||
|
powerCapacity: numOrUndef(berth.powerCapacity),
|
||||||
|
voltage: numOrUndef(berth.voltage),
|
||||||
|
mooringType: berth.mooringType ?? undefined,
|
||||||
|
cleatType: berth.cleatType ?? undefined,
|
||||||
|
cleatCapacity: berth.cleatCapacity ?? undefined,
|
||||||
|
bollardType: berth.bollardType ?? undefined,
|
||||||
|
bollardCapacity: berth.bollardCapacity ?? undefined,
|
||||||
|
access: berth.access ?? undefined,
|
||||||
|
bowFacing: berth.bowFacing ?? undefined,
|
||||||
|
price: numOrUndef(berth.price),
|
||||||
priceCurrency: berth.priceCurrency,
|
priceCurrency: berth.priceCurrency,
|
||||||
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
|
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
|
||||||
tenureYears: berth.tenureYears ?? undefined,
|
tenureYears: berth.tenureYears ?? undefined,
|
||||||
tenureStartDate: berth.tenureStartDate ?? undefined,
|
tenureStartDate: berth.tenureStartDate ?? undefined,
|
||||||
tenureEndDate: berth.tenureEndDate ?? undefined,
|
tenureEndDate: berth.tenureEndDate ?? undefined,
|
||||||
powerCapacity: berth.powerCapacity ?? undefined,
|
|
||||||
voltage: berth.voltage ?? undefined,
|
|
||||||
mooringType: berth.mooringType ?? undefined,
|
|
||||||
access: berth.access ?? undefined,
|
|
||||||
berthApproved: berth.berthApproved ?? false,
|
berthApproved: berth.berthApproved ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tenureType = watch('tenureType');
|
const tenureType = watch('tenureType');
|
||||||
|
const area = watch('area');
|
||||||
|
const sidePontoon = watch('sidePontoon');
|
||||||
|
const mooringType = watch('mooringType');
|
||||||
|
const cleatType = watch('cleatType');
|
||||||
|
const cleatCapacity = watch('cleatCapacity');
|
||||||
|
const bollardType = watch('bollardType');
|
||||||
|
const bollardCapacity = watch('bollardCapacity');
|
||||||
|
const access = watch('access');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="area">Area</Label>
|
<Label>Area</Label>
|
||||||
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
|
<SelectOrEmpty
|
||||||
|
value={area}
|
||||||
|
onChange={(v) => setValue('area', v)}
|
||||||
|
options={BERTH_AREAS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="mooringType">Mooring Type</Label>
|
<Label htmlFor="bowFacing">Bow Facing</Label>
|
||||||
<Input id="mooringType" {...register('mooringType')} />
|
<Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="access">Access</Label>
|
|
||||||
<Input id="access" {...register('access')} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="berthApproved"
|
id="berthApproved"
|
||||||
@@ -168,29 +234,46 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Length (ft)</Label>
|
<Label>Length (ft)</Label>
|
||||||
<Input type="number" step="0.1" {...register('lengthFt')} />
|
<Input type="number" step="0.01" {...register('lengthFt')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Length (m)</Label>
|
<Label>Length (m)</Label>
|
||||||
<Input type="number" step="0.1" {...register('lengthM')} />
|
<Input type="number" step="0.01" {...register('lengthM')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Width (ft)</Label>
|
<Label>Width (ft)</Label>
|
||||||
<Input type="number" step="0.1" {...register('widthFt')} />
|
<Input type="number" step="0.01" {...register('widthFt')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Width (m)</Label>
|
<Label>Width (m)</Label>
|
||||||
<Input type="number" step="0.1" {...register('widthM')} />
|
<Input type="number" step="0.01" {...register('widthM')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Draft (ft)</Label>
|
<Label>Draft (ft)</Label>
|
||||||
<Input type="number" step="0.1" {...register('draftFt')} />
|
<Input type="number" step="0.01" {...register('draftFt')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Draft (m)</Label>
|
<Label>Draft (m)</Label>
|
||||||
<Input type="number" step="0.1" {...register('draftM')} />
|
<Input type="number" step="0.01" {...register('draftM')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nominal Boat Size (ft)</Label>
|
||||||
|
<Input type="number" step="1" {...register('nominalBoatSize')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nominal Boat Size (m)</Label>
|
||||||
|
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Water Depth (ft)</Label>
|
||||||
|
<Input type="number" step="0.01" {...register('waterDepth')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Water Depth (m)</Label>
|
||||||
|
<Input type="number" step="0.01" {...register('waterDepthM')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="widthIsMinimum"
|
id="widthIsMinimum"
|
||||||
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="waterDepthIsMinimum"
|
||||||
|
checked={watch('waterDepthIsMinimum') ?? false}
|
||||||
|
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Mooring & Hardware */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Mooring & Hardware
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Side Pontoon</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={sidePontoon}
|
||||||
|
onChange={(v) => setValue('sidePontoon', v)}
|
||||||
|
options={BERTH_SIDE_PONTOON_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mooring Type</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={mooringType}
|
||||||
|
onChange={(v) => setValue('mooringType', v)}
|
||||||
|
options={BERTH_MOORING_TYPES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cleat Type</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={cleatType}
|
||||||
|
onChange={(v) => setValue('cleatType', v)}
|
||||||
|
options={BERTH_CLEAT_TYPES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cleat Capacity</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={cleatCapacity}
|
||||||
|
onChange={(v) => setValue('cleatCapacity', v)}
|
||||||
|
options={BERTH_CLEAT_CAPACITIES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bollard Type</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={bollardType}
|
||||||
|
onChange={(v) => setValue('bollardType', v)}
|
||||||
|
options={BERTH_BOLLARD_TYPES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Bollard Capacity</Label>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={bollardCapacity}
|
||||||
|
onChange={(v) => setValue('bollardCapacity', v)}
|
||||||
|
options={BERTH_BOLLARD_CAPACITIES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Power */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Power
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Power Capacity (kW)</Label>
|
||||||
|
<Input type="number" step="1" {...register('powerCapacity')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Voltage (V at 60Hz)</Label>
|
||||||
|
<Input type="number" step="1" {...register('voltage')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Access */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Access
|
||||||
|
</h3>
|
||||||
|
<SelectOrEmpty
|
||||||
|
value={access}
|
||||||
|
onChange={(v) => setValue('access', v)}
|
||||||
|
options={BERTH_ACCESS_OPTIONS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Infrastructure */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
|
||||||
Infrastructure
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Power Capacity</Label>
|
|
||||||
<Input {...register('powerCapacity')} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Voltage</Label>
|
|
||||||
<Input {...register('voltage')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
|||||||
@@ -57,13 +57,45 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OverviewTab({ berth }: { berth: BerthData }) {
|
function OverviewTab({ berth }: { berth: BerthData }) {
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formatDim = (ft: string | null, m: string | null) => {
|
const formatDim = (ft: string | null, m: string | null) => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (ft) parts.push(`${ft} ft`);
|
const ftFmt = fmt(ft);
|
||||||
if (m) parts.push(`${m} m`);
|
const mFmt = fmt(m);
|
||||||
|
if (ftFmt) parts.push(`${ftFmt} ft`);
|
||||||
|
if (mFmt) parts.push(`${mFmt} m`);
|
||||||
return parts.length > 0 ? parts.join(' / ') : null;
|
return parts.length > 0 ? parts.join(' / ') : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 formatPower = (kw: string | null) => {
|
||||||
|
const v = fmt(kw, 0);
|
||||||
|
return v ? `${v} kW` : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVoltage = (v: string | null) => {
|
||||||
|
const fv = fmt(v, 0);
|
||||||
|
return fv ? `${fv} V` : null;
|
||||||
|
};
|
||||||
|
|
||||||
const price = berth.price
|
const price = berth.price
|
||||||
? new Intl.NumberFormat('en-US', {
|
? new Intl.NumberFormat('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -97,7 +129,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
|
||||||
<SpecRow
|
<SpecRow
|
||||||
label="Nominal Boat Size"
|
label="Nominal Boat Size"
|
||||||
value={berth.nominalBoatSize || berth.nominalBoatSizeM}
|
value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
|
||||||
/>
|
/>
|
||||||
<SpecRow
|
<SpecRow
|
||||||
label="Water Depth"
|
label="Water Depth"
|
||||||
@@ -122,8 +154,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
|||||||
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 divide-y">
|
<CardContent className="pt-0 divide-y">
|
||||||
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
|
<SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
|
||||||
<SpecRow label="Voltage" value={berth.voltage} />
|
<SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
|
||||||
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
<SpecRow label="Cleat Type" value={berth.cleatType} />
|
||||||
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
|
||||||
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
<SpecRow label="Bollard Type" value={berth.bollardType} />
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ export interface ClientRow {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
yachtCount?: number;
|
yachtCount?: number;
|
||||||
companyCount?: number;
|
companyCount?: number;
|
||||||
interestCount?: number;
|
|
||||||
latestInterest?: { stage: string; mooringNumber: string | null } | null;
|
|
||||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react';
|
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -13,28 +12,31 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
|||||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||||
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { getCountryName } from '@/lib/i18n/countries';
|
|
||||||
|
|
||||||
interface ClientDetailHeaderProps {
|
interface ClientDetailHeaderProps {
|
||||||
client: {
|
client: {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
nationalityIso?: string | null;
|
nationality?: string | null;
|
||||||
|
preferredContactMethod?: string | null;
|
||||||
|
preferredLanguage?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
|
source?: string | null;
|
||||||
|
sourceDetails?: string | null;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
createdAt?: string;
|
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||||
contacts?: Array<{
|
|
||||||
channel: string;
|
|
||||||
value: string;
|
|
||||||
valueE164?: string | null;
|
|
||||||
isPrimary: boolean;
|
|
||||||
label?: string | null;
|
|
||||||
}>;
|
|
||||||
tags?: Array<{ id: string; name: string; color: string }>;
|
tags?: Array<{ id: string; name: string; color: string }>;
|
||||||
clientPortalEnabled?: boolean;
|
clientPortalEnabled?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
website: 'Website',
|
||||||
|
manual: 'Manual',
|
||||||
|
referral: 'Referral',
|
||||||
|
broker: 'Broker',
|
||||||
|
};
|
||||||
|
|
||||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
@@ -60,34 +62,19 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryEmail =
|
const primaryEmail =
|
||||||
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
|
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
|
||||||
client.contacts?.find((c) => c.channel === 'email')?.value;
|
client.contacts?.find((c) => c.channel === 'email');
|
||||||
const primaryPhoneContact =
|
const primaryPhone =
|
||||||
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
|
||||||
client.contacts?.find((c) => c.channel === 'phone');
|
client.contacts?.find((c) => c.channel === 'phone');
|
||||||
const primaryPhone = primaryPhoneContact?.value;
|
|
||||||
// wa.me requires the E.164 number without the leading "+". Strip from the
|
|
||||||
// canonical E.164 form when available; otherwise strip non-digits from the
|
|
||||||
// display value as a best-effort fallback.
|
|
||||||
const whatsappNumber = primaryPhoneContact?.valueE164
|
|
||||||
? primaryPhoneContact.valueE164.replace(/^\+/, '')
|
|
||||||
: primaryPhoneContact?.value
|
|
||||||
? primaryPhoneContact.value.replace(/[^\d]/g, '')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
|
||||||
const addedLabel = client.createdAt
|
|
||||||
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
|
|
||||||
: null;
|
|
||||||
const meta = [country, addedLabel].filter(Boolean) as string[];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DetailHeaderStrip>
|
<DetailHeaderStrip>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3 flex-wrap">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
|
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
|
||||||
{client.fullName}
|
{client.fullName}
|
||||||
</h1>
|
</h1>
|
||||||
{isArchived && (
|
{isArchived && (
|
||||||
@@ -97,71 +84,31 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{meta.length > 0 ? (
|
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
|
{client.source && (
|
||||||
) : null}
|
<span>
|
||||||
|
Source:{' '}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
<span className="text-foreground">
|
||||||
{primaryEmail ? (
|
{SOURCE_LABELS[client.source] ?? client.source}
|
||||||
<Button
|
</span>
|
||||||
asChild
|
</span>
|
||||||
variant="outline"
|
)}
|
||||||
size="sm"
|
{primaryEmail && (
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
<span className="flex items-center gap-1">
|
||||||
>
|
<Mail className="h-3.5 w-3.5" />
|
||||||
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
|
{primaryEmail.value}
|
||||||
<Mail />
|
</span>
|
||||||
Email
|
)}
|
||||||
</a>
|
{primaryPhone && (
|
||||||
</Button>
|
<span className="flex items-center gap-1">
|
||||||
) : null}
|
<Phone className="h-3.5 w-3.5" />
|
||||||
{primaryPhone ? (
|
{primaryPhone.value}
|
||||||
<Button
|
</span>
|
||||||
asChild
|
)}
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
|
|
||||||
<Phone />
|
|
||||||
Call
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{whatsappNumber ? (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={`https://wa.me/${whatsappNumber}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
|
||||||
>
|
|
||||||
<MessageCircle />
|
|
||||||
WhatsApp
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{!isArchived && client.clientPortalEnabled !== false ? (
|
|
||||||
<div className="hidden sm:inline-flex">
|
|
||||||
<PortalInviteButton
|
|
||||||
clientId={client.id}
|
|
||||||
clientName={client.fullName}
|
|
||||||
defaultEmail={primaryEmail}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="hidden sm:inline-flex">
|
|
||||||
<GdprExportButton clientId={client.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{client.tags.map((tag) => (
|
{client.tags.map((tag) => (
|
||||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||||
))}
|
))}
|
||||||
@@ -169,21 +116,34 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right: archive/restore as a small icon button — destructive
|
{/* Actions */}
|
||||||
action sits out of the primary action flow. */}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
{!isArchived && client.clientPortalEnabled !== false && (
|
||||||
type="button"
|
<PortalInviteButton
|
||||||
onClick={() => setArchiveOpen(true)}
|
clientId={client.id}
|
||||||
aria-label={isArchived ? 'Restore client' : 'Archive client'}
|
clientName={client.fullName}
|
||||||
title={isArchived ? 'Restore client' : 'Archive client'}
|
defaultEmail={primaryEmail?.value}
|
||||||
className={cn(
|
/>
|
||||||
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
|
|
||||||
'hover:bg-foreground/5 hover:text-foreground',
|
|
||||||
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
|
|
||||||
)}
|
)}
|
||||||
|
<GdprExportButton clientId={client.id} />
|
||||||
|
<Button
|
||||||
|
variant={isArchived ? 'outline' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
>
|
>
|
||||||
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
|
{isArchived ? (
|
||||||
</button>
|
<>
|
||||||
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Restore
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DetailHeaderStrip>
|
</DetailHeaderStrip>
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ interface ClientData {
|
|||||||
id: string;
|
id: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
value: string;
|
value: string;
|
||||||
valueE164: string | null;
|
|
||||||
valueCountry: string | null;
|
|
||||||
label: string | null;
|
label: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
|||||||
@@ -339,6 +339,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Preferred Language</Label>
|
||||||
|
<Input {...register('preferredLanguage')} placeholder="English" />
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Timezone</Label>
|
<Label>Timezone</Label>
|
||||||
<TimezoneCombobox
|
<TimezoneCombobox
|
||||||
|
|||||||
@@ -1,460 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import type { Route } from 'next';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
|
||||||
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { EmptyState } from '@/components/shared/empty-state';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
|
||||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
|
||||||
import {
|
|
||||||
StageStepper,
|
|
||||||
useClientInterests,
|
|
||||||
type ClientInterestRow,
|
|
||||||
} from '@/components/clients/client-pipeline-summary';
|
|
||||||
import { InterestForm } from '@/components/interests/interest-form';
|
|
||||||
|
|
||||||
const LEAD_CATEGORY_LABELS: Record<string, string> = {
|
|
||||||
general_interest: 'General interest',
|
|
||||||
specific_qualified: 'Specific qualified',
|
|
||||||
hot_lead: 'Hot lead',
|
|
||||||
};
|
|
||||||
|
|
||||||
function InterestRowItem({
|
|
||||||
interest,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
interest: ClientInterestRow;
|
|
||||||
onOpen: (i: ClientInterestRow) => void;
|
|
||||||
}) {
|
|
||||||
const stage = safeStage(interest.pipelineStage);
|
|
||||||
|
|
||||||
const berthLabel = interest.berthMooringNumber
|
|
||||||
? `Berth ${interest.berthMooringNumber}`
|
|
||||||
: 'General interest';
|
|
||||||
|
|
||||||
const yachtLabel = interest.yachtName ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
// Tap opens a bottom-sheet preview drawer rather than navigating to the
|
|
||||||
// full interest page. The drawer covers ~80% of mobile interactions
|
|
||||||
// ("what stage is this at, when did we last touch it"). For deeper
|
|
||||||
// edits the drawer has an "Open full page" CTA.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpen(interest)}
|
|
||||||
className={cn(
|
|
||||||
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
|
|
||||||
'hover:border-border/70 hover:shadow-md',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
|
||||||
{berthLabel}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
|
|
||||||
STAGE_BADGE[stage],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{STAGE_LABELS[stage]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{yachtLabel ? (
|
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3">
|
|
||||||
<StageStepper current={stage} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lastActivityFor(interest: ClientInterestRow): string | null {
|
|
||||||
const candidates = [interest.dateLastContact, interest.updatedAt]
|
|
||||||
.filter((v): v is string => Boolean(v))
|
|
||||||
.map((v) => new Date(v).getTime())
|
|
||||||
.filter((t) => !Number.isNaN(t));
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
|
|
||||||
* the drawer actually reads are typed here; the API returns more. */
|
|
||||||
interface InterestDetail {
|
|
||||||
id: string;
|
|
||||||
pipelineStage: string;
|
|
||||||
leadCategory: string | null;
|
|
||||||
source: string | null;
|
|
||||||
notes: string | null;
|
|
||||||
dateLastContact: string | null;
|
|
||||||
dateEoiSent: string | null;
|
|
||||||
dateEoiSigned: string | null;
|
|
||||||
dateDepositReceived: string | null;
|
|
||||||
dateContractSent: string | null;
|
|
||||||
dateContractSigned: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useInterestDetail(id: string | null) {
|
|
||||||
return useQuery<{ data: InterestDetail }>({
|
|
||||||
queryKey: ['interest-detail-drawer', id],
|
|
||||||
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
|
|
||||||
enabled: id !== null,
|
|
||||||
// Detail rarely changes during a single drawer-open session; stale-time
|
|
||||||
// keeps re-opens snappy without preventing background refetch.
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
|
|
||||||
* empty input so callers can render an "empty" state. */
|
|
||||||
function formatDate(value: string | null | undefined): string | null {
|
|
||||||
if (!value) return null;
|
|
||||||
const d = new Date(value);
|
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
|
||||||
return format(d, 'MMM d, yyyy');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single milestone row inside the drawer's milestone summary. Filled
|
|
||||||
* circle when the step is done, hollow when pending. Trailing meta line
|
|
||||||
* shows the date stamp or a "pending" hint. */
|
|
||||||
function MilestoneRow({
|
|
||||||
label,
|
|
||||||
done,
|
|
||||||
date,
|
|
||||||
hint = 'pending',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
done: boolean;
|
|
||||||
date: string | null;
|
|
||||||
hint?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li className="flex items-center gap-2 py-1">
|
|
||||||
{done ? (
|
|
||||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom-sheet preview of a single interest. Designed for the mobile
|
|
||||||
* "tap an interest → see what's happening without leaving the client
|
|
||||||
* page" flow. Shows the pipeline progress, a compact milestone summary
|
|
||||||
* (EOI / Deposit / Contract), lead context, last contact, and a notes
|
|
||||||
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
|
|
||||||
* away via "Open full page →".
|
|
||||||
*/
|
|
||||||
function InterestPreviewDrawer({
|
|
||||||
interest,
|
|
||||||
portSlug,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
interest: ClientInterestRow | null;
|
|
||||||
portSlug: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
// Pin the most recently selected interest so the drawer stays populated
|
|
||||||
// during the close-animation tail (Vaul keeps the content mounted ~250ms
|
|
||||||
// after `open=false`). Conditional setState is safe here — the guard
|
|
||||||
// ensures it only fires when the prop actually changes to a new row.
|
|
||||||
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
|
|
||||||
if (interest && interest !== pinned) setPinned(interest);
|
|
||||||
const showing = pinned;
|
|
||||||
|
|
||||||
const detail = useInterestDetail(showing?.id ?? null);
|
|
||||||
const fullDetail = detail.data?.data ?? null;
|
|
||||||
|
|
||||||
const open = interest !== null;
|
|
||||||
const stage = showing ? safeStage(showing.pipelineStage) : null;
|
|
||||||
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
|
|
||||||
const reached = (target: PipelineStage) =>
|
|
||||||
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
|
||||||
|
|
||||||
const berthLabel = showing
|
|
||||||
? showing.berthMooringNumber
|
|
||||||
? `Berth ${showing.berthMooringNumber}`
|
|
||||||
: 'General interest'
|
|
||||||
: '';
|
|
||||||
const yachtLabel = showing?.yachtName ?? null;
|
|
||||||
const activity = showing ? lastActivityFor(showing) : null;
|
|
||||||
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
|
|
||||||
|
|
||||||
const leadLabel = fullDetail?.leadCategory
|
|
||||||
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
|
|
||||||
: null;
|
|
||||||
const sourceLabel = fullDetail?.source
|
|
||||||
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
|
|
||||||
: null;
|
|
||||||
const lastContactDate = formatDate(fullDetail?.dateLastContact);
|
|
||||||
const notesPreview = fullDetail?.notes?.trim() || null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DrawerContent className="max-h-[85vh]">
|
|
||||||
<DrawerHeader>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
|
|
||||||
{yachtLabel ? (
|
|
||||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{stage ? (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
|
|
||||||
STAGE_BADGE[stage],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{STAGE_LABELS[stage]}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="space-y-5 overflow-y-auto px-4 pb-4">
|
|
||||||
{/* Pipeline-stepper segmented bar — the same primitive used on the
|
|
||||||
row card, so the at-a-glance progress hint is consistent
|
|
||||||
across surfaces. */}
|
|
||||||
{stage ? (
|
|
||||||
<div>
|
|
||||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Pipeline progress
|
|
||||||
</p>
|
|
||||||
<StageStepper current={stage} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Milestones — three sections matching the full interest detail
|
|
||||||
page (EOI / Deposit / Contract). Done-state is derived from
|
|
||||||
the pipeline stage so seed data without per-step dates still
|
|
||||||
renders correctly. The full milestone columns + per-step
|
|
||||||
actions live behind "Open full page". */}
|
|
||||||
<section>
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Milestones
|
|
||||||
</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
|
||||||
<p className="mb-1 text-sm font-semibold">EOI</p>
|
|
||||||
<ul>
|
|
||||||
<MilestoneRow
|
|
||||||
label="EOI sent"
|
|
||||||
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
|
|
||||||
date={formatDate(fullDetail?.dateEoiSent)}
|
|
||||||
/>
|
|
||||||
<MilestoneRow
|
|
||||||
label="EOI signed"
|
|
||||||
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
|
|
||||||
date={formatDate(fullDetail?.dateEoiSigned)}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
|
||||||
<p className="mb-1 text-sm font-semibold">Deposit</p>
|
|
||||||
<ul>
|
|
||||||
<MilestoneRow
|
|
||||||
label="Deposit received"
|
|
||||||
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
|
|
||||||
date={formatDate(fullDetail?.dateDepositReceived)}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
|
||||||
<p className="mb-1 text-sm font-semibold">Contract</p>
|
|
||||||
<ul>
|
|
||||||
<MilestoneRow
|
|
||||||
label="Contract sent"
|
|
||||||
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
|
|
||||||
date={formatDate(fullDetail?.dateContractSent)}
|
|
||||||
/>
|
|
||||||
<MilestoneRow
|
|
||||||
label="Contract signed"
|
|
||||||
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
|
|
||||||
date={formatDate(fullDetail?.dateContractSigned)}
|
|
||||||
/>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Compact key/value pairs — lead category, source, last contact,
|
|
||||||
activity. Each row collapses cleanly when its value is
|
|
||||||
missing so the drawer scales from sparse seed data to full
|
|
||||||
records without empty placeholders. */}
|
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
|
|
||||||
{leadLabel ? (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Lead</dt>
|
|
||||||
<dd className="text-right font-medium">{leadLabel}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{sourceLabel ? (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Source</dt>
|
|
||||||
<dd className="text-right font-medium">{sourceLabel}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{lastContactDate ? (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Last contact</dt>
|
|
||||||
<dd className="text-right font-medium">{lastContactDate}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{activity ? (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">Last activity</dt>
|
|
||||||
<dd className="text-right font-medium">{activity}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{notesPreview ? (
|
|
||||||
<section>
|
|
||||||
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Notes
|
|
||||||
</p>
|
|
||||||
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
|
|
||||||
{notesPreview}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button asChild className="w-full" size="lg">
|
|
||||||
<Link href={fullHref}>
|
|
||||||
Open full page
|
|
||||||
<ArrowRight className="ml-1.5 size-4" aria-hidden />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InterestSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="mt-2 h-3 w-24" />
|
|
||||||
<Skeleton className="mt-3 h-2 w-48" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClientInterestsTabProps {
|
|
||||||
clientId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
|
|
||||||
const routeParams = useParams<{ portSlug: string }>();
|
|
||||||
const portSlug = routeParams?.portSlug ?? '';
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useClientInterests(clientId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<InterestSkeleton />
|
|
||||||
<InterestSkeleton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interests = data?.data ?? [];
|
|
||||||
|
|
||||||
if (interests.length === 0) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<EmptyState
|
|
||||||
title="No interests yet"
|
|
||||||
description="When this client expresses interest in a berth, the sales process will appear here."
|
|
||||||
action={{
|
|
||||||
label: 'Add interest',
|
|
||||||
onClick: () => setCreateOpen(true),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = interests.filter((i) => !i.archivedAt);
|
|
||||||
const archived = interests.filter((i) => i.archivedAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
|
||||||
Add interest
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{active.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{active.map((i) => (
|
|
||||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{archived.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
Archived
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-3 opacity-60">
|
|
||||||
{archived.map((i) => (
|
|
||||||
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<InterestPreviewDrawer
|
|
||||||
interest={previewInterest}
|
|
||||||
portSlug={portSlug}
|
|
||||||
onClose={() => setPreviewInterest(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,6 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
|
|||||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||||
import { NotesList } from '@/components/shared/notes-list';
|
import { NotesList } from '@/components/shared/notes-list';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
|
|
||||||
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
|
|
||||||
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
|
||||||
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
|
||||||
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
|
||||||
@@ -133,11 +131,6 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
||||||
<ClientPipelineSummary clientId={clientId} variant="panel" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Personal Info */}
|
{/* Personal Info */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -155,6 +148,12 @@ function OverviewTab({
|
|||||||
data-testid="client-nationality-inline"
|
data-testid="client-nationality-inline"
|
||||||
/>
|
/>
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
|
<EditableRow label="Preferred Language">
|
||||||
|
<InlineEditableField
|
||||||
|
value={client.preferredLanguage}
|
||||||
|
onSave={save('preferredLanguage')}
|
||||||
|
/>
|
||||||
|
</EditableRow>
|
||||||
<EditableRow label="Timezone">
|
<EditableRow label="Timezone">
|
||||||
<InlineTimezoneField
|
<InlineTimezoneField
|
||||||
value={client.timezone}
|
value={client.timezone}
|
||||||
@@ -210,7 +209,6 @@ function OverviewTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,11 +219,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
label: 'Overview',
|
label: 'Overview',
|
||||||
content: <OverviewTab clientId={clientId} client={client} />,
|
content: <OverviewTab clientId={clientId} client={client} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'interests',
|
|
||||||
label: 'Interests',
|
|
||||||
content: <ClientInterestsTab clientId={clientId} />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'yachts',
|
id: 'yachts',
|
||||||
label: 'Yachts',
|
label: 'Yachts',
|
||||||
@@ -258,6 +251,15 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'interests',
|
||||||
|
label: 'Interests',
|
||||||
|
content: (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<p>Interests will appear here once created.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'notes',
|
id: 'notes',
|
||||||
label: 'Notes',
|
label: 'Notes',
|
||||||
|
|||||||
@@ -155,7 +155,6 @@ function ContactRow({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
|
||||||
const [phoneEditing, setPhoneEditing] = useState(false);
|
|
||||||
|
|
||||||
async function togglePrimary() {
|
async function togglePrimary() {
|
||||||
try {
|
try {
|
||||||
@@ -175,31 +174,17 @@ function ContactRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||||
data-editing={phoneEditing ? 'true' : undefined}
|
{/* Left: channel + value */}
|
||||||
className={cn(
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
'group rounded-lg border text-sm transition-all duration-150',
|
|
||||||
// Active-edit dilation: lift the row out of the muted baseline with a
|
|
||||||
// soft primary ring + slightly brighter surface. Single visual signal
|
|
||||||
// replaces the need for any "now editing" label.
|
|
||||||
phoneEditing
|
|
||||||
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
|
|
||||||
: 'bg-muted/30 p-2 gap-2',
|
|
||||||
// Stack value editor / action cluster on mobile; single row on sm+.
|
|
||||||
'flex flex-col sm:flex-row sm:items-center',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Top / left: channel + value */}
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
<ChannelPicker value={contact.channel} onChange={changeChannel}>
|
||||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</ChannelPicker>
|
</ChannelPicker>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0">
|
||||||
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
|
||||||
<InlinePhoneField
|
<InlinePhoneField
|
||||||
e164={contact.valueE164 ?? null}
|
e164={contact.valueE164 ?? null}
|
||||||
country={contact.valueCountry ?? null}
|
country={contact.valueCountry ?? null}
|
||||||
onEditingChange={setPhoneEditing}
|
|
||||||
onSave={async ({ e164, country }) => {
|
onSave={async ({ e164, country }) => {
|
||||||
if (!e164) {
|
if (!e164) {
|
||||||
toast.error('Phone number is required');
|
toast.error('Phone number is required');
|
||||||
@@ -223,11 +208,9 @@ function ContactRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom / right: tag + actions. Hidden while the phone editor is active
|
{/* Right: tag + actions */}
|
||||||
to keep focus on the form — no chips fighting for space, no noise. */}
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{!phoneEditing ? (
|
<div className="w-28 text-xs text-muted-foreground text-right">
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
||||||
<div className="w-28 text-right text-xs text-muted-foreground">
|
|
||||||
<InlineEditableField
|
<InlineEditableField
|
||||||
value={
|
value={
|
||||||
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
|
||||||
@@ -245,7 +228,7 @@ function ContactRow({
|
|||||||
onClick={togglePrimary}
|
onClick={togglePrimary}
|
||||||
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
title={contact.isPrimary ? 'Primary' : 'Make primary'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded p-1 transition-colors hover:bg-background/60',
|
'p-1 rounded hover:bg-background/60 transition-colors',
|
||||||
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -256,13 +239,11 @@ function ContactRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
// Trash is opacity-0 on desktop hover-only; on touch, always show.
|
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -349,9 +330,7 @@ function NewContactForm({
|
|||||||
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Single row on sm+; wraps onto multiple lines below 640px so the channel
|
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
|
||||||
// picker, value field, label, and buttons each get their own usable width.
|
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
|
|
||||||
<Select
|
<Select
|
||||||
value={channel}
|
value={channel}
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
@@ -374,7 +353,7 @@ function NewContactForm({
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{isPhoneChannel ? (
|
{isPhoneChannel ? (
|
||||||
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
<div className="flex-1 min-w-0">
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
value={phoneValue}
|
value={phoneValue}
|
||||||
onChange={(v) => setPhoneValue(v)}
|
onChange={(v) => setPhoneValue(v)}
|
||||||
@@ -386,7 +365,7 @@ function NewContactForm({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
placeholder={channel === 'email' ? 'name@example.com' : 'value'}
|
||||||
className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto"
|
className="h-7 text-sm flex-1 min-w-0"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -403,7 +382,7 @@ function NewContactForm({
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="tag (optional)"
|
placeholder="tag (optional)"
|
||||||
className="h-7 w-28 text-xs"
|
className="h-7 text-xs w-28"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -414,7 +393,6 @@ function NewContactForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ml-auto flex gap-2">
|
|
||||||
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
|
||||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -422,6 +400,5 @@ function NewContactForm({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,11 @@ function formatPercent(value: number): string {
|
|||||||
|
|
||||||
function KpiTileSkeleton() {
|
function KpiTileSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5">
|
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm">
|
||||||
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="h-3 w-24" />
|
||||||
<Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" />
|
<Skeleton className="mt-3 h-7 w-32" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-12" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,11 +67,8 @@ export function MyRemindersRail() {
|
|||||||
return `/${portSlug}/reminders`;
|
return `/${portSlug}/reminders`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
|
||||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
|
||||||
// tall empty card.
|
|
||||||
return (
|
return (
|
||||||
<Card className="xl:h-full">
|
<Card className="h-full">
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
type Edge = 'top' | 'bottom' | 'left' | 'right';
|
|
||||||
|
|
||||||
interface ToolbarState {
|
|
||||||
edge: Edge;
|
|
||||||
ratio: number;
|
|
||||||
collapsed: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
defaultAction?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReactGrabAPI {
|
|
||||||
setToolbarState: (state: Partial<ToolbarState>) => void;
|
|
||||||
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__REACT_GRAB__?: ReactGrabAPI;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
|
||||||
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
|
|
||||||
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
|
|
||||||
|
|
||||||
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
|
|
||||||
edge: 'bottom',
|
|
||||||
ratio: 0.5,
|
|
||||||
collapsed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOBILE_DEFAULT: Partial<ToolbarState> = {
|
|
||||||
edge: 'right',
|
|
||||||
ratio: 0.5,
|
|
||||||
collapsed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReactGrabViewportSync() {
|
|
||||||
useEffect(() => {
|
|
||||||
if (process.env.NODE_ENV !== 'development') return;
|
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
|
||||||
let pollId: number | undefined;
|
|
||||||
|
|
||||||
const wireUp = (api: ReactGrabAPI) => {
|
|
||||||
const mql = window.matchMedia(MOBILE_QUERY);
|
|
||||||
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
|
|
||||||
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
|
|
||||||
|
|
||||||
let suppressNextWrite = false;
|
|
||||||
const apply = () => {
|
|
||||||
const stored = localStorage.getItem(keyFor());
|
|
||||||
suppressNextWrite = true;
|
|
||||||
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
|
|
||||||
};
|
|
||||||
|
|
||||||
apply();
|
|
||||||
|
|
||||||
const unsubscribe = api.onToolbarStateChange((state) => {
|
|
||||||
if (suppressNextWrite) {
|
|
||||||
suppressNextWrite = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localStorage.setItem(keyFor(), JSON.stringify(state));
|
|
||||||
});
|
|
||||||
|
|
||||||
mql.addEventListener('change', apply);
|
|
||||||
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryWire = () => {
|
|
||||||
const api = window.__REACT_GRAB__;
|
|
||||||
if (!api) return false;
|
|
||||||
wireUp(api);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!tryWire()) {
|
|
||||||
pollId = window.setInterval(() => {
|
|
||||||
if (tryWire() && pollId !== undefined) {
|
|
||||||
window.clearInterval(pollId);
|
|
||||||
pollId = undefined;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (pollId !== undefined) {
|
|
||||||
window.clearInterval(pollId);
|
|
||||||
pollId = undefined;
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (pollId !== undefined) window.clearInterval(pollId);
|
|
||||||
cleanups.forEach((fn) => fn());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
|
import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -12,27 +12,11 @@ type TabSpec = {
|
|||||||
segment: string; // route segment after /[portSlug]/
|
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[] = [
|
const TABS: TabSpec[] = [
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
|
||||||
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
|
||||||
{ label: 'Clients', icon: Users, segment: 'clients' },
|
{ label: 'Clients', icon: Users, segment: 'clients' },
|
||||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||||
|
{ label: 'Berths', icon: Anchor, segment: 'berths' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
|
||||||
Bell,
|
|
||||||
Bookmark,
|
|
||||||
Building2,
|
Building2,
|
||||||
FileText,
|
Bookmark,
|
||||||
Mail,
|
|
||||||
Receipt,
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Mail,
|
||||||
|
Bell,
|
||||||
|
ShieldAlert,
|
||||||
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldAlert,
|
|
||||||
Ship,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,17 +30,12 @@ type MoreItem = {
|
|||||||
segment: string;
|
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.
|
|
||||||
const MORE_ITEMS: MoreItem[] = [
|
const MORE_ITEMS: MoreItem[] = [
|
||||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
|
||||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
|
||||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||||
|
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||||
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
{ label: 'Invoices', icon: FileText, segment: 'invoices' },
|
||||||
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
|
||||||
|
{ label: 'Documents', icon: FolderOpen, segment: 'documents' },
|
||||||
{ label: 'Email', icon: Mail, segment: 'email' },
|
{ label: 'Email', icon: Mail, segment: 'email' },
|
||||||
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
|
||||||
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
{ label: 'Reports', icon: BarChart3, segment: 'reports' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -225,14 +225,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
|
|
||||||
function flagEmoji(code: string | null | undefined): string {
|
|
||||||
if (!code || code.length !== 2) return '';
|
|
||||||
const A = 0x1f1e6;
|
|
||||||
const a = 'A'.charCodeAt(0);
|
|
||||||
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CountryFieldInline({
|
function CountryFieldInline({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -241,34 +233,20 @@ function CountryFieldInline({
|
|||||||
onSave: (iso: string | null) => Promise<void>;
|
onSave: (iso: string | null) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
// Tracks whether a value was picked this edit cycle so the open-change
|
|
||||||
// handler doesn't double-exit while commit is still in flight.
|
|
||||||
const pickedRef = useRef(false);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<CountryCombobox
|
<CountryCombobox
|
||||||
value={value ?? null}
|
value={value ?? null}
|
||||||
onChange={async (iso) => {
|
onChange={async (iso) => {
|
||||||
pickedRef.current = true;
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
await onSave(iso ?? null);
|
await onSave(iso ?? null);
|
||||||
}}
|
}}
|
||||||
clearable
|
clearable
|
||||||
className="w-full"
|
className="w-full"
|
||||||
// Drop the user straight into the picker — no extra click on the
|
|
||||||
// trigger required.
|
|
||||||
defaultOpen
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
// Auto-exit edit mode when the popover closes without a pick so
|
|
||||||
// the user isn't stuck staring at a "Select country…" trigger.
|
|
||||||
if (!open && !pickedRef.current) setEditing(false);
|
|
||||||
if (open) pickedRef.current = false;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
|
const display = value ? getCountryName(value, 'en') : null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -290,25 +268,17 @@ function SubdivisionFieldInline({
|
|||||||
onSave: (code: string | null) => Promise<void>;
|
onSave: (code: string | null) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const pickedRef = useRef(false);
|
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<SubdivisionCombobox
|
<SubdivisionCombobox
|
||||||
value={value ?? null}
|
value={value ?? null}
|
||||||
country={country}
|
country={country}
|
||||||
onChange={async (code) => {
|
onChange={async (code) => {
|
||||||
pickedRef.current = true;
|
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
await onSave(code ?? null);
|
await onSave(code ?? null);
|
||||||
}}
|
}}
|
||||||
clearable
|
clearable
|
||||||
className="w-full"
|
className="w-full"
|
||||||
defaultOpen
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !pickedRef.current) setEditing(false);
|
|
||||||
if (open) pickedRef.current = false;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ interface CountryComboboxProps {
|
|||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
/** Open the dropdown on first render. Used by inline-edit wrappers so the
|
|
||||||
* user lands directly in the picker after clicking the edit affordance. */
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
|
||||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,14 +58,8 @@ export function CountryCombobox({
|
|||||||
clearable = true,
|
clearable = true,
|
||||||
id,
|
id,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
defaultOpen = false,
|
|
||||||
onOpenChange,
|
|
||||||
}: CountryComboboxProps) {
|
}: CountryComboboxProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(false);
|
||||||
const handleOpenChange = (next: boolean) => {
|
|
||||||
setOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||||
|
|
||||||
// Pre-build the options list once per locale change so the cmdk filter
|
// Pre-build the options list once per locale change so the cmdk filter
|
||||||
@@ -87,7 +75,7 @@ export function CountryCombobox({
|
|||||||
const selected = value ? options.find((o) => o.code === value) : undefined;
|
const selected = value ? options.find((o) => o.code === value) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, Pencil } from 'lucide-react';
|
import { Loader2, Pencil } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -31,12 +31,8 @@ export function InlineCountryField({
|
|||||||
}: InlineCountryFieldProps) {
|
}: InlineCountryFieldProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
// Set true when the user picks a value from the dropdown, so the
|
|
||||||
// popover-close handler knows commit() will exit edit mode itself.
|
|
||||||
const pickedRef = useRef(false);
|
|
||||||
|
|
||||||
async function commit(next: CountryCode | null) {
|
async function commit(next: CountryCode | null) {
|
||||||
pickedRef.current = true;
|
|
||||||
if (next === (value ?? null)) {
|
if (next === (value ?? null)) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
return;
|
return;
|
||||||
@@ -55,23 +51,7 @@ export function InlineCountryField({
|
|||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-1', className)}>
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
<CountryCombobox
|
<CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} />
|
||||||
value={value}
|
|
||||||
onChange={(iso) => void commit(iso)}
|
|
||||||
data-testid={testId}
|
|
||||||
defaultOpen
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
// When the dropdown closes without a selection, leave edit mode
|
|
||||||
// so the user isn't stuck staring at the trigger button. If a
|
|
||||||
// pick happened, commit() handles the exit (and may need to keep
|
|
||||||
// edit mode briefly to show the saving spinner).
|
|
||||||
if (!open && !pickedRef.current) {
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
// Reset for the next open cycle.
|
|
||||||
if (open) pickedRef.current = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ interface InlinePhoneFieldProps {
|
|||||||
/** Falls back to this country if `country` isn't set. */
|
/** Falls back to this country if `country` isn't set. */
|
||||||
defaultCountry?: CountryCode;
|
defaultCountry?: CountryCode;
|
||||||
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
|
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
|
||||||
/**
|
|
||||||
* Notifies the parent when the field enters/exits edit mode. Lets the row
|
|
||||||
* dim or hide noise (tag chips, action buttons) while the user is focused
|
|
||||||
* on the editor.
|
|
||||||
*/
|
|
||||||
onEditingChange?: (editing: boolean) => void;
|
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -34,13 +28,12 @@ export function InlinePhoneField({
|
|||||||
country,
|
country,
|
||||||
defaultCountry,
|
defaultCountry,
|
||||||
onSave,
|
onSave,
|
||||||
onEditingChange,
|
|
||||||
emptyText = '—',
|
emptyText = '—',
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: InlinePhoneFieldProps) {
|
}: InlinePhoneFieldProps) {
|
||||||
const [editing, setEditingRaw] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
|
const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
|
||||||
if (!e164 && !country) return null;
|
if (!e164 && !country) return null;
|
||||||
return {
|
return {
|
||||||
@@ -50,11 +43,6 @@ export function InlinePhoneField({
|
|||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
function setEditing(next: boolean) {
|
|
||||||
setEditingRaw(next);
|
|
||||||
onEditingChange?.(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
|
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
|
||||||
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
|
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
|
||||||
@@ -74,15 +62,21 @@ export function InlinePhoneField({
|
|||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
return (
|
return (
|
||||||
// Two clean lines: country picker + number on top, action pair below.
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
<div className={cn('flex w-full flex-col gap-2.5', className)}>
|
|
||||||
<PhoneInput
|
<PhoneInput
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(v) => setDraft(v)}
|
onChange={(v) => setDraft(v)}
|
||||||
defaultCountry={defaultCountry}
|
defaultCountry={defaultCountry}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void commit()}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -97,27 +91,10 @@ export function InlinePhoneField({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className={cn(
|
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||||
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
|
|
||||||
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
|
|
||||||
'disabled:opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void commit()}
|
|
||||||
disabled={saving}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
|
|
||||||
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
|
|
||||||
'transition-colors hover:bg-primary/90 disabled:opacity-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, Pencil } from 'lucide-react';
|
import { Loader2, Pencil } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -31,12 +31,8 @@ export function InlineTimezoneField({
|
|||||||
}: InlineTimezoneFieldProps) {
|
}: InlineTimezoneFieldProps) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
// Set true when the user picks a value from the dropdown, so the
|
|
||||||
// popover-close handler knows commit() will exit edit mode itself.
|
|
||||||
const pickedRef = useRef(false);
|
|
||||||
|
|
||||||
async function commit(next: string | null) {
|
async function commit(next: string | null) {
|
||||||
pickedRef.current = true;
|
|
||||||
if (next === (value ?? null)) {
|
if (next === (value ?? null)) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
return;
|
return;
|
||||||
@@ -60,16 +56,6 @@ export function InlineTimezoneField({
|
|||||||
onChange={(tz) => void commit(tz)}
|
onChange={(tz) => void commit(tz)}
|
||||||
countryHint={countryHint ?? undefined}
|
countryHint={countryHint ?? undefined}
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
defaultOpen
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
// Auto-exit edit mode when the dropdown closes without a pick,
|
|
||||||
// so the user isn't stuck looking at the trigger. commit() owns
|
|
||||||
// the exit when a value was selected.
|
|
||||||
if (!open && !pickedRef.current) {
|
|
||||||
setEditing(false);
|
|
||||||
}
|
|
||||||
if (open) pickedRef.current = false;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,11 +32,6 @@ interface SubdivisionComboboxProps {
|
|||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
|
||||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubdivisionCombobox({
|
export function SubdivisionCombobox({
|
||||||
@@ -49,14 +44,8 @@ export function SubdivisionCombobox({
|
|||||||
clearable = true,
|
clearable = true,
|
||||||
id,
|
id,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
defaultOpen = false,
|
|
||||||
onOpenChange,
|
|
||||||
}: SubdivisionComboboxProps) {
|
}: SubdivisionComboboxProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(false);
|
||||||
const handleOpenChange = (next: boolean) => {
|
|
||||||
setOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
if (!country) return [];
|
if (!country) return [];
|
||||||
@@ -75,7 +64,7 @@ export function SubdivisionCombobox({
|
|||||||
else triggerLabel = placeholder;
|
else triggerLabel = placeholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ interface TimezoneComboboxProps {
|
|||||||
clearable?: boolean;
|
clearable?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
/** Open the dropdown on first render. Used by inline-edit wrappers. */
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
|
|
||||||
* this to auto-exit edit mode when the user dismisses without picking. */
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimezoneCombobox({
|
export function TimezoneCombobox({
|
||||||
@@ -46,14 +41,8 @@ export function TimezoneCombobox({
|
|||||||
clearable = true,
|
clearable = true,
|
||||||
id,
|
id,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
defaultOpen = false,
|
|
||||||
onOpenChange,
|
|
||||||
}: TimezoneComboboxProps) {
|
}: TimezoneComboboxProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(false);
|
||||||
const handleOpenChange = (next: boolean) => {
|
|
||||||
setOpen(next);
|
|
||||||
onOpenChange?.(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allOptions = useMemo(() => {
|
const allOptions = useMemo(() => {
|
||||||
return listAllTimezones().map((tz) => ({
|
return listAllTimezones().map((tz) => ({
|
||||||
@@ -77,7 +66,7 @@ export function TimezoneCombobox({
|
|||||||
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
|
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function KPITile({
|
|||||||
<div
|
<div
|
||||||
data-testid="kpi-tile"
|
data-testid="kpi-tile"
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-3 shadow-sm transition-all duration-base ease-smooth hover:shadow-md sm:p-5',
|
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -53,12 +53,10 @@ export function KPITile({
|
|||||||
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
|
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
{typeof delta === 'number' ? (
|
{typeof delta === 'number' ? (
|
||||||
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
||||||
{deltaPrefix}
|
{deltaPrefix}
|
||||||
|
|||||||
@@ -123,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
|
|||||||
|
|
||||||
export type BerthStatus = (typeof BERTH_STATUSES)[number];
|
export type BerthStatus = (typeof BERTH_STATUSES)[number];
|
||||||
|
|
||||||
|
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
|
||||||
|
// Stored as free text in the DB so legacy values still load, but the form
|
||||||
|
// presents only the canonical options below.
|
||||||
|
|
||||||
|
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
|
||||||
|
|
||||||
|
export const BERTH_SIDE_PONTOON_OPTIONS = [
|
||||||
|
'No',
|
||||||
|
'Quay SB',
|
||||||
|
'Quay PT',
|
||||||
|
'Quay SB, Yes PT',
|
||||||
|
'Quay PT, Yes SB',
|
||||||
|
'Yes SB',
|
||||||
|
'Yes PT',
|
||||||
|
'Yes SB, PT',
|
||||||
|
'Finger SB',
|
||||||
|
'Finger PT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BERTH_MOORING_TYPES = [
|
||||||
|
'Side Pier / Med Mooring',
|
||||||
|
'2x Med Mooring',
|
||||||
|
'Side Pier / Finger',
|
||||||
|
'Finger / Med Mooring',
|
||||||
|
'2x Finger',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
|
||||||
|
|
||||||
|
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
|
||||||
|
|
||||||
|
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
|
||||||
|
|
||||||
|
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
|
||||||
|
|
||||||
|
export const BERTH_ACCESS_OPTIONS = [
|
||||||
|
'Car to Vessel',
|
||||||
|
'Car to Quai, Cart to Vessel',
|
||||||
|
'Cart to Vessel',
|
||||||
|
'Car (3t) to Vessel',
|
||||||
|
'Car (3.5t) to Vessel',
|
||||||
|
] as const;
|
||||||
|
|
||||||
// ─── Lead Categories ─────────────────────────────────────────────────────────
|
// ─── Lead Categories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
|
||||||
|
|||||||
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal file
15
src/lib/db/migrations/0020_medical_betty_brant.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Convert text columns to numeric. NULLs survive; empty strings become NULL;
|
||||||
|
-- whitespace is trimmed before casting so legacy data with stray spaces converts cleanly.
|
||||||
|
ALTER TABLE "berths"
|
||||||
|
ALTER COLUMN "nominal_boat_size" SET DATA TYPE numeric
|
||||||
|
USING NULLIF(TRIM("nominal_boat_size"), '')::numeric;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berths"
|
||||||
|
ALTER COLUMN "nominal_boat_size_m" SET DATA TYPE numeric
|
||||||
|
USING NULLIF(TRIM("nominal_boat_size_m"), '')::numeric;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berths"
|
||||||
|
ALTER COLUMN "power_capacity" SET DATA TYPE numeric
|
||||||
|
USING NULLIF(TRIM("power_capacity"), '')::numeric;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berths"
|
||||||
|
ALTER COLUMN "voltage" SET DATA TYPE numeric
|
||||||
|
USING NULLIF(TRIM("voltage"), '')::numeric;--> statement-breakpoint
|
||||||
|
ALTER TABLE "berths" ADD COLUMN "status_override_mode" text;
|
||||||
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
10246
src/lib/db/migrations/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
|||||||
"when": 1777671562738,
|
"when": 1777671562738,
|
||||||
"tag": "0019_lazy_vampiro",
|
"tag": "0019_lazy_vampiro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777814682110,
|
||||||
|
"tag": "0020_medical_betty_brant",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ export const berths = pgTable(
|
|||||||
widthM: numeric('width_m'),
|
widthM: numeric('width_m'),
|
||||||
draftM: numeric('draft_m'),
|
draftM: numeric('draft_m'),
|
||||||
widthIsMinimum: boolean('width_is_minimum').default(false),
|
widthIsMinimum: boolean('width_is_minimum').default(false),
|
||||||
nominalBoatSize: text('nominal_boat_size'),
|
// Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
|
||||||
nominalBoatSizeM: text('nominal_boat_size_m'),
|
nominalBoatSize: numeric('nominal_boat_size'),
|
||||||
|
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||||
waterDepth: numeric('water_depth'),
|
waterDepth: numeric('water_depth'),
|
||||||
waterDepthM: numeric('water_depth_m'),
|
waterDepthM: numeric('water_depth_m'),
|
||||||
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
||||||
sidePontoon: text('side_pontoon'),
|
sidePontoon: text('side_pontoon'),
|
||||||
powerCapacity: text('power_capacity'),
|
powerCapacity: numeric('power_capacity'), // kW
|
||||||
voltage: text('voltage'),
|
voltage: numeric('voltage'), // V at 60Hz
|
||||||
mooringType: text('mooring_type'),
|
mooringType: text('mooring_type'),
|
||||||
cleatType: text('cleat_type'),
|
cleatType: text('cleat_type'),
|
||||||
cleatCapacity: text('cleat_capacity'),
|
cleatCapacity: text('cleat_capacity'),
|
||||||
@@ -58,6 +59,9 @@ export const berths = pgTable(
|
|||||||
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
statusLastChangedBy: text('status_last_changed_by'), // user ID
|
||||||
statusLastChangedReason: text('status_last_changed_reason'),
|
statusLastChangedReason: text('status_last_changed_reason'),
|
||||||
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
|
statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
|
||||||
|
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
|
||||||
|
// Reserved for future "manual override" semantics; not surfaced in the UI today.
|
||||||
|
statusOverrideMode: text('status_override_mode'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
* Exports `seedPortData(portId, portSlug)` — creates a realistic,
|
* Exports `seedPortData(portId, portSlug)` — creates a realistic,
|
||||||
* multi-cardinality data fixture for one port:
|
* multi-cardinality data fixture for one port:
|
||||||
*
|
*
|
||||||
* - 12 berths (5 available / 5 reserved-active / 2 sold)
|
* - 117 berths imported from a snapshot of the legacy NocoDB Berths
|
||||||
|
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
|
||||||
|
* so the first 12 entries satisfy the index assumptions used further
|
||||||
|
* down for interest/reservation linkage:
|
||||||
|
* idx 0..4 — available (small)
|
||||||
|
* idx 5..9 — under_offer (medium)
|
||||||
|
* idx 10..11 — sold (large)
|
||||||
* - 3 companies (2 active, 1 dissolved) with primary billing addresses
|
* - 3 companies (2 active, 1 dissolved) with primary billing addresses
|
||||||
* - 8 clients + contacts + primary addresses
|
* - 8 clients + contacts + primary addresses
|
||||||
* - Memberships tying clients to companies (incl. multi-company + ended)
|
* - Memberships tying clients to companies (incl. multi-company + ended)
|
||||||
@@ -39,6 +45,44 @@ import {
|
|||||||
getStandardEoiTemplateHtml,
|
getStandardEoiTemplateHtml,
|
||||||
STANDARD_EOI_MERGE_FIELDS,
|
STANDARD_EOI_MERGE_FIELDS,
|
||||||
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
||||||
|
import berthSnapshot from './seed-data/berths.json';
|
||||||
|
|
||||||
|
// ─── Berth snapshot ──────────────────────────────────────────────────────────
|
||||||
|
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
|
||||||
|
// Refresh by re-running the snapshot script (see git history of this file).
|
||||||
|
type SeedBerth = {
|
||||||
|
legacyId: number;
|
||||||
|
mooringNumber: string;
|
||||||
|
legacyMooringNumber: string;
|
||||||
|
area: string | null;
|
||||||
|
status: 'available' | 'under_offer' | 'sold';
|
||||||
|
lengthFt: number | null;
|
||||||
|
widthFt: number | null;
|
||||||
|
draftFt: number | null;
|
||||||
|
lengthM: number | null;
|
||||||
|
widthM: number | null;
|
||||||
|
draftM: number | null;
|
||||||
|
widthIsMinimum: boolean;
|
||||||
|
nominalBoatSize: number | null;
|
||||||
|
nominalBoatSizeM: number | null;
|
||||||
|
waterDepth: number | null;
|
||||||
|
waterDepthM: number | null;
|
||||||
|
waterDepthIsMinimum: boolean;
|
||||||
|
sidePontoon: string | null;
|
||||||
|
powerCapacity: number | null;
|
||||||
|
voltage: number | null;
|
||||||
|
mooringType: string | null;
|
||||||
|
cleatType: string | null;
|
||||||
|
cleatCapacity: string | null;
|
||||||
|
bollardType: string | null;
|
||||||
|
bollardCapacity: string | null;
|
||||||
|
access: string | null;
|
||||||
|
price: number | null;
|
||||||
|
bowFacing: string | null;
|
||||||
|
berthApproved: boolean;
|
||||||
|
statusOverrideMode: string | null;
|
||||||
|
};
|
||||||
|
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
|
||||||
|
|
||||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
|||||||
|
|
||||||
return withTransaction(async (tx) => {
|
return withTransaction(async (tx) => {
|
||||||
// ── 1. Berths ──────────────────────────────────────────────────────────
|
// ── 1. Berths ──────────────────────────────────────────────────────────
|
||||||
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold.
|
// 117 berths seeded from the legacy NocoDB Berths snapshot.
|
||||||
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation")
|
// The JSON file is pre-sorted so the first 12 indexes satisfy the
|
||||||
// and 10..11 as 'sold'; 0..4 remain 'available'.
|
// status semantics expected by the interest/reservation seeds:
|
||||||
const BERTH_SPECS: Array<{
|
// idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
|
||||||
mooring: string;
|
|
||||||
area: string;
|
|
||||||
lengthM: string;
|
|
||||||
widthM: string;
|
|
||||||
draftM: string;
|
|
||||||
price: string;
|
|
||||||
status: 'available' | 'under_offer' | 'sold';
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
mooring: 'A-01',
|
|
||||||
area: 'North Pier',
|
|
||||||
lengthM: '15',
|
|
||||||
widthM: '5',
|
|
||||||
draftM: '2.5',
|
|
||||||
price: '250000',
|
|
||||||
status: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'A-02',
|
|
||||||
area: 'North Pier',
|
|
||||||
lengthM: '18',
|
|
||||||
widthM: '5.5',
|
|
||||||
draftM: '2.8',
|
|
||||||
price: '320000',
|
|
||||||
status: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'A-03',
|
|
||||||
area: 'North Pier',
|
|
||||||
lengthM: '20',
|
|
||||||
widthM: '6',
|
|
||||||
draftM: '3.0',
|
|
||||||
price: '420000',
|
|
||||||
status: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'B-01',
|
|
||||||
area: 'Central Basin',
|
|
||||||
lengthM: '25',
|
|
||||||
widthM: '7',
|
|
||||||
draftM: '3.5',
|
|
||||||
price: '580000',
|
|
||||||
status: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'B-02',
|
|
||||||
area: 'Central Basin',
|
|
||||||
lengthM: '30',
|
|
||||||
widthM: '8',
|
|
||||||
draftM: '4.0',
|
|
||||||
price: '780000',
|
|
||||||
status: 'available',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'B-03',
|
|
||||||
area: 'Central Basin',
|
|
||||||
lengthM: '35',
|
|
||||||
widthM: '8.5',
|
|
||||||
draftM: '4.2',
|
|
||||||
price: '950000',
|
|
||||||
status: 'under_offer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'C-01',
|
|
||||||
area: 'South Marina',
|
|
||||||
lengthM: '40',
|
|
||||||
widthM: '9',
|
|
||||||
draftM: '4.5',
|
|
||||||
price: '1250000',
|
|
||||||
status: 'under_offer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'C-02',
|
|
||||||
area: 'South Marina',
|
|
||||||
lengthM: '45',
|
|
||||||
widthM: '10',
|
|
||||||
draftM: '4.8',
|
|
||||||
price: '1600000',
|
|
||||||
status: 'under_offer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'C-03',
|
|
||||||
area: 'South Marina',
|
|
||||||
lengthM: '50',
|
|
||||||
widthM: '11',
|
|
||||||
draftM: '5.0',
|
|
||||||
price: '2100000',
|
|
||||||
status: 'under_offer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'D-01',
|
|
||||||
area: 'Superyacht Dock',
|
|
||||||
lengthM: '60',
|
|
||||||
widthM: '13',
|
|
||||||
draftM: '5.5',
|
|
||||||
price: '3200000',
|
|
||||||
status: 'under_offer',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'D-02',
|
|
||||||
area: 'Superyacht Dock',
|
|
||||||
lengthM: '70',
|
|
||||||
widthM: '14',
|
|
||||||
draftM: '6.0',
|
|
||||||
price: '4500000',
|
|
||||||
status: 'sold',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mooring: 'D-03',
|
|
||||||
area: 'Superyacht Dock',
|
|
||||||
lengthM: '80',
|
|
||||||
widthM: '15',
|
|
||||||
draftM: '6.5',
|
|
||||||
price: '6800000',
|
|
||||||
status: 'sold',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const berthRows = await tx
|
const berthRows = await tx
|
||||||
.insert(berths)
|
.insert(berths)
|
||||||
.values(
|
.values(
|
||||||
BERTH_SPECS.map((b) => ({
|
BERTH_SNAPSHOT.map((b) => ({
|
||||||
portId,
|
portId,
|
||||||
mooringNumber: b.mooring,
|
mooringNumber: b.mooringNumber,
|
||||||
area: b.area,
|
area: b.area,
|
||||||
status: b.status,
|
status: b.status,
|
||||||
lengthM: b.lengthM,
|
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
|
||||||
widthM: b.widthM,
|
widthFt: b.widthFt != null ? String(b.widthFt) : null,
|
||||||
draftM: b.draftM,
|
draftFt: b.draftFt != null ? String(b.draftFt) : null,
|
||||||
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2),
|
lengthM: b.lengthM != null ? String(b.lengthM) : null,
|
||||||
widthFt: (Number(b.widthM) * 3.28084).toFixed(2),
|
widthM: b.widthM != null ? String(b.widthM) : null,
|
||||||
draftFt: (Number(b.draftM) * 3.28084).toFixed(2),
|
draftM: b.draftM != null ? String(b.draftM) : null,
|
||||||
price: b.price,
|
widthIsMinimum: b.widthIsMinimum,
|
||||||
|
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
|
||||||
|
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
|
||||||
|
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
|
||||||
|
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
|
||||||
|
waterDepthIsMinimum: b.waterDepthIsMinimum,
|
||||||
|
sidePontoon: b.sidePontoon,
|
||||||
|
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
|
||||||
|
voltage: b.voltage != null ? String(b.voltage) : null,
|
||||||
|
mooringType: b.mooringType,
|
||||||
|
cleatType: b.cleatType,
|
||||||
|
cleatCapacity: b.cleatCapacity,
|
||||||
|
bollardType: b.bollardType,
|
||||||
|
bollardCapacity: b.bollardCapacity,
|
||||||
|
access: b.access,
|
||||||
|
price: b.price != null ? String(b.price) : null,
|
||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
|
bowFacing: b.bowFacing,
|
||||||
|
berthApproved: b.berthApproved,
|
||||||
|
statusOverrideMode: b.statusOverrideMode,
|
||||||
tenureType: 'permanent' as const,
|
tenureType: 'permanent' as const,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
3746
src/lib/db/seed-data/berths.json
Normal file
3746
src/lib/db/seed-data/berths.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,16 @@
|
|||||||
* Seed script for Port Nimara CRM.
|
* Seed script for Port Nimara CRM.
|
||||||
*
|
*
|
||||||
* Top-level orchestrator:
|
* Top-level orchestrator:
|
||||||
* 1. Create 3 ports (idempotent):
|
* 1. Create the operational ports (idempotent):
|
||||||
* - Port Nimara
|
* - Port Nimara (primary install — the real marina)
|
||||||
* - Marina Azzurra
|
* - Port Amador (secondary, kept for multi-tenant isolation tests
|
||||||
* - Harbor Royale
|
* and as scaffolding for a future Panama install)
|
||||||
* 2. Create 5 system roles with full permission maps
|
* 2. Create 5 system roles with full permission maps
|
||||||
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
* 3. Create the super admin user profile placeholder (matt@portnimara.com)
|
||||||
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
|
||||||
* to produce the realistic multi-cardinality fixture
|
* to produce the realistic multi-cardinality fixture
|
||||||
* (berths, clients, companies, yachts, memberships, interests,
|
* (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
|
||||||
* reservations, ownership-transfer history).
|
* memberships, interests, reservations, ownership-transfer history).
|
||||||
* 5. Print a summary.
|
* 5. Print a summary.
|
||||||
*
|
*
|
||||||
* Run with: pnpm db:seed
|
* Run with: pnpm db:seed
|
||||||
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
|||||||
generate_eoi: true,
|
generate_eoi: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
|||||||
generate_eoi: true,
|
generate_eoi: true,
|
||||||
export: true,
|
export: true,
|
||||||
},
|
},
|
||||||
berths: { view: true, edit: false, import: false, manage_waiting_list: true },
|
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||||
documents: {
|
documents: {
|
||||||
view: true,
|
view: true,
|
||||||
create: true,
|
create: true,
|
||||||
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
|
|||||||
defaultCurrency: 'USD',
|
defaultCurrency: 'USD',
|
||||||
timezone: 'America/Anguilla',
|
timezone: 'America/Anguilla',
|
||||||
},
|
},
|
||||||
|
// Second port kept for multi-tenant isolation tests (cross-port scoping,
|
||||||
|
// permission boundaries). Drop or rename if the production install is
|
||||||
|
// single-port.
|
||||||
{
|
{
|
||||||
name: 'Marina Azzurra',
|
name: 'Port Amador',
|
||||||
slug: 'marina-azzurra',
|
slug: 'port-amador',
|
||||||
primaryColor: '#2E86AB',
|
primaryColor: '#D97706',
|
||||||
defaultCurrency: 'EUR',
|
defaultCurrency: 'USD',
|
||||||
timezone: 'Europe/Rome',
|
timezone: 'America/Panama',
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Harbor Royale',
|
|
||||||
slug: 'harbor-royale',
|
|
||||||
primaryColor: '#8B1E3F',
|
|
||||||
defaultCurrency: 'GBP',
|
|
||||||
timezone: 'Europe/London',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -58,11 +58,10 @@ export function buildPipelineInputs(
|
|||||||
'open',
|
'open',
|
||||||
'details_sent',
|
'details_sent',
|
||||||
'in_communication',
|
'in_communication',
|
||||||
'eoi_sent',
|
'visited',
|
||||||
'eoi_signed',
|
'signed_eoi_nda',
|
||||||
'deposit_10pct',
|
'deposit_10pct',
|
||||||
'contract_sent',
|
'contract',
|
||||||
'contract_signed',
|
|
||||||
'completed',
|
'completed',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -74,7 +73,9 @@ export function buildPipelineInputs(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Include stages not in standard order
|
// Include stages not in standard order
|
||||||
const unknownStages = Object.keys(data.stageCounts).filter((s) => !stageOrder.includes(s));
|
const unknownStages = Object.keys(data.stageCounts).filter(
|
||||||
|
(s) => !stageOrder.includes(s),
|
||||||
|
);
|
||||||
for (const stage of unknownStages) {
|
for (const stage of unknownStages) {
|
||||||
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
|
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,16 +50,18 @@ export const revenueReportTemplate: Template = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildRevenueInputs(data: RevenueData, portName?: string): Record<string, string>[] {
|
export function buildRevenueInputs(
|
||||||
|
data: RevenueData,
|
||||||
|
portName?: string,
|
||||||
|
): Record<string, string>[] {
|
||||||
const stageOrder = [
|
const stageOrder = [
|
||||||
'open',
|
'open',
|
||||||
'details_sent',
|
'details_sent',
|
||||||
'in_communication',
|
'in_communication',
|
||||||
'eoi_sent',
|
'visited',
|
||||||
'eoi_signed',
|
'signed_eoi_nda',
|
||||||
'deposit_10pct',
|
'deposit_10pct',
|
||||||
'contract_sent',
|
'contract',
|
||||||
'contract_signed',
|
|
||||||
'completed',
|
'completed',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -180,14 +180,14 @@ export async function updateBerth(
|
|||||||
draftFt: n(data.draftFt),
|
draftFt: n(data.draftFt),
|
||||||
draftM: n(data.draftM),
|
draftM: n(data.draftM),
|
||||||
widthIsMinimum: data.widthIsMinimum,
|
widthIsMinimum: data.widthIsMinimum,
|
||||||
nominalBoatSize: data.nominalBoatSize,
|
nominalBoatSize: n(data.nominalBoatSize),
|
||||||
nominalBoatSizeM: data.nominalBoatSizeM,
|
nominalBoatSizeM: n(data.nominalBoatSizeM),
|
||||||
waterDepth: n(data.waterDepth),
|
waterDepth: n(data.waterDepth),
|
||||||
waterDepthM: n(data.waterDepthM),
|
waterDepthM: n(data.waterDepthM),
|
||||||
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
waterDepthIsMinimum: data.waterDepthIsMinimum,
|
||||||
sidePontoon: data.sidePontoon,
|
sidePontoon: data.sidePontoon,
|
||||||
powerCapacity: data.powerCapacity,
|
powerCapacity: n(data.powerCapacity),
|
||||||
voltage: data.voltage,
|
voltage: n(data.voltage),
|
||||||
mooringType: data.mooringType,
|
mooringType: data.mooringType,
|
||||||
cleatType: data.cleatType,
|
cleatType: data.cleatType,
|
||||||
cleatCapacity: data.cleatCapacity,
|
cleatCapacity: data.cleatCapacity,
|
||||||
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
|||||||
priceCurrency: data.priceCurrency ?? 'USD',
|
priceCurrency: data.priceCurrency ?? 'USD',
|
||||||
tenureType: data.tenureType ?? 'permanent',
|
tenureType: data.tenureType ?? 'permanent',
|
||||||
mooringType: data.mooringType,
|
mooringType: data.mooringType,
|
||||||
powerCapacity: data.powerCapacity,
|
powerCapacity: data.powerCapacity?.toString(),
|
||||||
voltage: data.voltage,
|
voltage: data.voltage?.toString(),
|
||||||
access: data.access,
|
access: data.access,
|
||||||
bowFacing: data.bowFacing,
|
bowFacing: data.bowFacing,
|
||||||
sidePontoon: data.sidePontoon,
|
sidePontoon: data.sidePontoon,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
|
||||||
import { tags } from '@/lib/db/schema/system';
|
import { tags } from '@/lib/db/schema/system';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
@@ -83,7 +81,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
|
|
||||||
const ids = result.data.map((r) => r.id);
|
const ids = result.data.map((r) => r.id);
|
||||||
|
|
||||||
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
|
const [yachtCounts, companyCounts] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||||
.from(yachts)
|
.from(yachts)
|
||||||
@@ -101,67 +99,18 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
.from(companyMemberships)
|
.from(companyMemberships)
|
||||||
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
|
||||||
.groupBy(companyMemberships.clientId),
|
.groupBy(companyMemberships.clientId),
|
||||||
db
|
|
||||||
.select({
|
|
||||||
clientId: interests.clientId,
|
|
||||||
pipelineStage: interests.pipelineStage,
|
|
||||||
updatedAt: interests.updatedAt,
|
|
||||||
mooringNumber: berths.mooringNumber,
|
|
||||||
})
|
|
||||||
.from(interests)
|
|
||||||
.leftJoin(berths, eq(berths.id, interests.berthId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interests.portId, portId),
|
|
||||||
inArray(interests.clientId, ids),
|
|
||||||
isNull(interests.archivedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(desc(interests.updatedAt)),
|
|
||||||
db
|
|
||||||
.select({ clientId: interests.clientId, count: count() })
|
|
||||||
.from(interests)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(interests.portId, portId),
|
|
||||||
inArray(interests.clientId, ids),
|
|
||||||
isNull(interests.archivedAt),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(interests.clientId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||||
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
|
|
||||||
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
|
|
||||||
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
|
|
||||||
for (const row of interestRows) {
|
|
||||||
if (!latestInterestMap.has(row.clientId)) {
|
|
||||||
latestInterestMap.set(row.clientId, {
|
|
||||||
stage: row.pipelineStage,
|
|
||||||
mooringNumber: row.mooringNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data.map((row) => {
|
data: result.data.map((row) => ({
|
||||||
const latest = latestInterestMap.get(row.id);
|
|
||||||
return {
|
|
||||||
...row,
|
...row,
|
||||||
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
yachtCount: yachtCountMap.get(row.id) ?? 0,
|
||||||
companyCount: companyCountMap.get(row.id) ?? 0,
|
companyCount: companyCountMap.get(row.id) ?? 0,
|
||||||
interestCount: interestCountMap.get(row.id) ?? 0,
|
})),
|
||||||
latestInterest: latest
|
|
||||||
? {
|
|
||||||
stage: latest.stage,
|
|
||||||
mooringNumber: latest.mooringNumber,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
|
|||||||
status: z.enum(BERTH_STATUSES).default('available'),
|
status: z.enum(BERTH_STATUSES).default('available'),
|
||||||
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
tenureType: z.enum(['permanent', 'fixed_term']).optional(),
|
||||||
mooringType: z.string().optional(),
|
mooringType: z.string().optional(),
|
||||||
powerCapacity: z.string().optional(),
|
powerCapacity: z.coerce.number().optional(), // kW
|
||||||
voltage: z.string().optional(),
|
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||||
access: z.string().optional(),
|
access: z.string().optional(),
|
||||||
bowFacing: z.string().optional(),
|
bowFacing: z.string().optional(),
|
||||||
sidePontoon: z.string().optional(),
|
sidePontoon: z.string().optional(),
|
||||||
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
|
|||||||
draftFt: z.coerce.number().optional(),
|
draftFt: z.coerce.number().optional(),
|
||||||
draftM: z.coerce.number().optional(),
|
draftM: z.coerce.number().optional(),
|
||||||
widthIsMinimum: z.boolean().optional(),
|
widthIsMinimum: z.boolean().optional(),
|
||||||
nominalBoatSize: z.string().optional(),
|
nominalBoatSize: z.coerce.number().optional(), // ft
|
||||||
nominalBoatSizeM: z.string().optional(),
|
nominalBoatSizeM: z.coerce.number().optional(), // m
|
||||||
waterDepth: z.coerce.number().optional(),
|
waterDepth: z.coerce.number().optional(),
|
||||||
waterDepthM: z.coerce.number().optional(),
|
waterDepthM: z.coerce.number().optional(),
|
||||||
waterDepthIsMinimum: z.boolean().optional(),
|
waterDepthIsMinimum: z.boolean().optional(),
|
||||||
sidePontoon: z.string().optional(),
|
sidePontoon: z.string().optional(),
|
||||||
powerCapacity: z.string().optional(),
|
powerCapacity: z.coerce.number().optional(), // kW
|
||||||
voltage: z.string().optional(),
|
voltage: z.coerce.number().optional(), // V at 60Hz
|
||||||
mooringType: z.string().optional(),
|
mooringType: z.string().optional(),
|
||||||
cleatType: z.string().optional(),
|
cleatType: z.string().optional(),
|
||||||
cleatCapacity: z.string().optional(),
|
cleatCapacity: z.string().optional(),
|
||||||
|
|||||||
@@ -635,11 +635,10 @@ export function makeCreateInterestInput(overrides?: {
|
|||||||
| 'open'
|
| 'open'
|
||||||
| 'details_sent'
|
| 'details_sent'
|
||||||
| 'in_communication'
|
| 'in_communication'
|
||||||
| 'eoi_sent'
|
| 'visited'
|
||||||
| 'eoi_signed'
|
| 'signed_eoi_nda'
|
||||||
| 'deposit_10pct'
|
| 'deposit_10pct'
|
||||||
| 'contract_sent'
|
| 'contract'
|
||||||
| 'contract_signed'
|
|
||||||
| 'completed';
|
| 'completed';
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ describe('alert engine', () => {
|
|||||||
await db.insert(interests).values({
|
await db.insert(interests).values({
|
||||||
portId: port.id,
|
portId: port.id,
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
pipelineStage: 'in_communication',
|
pipelineStage: 'visited',
|
||||||
leadCategory: 'hot_lead',
|
leadCategory: 'hot_lead',
|
||||||
dateLastContact: stale,
|
dateLastContact: stale,
|
||||||
updatedAt: stale,
|
updatedAt: stale,
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ describe('Pipeline Transitions', () => {
|
|||||||
await import('@/lib/services/interests.service');
|
await import('@/lib/services/interests.service');
|
||||||
const meta = makeAuditMeta({ portId });
|
const meta = makeAuditMeta({ portId });
|
||||||
|
|
||||||
await changeInterestStage(interestId, portId, { pipelineStage: 'eoi_signed' }, meta);
|
await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta);
|
||||||
|
|
||||||
const updated = await getInterestById(interestId, portId);
|
const updated = await getInterestById(interestId, portId);
|
||||||
expect(updated.dateEoiSigned).not.toBeNull();
|
expect(updated.dateEoiSigned).not.toBeNull();
|
||||||
@@ -181,7 +181,7 @@ describe('Pipeline Transitions', () => {
|
|||||||
await import('@/lib/services/interests.service');
|
await import('@/lib/services/interests.service');
|
||||||
const meta = makeAuditMeta({ portId });
|
const meta = makeAuditMeta({ portId });
|
||||||
|
|
||||||
await changeInterestStage(interestId, portId, { pipelineStage: 'contract_signed' }, meta);
|
await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta);
|
||||||
|
|
||||||
const updated = await getInterestById(interestId, portId);
|
const updated = await getInterestById(interestId, portId);
|
||||||
expect(updated.dateContractSigned).not.toBeNull();
|
expect(updated.dateContractSigned).not.toBeNull();
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe('calculateInterestScore', () => {
|
|||||||
portId: 'p1',
|
portId: 'p1',
|
||||||
clientId: 'c1',
|
clientId: 'c1',
|
||||||
createdAt: daysAgo(10),
|
createdAt: daysAgo(10),
|
||||||
pipelineStage: 'contract_signed',
|
pipelineStage: 'contract',
|
||||||
eoiStatus: 'signed',
|
eoiStatus: 'signed',
|
||||||
contractStatus: 'signed',
|
contractStatus: 'signed',
|
||||||
depositStatus: 'received',
|
depositStatus: 'received',
|
||||||
|
|||||||
Reference in New Issue
Block a user