feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.
Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
three doc-status columns, two documenso-id columns, and
date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
interest_qualifications (per-interest state), payments (deposit /
balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
the new stage + doc-status + outcome shape.
Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).
v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
the contact-log compose dialog (useVoiceTranscription hook).
- C: berth-rules-engine wraps state writes in pg_advisory_xact_lock
with an idempotent re-read; emits rule_evaluated audit traces.
- D: Documenso webhook: reservation/contract sub-status stamping
moved out of the PDF-download try-block so a download failure
no longer swallows the stamp. New integration test coverage.
- E: /admin/qualification-criteria CRUD page + admin component.
- F: default_new_interest_owner exposed in System Settings.
- G: recentActivityCount + active_engagement deal-pulse signal
surfaced as a chip on interests + hot-deals card.
- H: interest_assigned notification on assignedTo change (skips
self-assign, uses a dedupe key).
Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.
Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,27 +3,34 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
Activity,
|
||||
BarChart3,
|
||||
BellRing,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Database,
|
||||
ClipboardList,
|
||||
CopyCheck,
|
||||
DatabaseBackup,
|
||||
FilePen,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Globe,
|
||||
HardDrive,
|
||||
FileUp,
|
||||
Inbox,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Mail,
|
||||
Palette,
|
||||
MailPlus,
|
||||
Paintbrush,
|
||||
ScrollText,
|
||||
Search,
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Sliders,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Upload,
|
||||
TrendingUp,
|
||||
Users,
|
||||
UsersRound,
|
||||
Webhook,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -83,7 +90,7 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'invitations',
|
||||
label: 'Invitations',
|
||||
description: 'Send invitations, track pending invites, and resend or revoke them.',
|
||||
icon: Mail,
|
||||
icon: MailPlus,
|
||||
},
|
||||
{
|
||||
href: 'roles',
|
||||
@@ -108,19 +115,19 @@ const GROUPS: AdminGroup[] = [
|
||||
label: 'EOI signing service',
|
||||
description:
|
||||
'API credentials, EOI template, and default in-app vs external signing pathway.',
|
||||
icon: FileText,
|
||||
icon: FileSignature,
|
||||
},
|
||||
{
|
||||
href: 'reminders',
|
||||
label: 'Reminders',
|
||||
description: 'Default reminder behaviour and the daily-digest delivery window.',
|
||||
icon: Bell,
|
||||
icon: BellRing,
|
||||
},
|
||||
{
|
||||
href: 'branding',
|
||||
label: 'Branding',
|
||||
description: 'App name, logo, primary color, and email header/footer HTML.',
|
||||
icon: Palette,
|
||||
icon: Paintbrush,
|
||||
},
|
||||
{
|
||||
href: 'settings',
|
||||
@@ -183,7 +190,7 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'forms',
|
||||
label: 'Forms',
|
||||
description: 'Form templates used by client-facing inquiry and intake flows.',
|
||||
icon: Sliders,
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
href: 'templates',
|
||||
@@ -195,7 +202,7 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'email-templates',
|
||||
label: 'Email Templates',
|
||||
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
|
||||
icon: Mail,
|
||||
icon: FilePen,
|
||||
},
|
||||
{
|
||||
href: 'tags',
|
||||
@@ -214,7 +221,7 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'custom-fields',
|
||||
label: 'Custom Fields',
|
||||
description: 'Tenant-defined fields for clients, yachts, and reservations.',
|
||||
icon: Key,
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -233,19 +240,19 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'sends',
|
||||
label: 'Send Log',
|
||||
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
|
||||
icon: Mail,
|
||||
icon: Send,
|
||||
},
|
||||
{
|
||||
href: 'duplicates',
|
||||
label: 'Duplicates',
|
||||
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
|
||||
icon: UsersRound,
|
||||
icon: CopyCheck,
|
||||
},
|
||||
{
|
||||
href: 'import',
|
||||
label: 'Bulk Import',
|
||||
description: 'CSV-driven imports for clients, yachts, and reservations.',
|
||||
icon: Upload,
|
||||
icon: FileUp,
|
||||
},
|
||||
{
|
||||
href: 'audit',
|
||||
@@ -263,26 +270,26 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'reports',
|
||||
label: 'Reports',
|
||||
description: 'Saved analytics views and ad-hoc query results.',
|
||||
icon: LayoutDashboard,
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
href: 'monitoring',
|
||||
label: 'Queue Monitoring',
|
||||
description: 'BullMQ queue health, throughput, and retry diagnostics.',
|
||||
icon: Database,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: 'backup',
|
||||
label: 'Backup & Restore',
|
||||
description: 'Backup posture + retention policy (read-only).',
|
||||
icon: HardDrive,
|
||||
icon: DatabaseBackup,
|
||||
},
|
||||
{
|
||||
href: 'storage',
|
||||
label: 'Storage Backend',
|
||||
description:
|
||||
'Choose between S3-compatible object store or local filesystem; migrate between them.',
|
||||
icon: HardDrive,
|
||||
icon: Server,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -294,14 +301,14 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'ports',
|
||||
label: 'Ports',
|
||||
description: 'Manage the marinas/ports this installation serves.',
|
||||
icon: Briefcase,
|
||||
icon: Ship,
|
||||
},
|
||||
{
|
||||
href: 'onboarding',
|
||||
label: 'Onboarding checklist',
|
||||
description:
|
||||
'Step-by-step setup checklist for fresh ports — auto-detects what you’ve configured and lets you mark manual steps complete.',
|
||||
icon: LayoutDashboard,
|
||||
icon: ListChecks,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -313,22 +320,28 @@ const GROUPS: AdminGroup[] = [
|
||||
href: 'ai',
|
||||
label: 'AI configuration',
|
||||
description:
|
||||
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
|
||||
icon: ScrollText,
|
||||
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
|
||||
},
|
||||
{
|
||||
href: 'ocr',
|
||||
label: 'Receipt OCR (per-feature)',
|
||||
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
|
||||
icon: ScrollText,
|
||||
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
|
||||
'Master switch, provider credentials, and the Receipt OCR settings in one place. Per-feature pages (berth-PDF parser, recommender) link out from here.',
|
||||
icon: Sparkles,
|
||||
keywords: [
|
||||
'openai',
|
||||
'anthropic',
|
||||
'gpt',
|
||||
'claude',
|
||||
'llm',
|
||||
'api key',
|
||||
'embeddings',
|
||||
'receipt',
|
||||
'scan',
|
||||
'tesseract',
|
||||
'ocr',
|
||||
'expense scanner',
|
||||
],
|
||||
},
|
||||
{
|
||||
href: 'website-analytics',
|
||||
label: 'Website analytics (Umami)',
|
||||
description: 'Per-port Umami URL, API token, and Website ID.',
|
||||
icon: Globe,
|
||||
icon: TrendingUp,
|
||||
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
|
||||
},
|
||||
{
|
||||
@@ -336,9 +349,17 @@ const GROUPS: AdminGroup[] = [
|
||||
label: 'Residential pipeline stages',
|
||||
description:
|
||||
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
|
||||
icon: ScrollText,
|
||||
icon: ListChecks,
|
||||
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
|
||||
},
|
||||
{
|
||||
href: 'qualification-criteria',
|
||||
label: 'Qualification criteria',
|
||||
description:
|
||||
'Checklist reps complete to qualify an enquiry. Enable/disable/reorder per port; affects the soft "ready to qualify" hint on the interest detail.',
|
||||
icon: ListChecks,
|
||||
keywords: ['qualification', 'criteria', 'checklist', 'qualify', 'enquiry', 'sales gate'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -314,17 +314,19 @@ function SettingsBlockBody({
|
||||
);
|
||||
}
|
||||
|
||||
export function OcrSettingsForm() {
|
||||
export function OcrSettingsForm({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { isSuperAdmin } = usePermissions();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Receipt OCR"
|
||||
eyebrow="Admin"
|
||||
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
|
||||
variant="gradient"
|
||||
/>
|
||||
{embedded ? null : (
|
||||
<PageHeader
|
||||
title="Receipt OCR"
|
||||
eyebrow="Admin"
|
||||
description="Receipts are scanned on-device by default. Optionally configure an AI provider for higher-accuracy parsing on tricky receipts."
|
||||
variant="gradient"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsBlock
|
||||
scope="port"
|
||||
|
||||
341
src/components/admin/qualification-criteria-admin.tsx
Normal file
341
src/components/admin/qualification-criteria-admin.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Save } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CriterionRow {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
data: CriterionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-port qualification-criteria admin. Lists current criteria, add via
|
||||
* the dialog, toggle enabled inline, drag-style reorder via up/down buttons
|
||||
* (keeps the UI simple for v1; can swap to a real DnD later if reps want it).
|
||||
*/
|
||||
export function QualificationCriteriaAdmin() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
queryKey: ['qualification-criteria'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/qualification-criteria'),
|
||||
});
|
||||
const criteria = data?.data ?? [];
|
||||
|
||||
const toggleEnabled = useMutation({
|
||||
mutationFn: async (vars: { id: string; enabled: boolean }) =>
|
||||
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { enabled: vars.enabled },
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const reorder = useMutation({
|
||||
mutationFn: async (vars: { id: string; displayOrder: number }) =>
|
||||
apiFetch(`/api/v1/admin/qualification-criteria/${vars.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { displayOrder: vars.displayOrder },
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const deleteCriterion = useMutation({
|
||||
mutationFn: async (id: string) =>
|
||||
apiFetch(`/api/v1/admin/qualification-criteria/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading criteria…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{criteria.length} criteria configured · {criteria.filter((c) => c.enabled).length} enabled
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)} className="gap-1.5">
|
||||
<Plus className="size-4" aria-hidden />
|
||||
Add criterion
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{criteria.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-8 text-center">
|
||||
<p className="text-sm font-medium">No criteria configured yet.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add the first criterion the rep needs to confirm before a deal can be qualified.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border">
|
||||
{criteria.map((c, idx) => {
|
||||
const isFirst = idx === 0;
|
||||
const isLast = idx === criteria.length - 1;
|
||||
return (
|
||||
<li key={c.id} className="flex items-start gap-3 p-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Move up"
|
||||
disabled={isFirst || reorder.isPending}
|
||||
onClick={() =>
|
||||
reorder.mutate({ id: c.id, displayOrder: Math.max(0, c.displayOrder - 1) })
|
||||
}
|
||||
className={cn(
|
||||
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Move down"
|
||||
disabled={isLast || reorder.isPending}
|
||||
onClick={() => reorder.mutate({ id: c.id, displayOrder: c.displayOrder + 1 })}
|
||||
className={cn(
|
||||
'rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<CriterionEditableRow
|
||||
criterion={c}
|
||||
onToggleEnabled={(enabled) => toggleEnabled.mutate({ id: c.id, enabled })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete criterion"
|
||||
disabled={deleteCriterion.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Delete criterion "${c.label}"? Per-interest state rows for this key will become orphaned (hidden from the UI but kept in audit history).`,
|
||||
)
|
||||
) {
|
||||
deleteCriterion.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
className="ml-auto rounded p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<CreateCriterionDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CriterionEditableRow({
|
||||
criterion,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
criterion: CriterionRow;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [label, setLabel] = useState(criterion.label);
|
||||
const [description, setDescription] = useState(criterion.description ?? '');
|
||||
const isDirty =
|
||||
label.trim() !== criterion.label || (description.trim() || null) !== criterion.description;
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiFetch(`/api/v1/admin/qualification-criteria/${criterion.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { label: label.trim(), description: description.trim() || null },
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] }),
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="h-7 max-w-md text-sm font-medium"
|
||||
/>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{criterion.key}
|
||||
</code>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isDirty ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={save.isPending || label.trim().length === 0}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
<Save className="size-3" aria-hidden />
|
||||
Save
|
||||
</Button>
|
||||
) : null}
|
||||
<Switch
|
||||
checked={criterion.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
aria-label={criterion.enabled ? 'Disable' : 'Enable'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional helper text shown under the checkbox on the interest detail page."
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateCriterionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [key, setKey] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiFetch('/api/v1/admin/qualification-criteria', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
key: key.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || null,
|
||||
enabled,
|
||||
displayOrder: 999,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['qualification-criteria'] });
|
||||
onOpenChange(false);
|
||||
setKey('');
|
||||
setLabel('');
|
||||
setDescription('');
|
||||
setEnabled(true);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
key.trim().length > 0 &&
|
||||
/^[a-z][a-z0-9_]*$/.test(key.trim()) &&
|
||||
label.trim().length > 0 &&
|
||||
!mutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add qualification criterion</DialogTitle>
|
||||
<DialogDescription>
|
||||
The <strong>key</strong> is a stable identifier code references (lowercase alphanumeric
|
||||
+ underscores). It can't be changed once created — per-interest state rows
|
||||
reference it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="qc-key">Key</Label>
|
||||
<Input
|
||||
id="qc-key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value.toLowerCase())}
|
||||
placeholder="e.g. budget_confirmed"
|
||||
/>
|
||||
{key && !/^[a-z][a-z0-9_]*$/.test(key) ? (
|
||||
<p className="text-[11px] text-rose-700">
|
||||
Must start with a letter; lowercase alphanumeric and underscores only.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="qc-label">Label</Label>
|
||||
<Input
|
||||
id="qc-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Budget confirmed"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="qc-desc">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="qc-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Shown under the checkbox on the interest detail page."
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
Enabled by default
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!canSubmit} onClick={() => mutation.mutate()}>
|
||||
{mutation.isPending ? 'Adding…' : 'Add criterion'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -74,15 +74,13 @@ const KNOWN_SETTINGS: Array<{
|
||||
description: 'Probability weights for revenue forecast by pipeline stage (JSON)',
|
||||
type: 'json',
|
||||
defaultValue: {
|
||||
open: 0.05,
|
||||
details_sent: 0.1,
|
||||
in_communication: 0.2,
|
||||
eoi_sent: 0.4,
|
||||
eoi_signed: 0.6,
|
||||
deposit_10pct: 0.75,
|
||||
contract_sent: 0.85,
|
||||
contract_signed: 0.95,
|
||||
completed: 1.0,
|
||||
enquiry: 0.05,
|
||||
qualified: 0.15,
|
||||
nurturing: 0.15,
|
||||
eoi: 0.4,
|
||||
reservation: 0.7,
|
||||
deposit_paid: 0.85,
|
||||
contract: 0.95,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -92,6 +90,14 @@ const KNOWN_SETTINGS: Array<{
|
||||
type: 'json',
|
||||
defaultValue: [],
|
||||
},
|
||||
{
|
||||
key: 'default_new_interest_owner',
|
||||
label: 'Default New-Interest Owner',
|
||||
description:
|
||||
'User ID to auto-assign as the deal owner when a new interest is created. Stored as { "userId": "..." }. Leave blank to have new interests unassigned by default — the rep can pick an owner from the interest detail header.',
|
||||
type: 'json',
|
||||
defaultValue: { userId: null },
|
||||
},
|
||||
{
|
||||
key: 'inquiry_contact_email',
|
||||
label: 'Inquiry Contact Email',
|
||||
|
||||
@@ -35,6 +35,7 @@ export function BerthList() {
|
||||
setSort,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
setPage,
|
||||
setPageSize,
|
||||
@@ -62,35 +63,37 @@ export function BerthList() {
|
||||
// No "New" button - berths are import-only
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FilterBar
|
||||
// Search is hoisted out of the popover into the inline input
|
||||
// below — keeps the daily "find by mooring/area" lookup one
|
||||
// tap away instead of buried behind the Filters dropdown.
|
||||
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
inputMode="search"
|
||||
placeholder="Search mooring or area…"
|
||||
aria-label="Search berths"
|
||||
value={(filters.search as string | undefined) ?? ''}
|
||||
onChange={(e) => setFilter('search', e.target.value || undefined)}
|
||||
// flex-1 + min-w-0 lets the input expand to fill the row's
|
||||
// remaining width on mobile (where space is at a premium).
|
||||
// sm:max-w-xs caps it at 320px on desktop so it doesn't grow
|
||||
// absurdly wide on a 2k monitor.
|
||||
className="h-8 min-w-0 flex-1 sm:max-w-xs"
|
||||
/>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Toolbar — two halves separated by `justify-between` so the
|
||||
Columns + Saved-views actions stay pinned to the right edge of
|
||||
the row at every width. The previous `ml-auto` trick didn't
|
||||
survive flex-wrap on intermediate widths — the actions ended
|
||||
up centered. */}
|
||||
<div className="flex items-center gap-2 flex-wrap justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
|
||||
<FilterBar
|
||||
// Search is hoisted out of the popover into the inline input
|
||||
// below — keeps the daily "find by mooring/area" lookup one
|
||||
// tap away instead of buried behind the Filters dropdown.
|
||||
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
inputMode="search"
|
||||
placeholder="Search mooring or area…"
|
||||
aria-label="Search berths"
|
||||
value={(filters.search as string | undefined) ?? ''}
|
||||
onChange={(e) => setFilter('search', e.target.value || undefined)}
|
||||
className="h-8 min-w-0 flex-1 sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<SavedViewsDropdown
|
||||
entityType="berths"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
|
||||
setAllFilters(savedFilters);
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -78,6 +78,36 @@ function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags Card for the berth overview. Wraps the InlineTagEditor in a Card so
|
||||
* the section header uses CardTitle styling; mirrors the visibility rule
|
||||
* the editor itself uses — hides entirely when the port has no tags
|
||||
* defined AND this berth has none applied.
|
||||
*/
|
||||
function BerthTagsCard({ berth }: { berth: BerthData }) {
|
||||
const { data: allTags } = useQuery<{ data: { id: string; name: string; color: string }[] }>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => apiFetch('/api/v1/tags'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const portHasNoTags = allTags && allTags.data.length === 0;
|
||||
if (portHasNoTags && berth.tags.length === 0) return null;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/berths/${berth.id}/tags`}
|
||||
currentTags={berth.tags}
|
||||
invalidateKey={['berths', berth.id]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useBerthPatch(berthId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -382,18 +412,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/berths/${berth.id}/tags`}
|
||||
currentTags={berth.tags}
|
||||
invalidateKey={['berths', berth.id]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BerthTagsCard berth={berth} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive, Mail, MessageCircle, Phone } from 'lucide-react';
|
||||
import { MoreHorizontal, Pencil, Archive, Mail, Phone } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -160,7 +161,7 @@ export function getClientColumns({
|
||||
title={`WhatsApp ${value}`}
|
||||
aria-label={`WhatsApp ${value}`}
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
<WhatsAppIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Archive, Mail, MessageCircle, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { Archive, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -126,7 +127,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message ${primaryPhone} on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -236,7 +236,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-3 p-3 rounded-lg border bg-muted/30">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-start sm:gap-2">
|
||||
<div className="space-y-1 sm:col-span-3">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
|
||||
@@ -113,6 +113,9 @@ interface InterestDetail {
|
||||
dateDepositReceived: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
eoiDocStatus: string | null;
|
||||
reservationDocStatus: string | null;
|
||||
contractDocStatus: string | null;
|
||||
}
|
||||
|
||||
function useInterestDetail(id: string | null) {
|
||||
@@ -272,12 +275,16 @@ function InterestPreviewSheet({
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="EOI sent"
|
||||
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
|
||||
done={reached('eoi') || !!fullDetail?.dateEoiSent}
|
||||
date={formatDate(fullDetail?.dateEoiSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="EOI signed"
|
||||
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
|
||||
done={
|
||||
fullDetail?.eoiDocStatus === 'signed' ||
|
||||
stageIdx > PIPELINE_STAGES.indexOf('eoi') ||
|
||||
!!fullDetail?.dateEoiSigned
|
||||
}
|
||||
date={formatDate(fullDetail?.dateEoiSigned)}
|
||||
/>
|
||||
</ul>
|
||||
@@ -287,7 +294,7 @@ function InterestPreviewSheet({
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Deposit received"
|
||||
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
|
||||
done={reached('deposit_paid') || !!fullDetail?.dateDepositReceived}
|
||||
date={formatDate(fullDetail?.dateDepositReceived)}
|
||||
/>
|
||||
</ul>
|
||||
@@ -297,12 +304,14 @@ function InterestPreviewSheet({
|
||||
<ul>
|
||||
<MilestoneRow
|
||||
label="Contract sent"
|
||||
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
|
||||
done={reached('contract') || !!fullDetail?.dateContractSent}
|
||||
date={formatDate(fullDetail?.dateContractSent)}
|
||||
/>
|
||||
<MilestoneRow
|
||||
label="Contract signed"
|
||||
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
|
||||
done={
|
||||
fullDetail?.contractDocStatus === 'signed' || !!fullDetail?.dateContractSigned
|
||||
}
|
||||
date={formatDate(fullDetail?.dateContractSigned)}
|
||||
/>
|
||||
</ul>
|
||||
|
||||
@@ -78,6 +78,7 @@ export function ClientList() {
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<ClientRow>({
|
||||
queryKey: ['clients'],
|
||||
@@ -152,8 +153,11 @@ export function ClientList() {
|
||||
<SavedViewsDropdown
|
||||
entityType="clients"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
// Atomic replace — sequential setFilter() calls dropped all
|
||||
// but the last value (each one read stale `filters` from
|
||||
// closure and overwrote). setAllFilters writes the whole
|
||||
// saved view in one setState.
|
||||
setAllFilters(savedFilters);
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker
|
||||
|
||||
@@ -219,15 +219,12 @@ function OverviewTab({
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
</div>
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
endpoint={`/api/v1/clients/${clientId}/tags`}
|
||||
currentTags={client.tags ?? []}
|
||||
invalidateKey={['clients', clientId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Phone,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Loader2, Mail, MoreHorizontal, Phone, Plus, Star, Trash2 } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -51,7 +43,7 @@ const CHANNEL_OPTIONS = [
|
||||
const CHANNEL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
email: Mail,
|
||||
phone: Phone,
|
||||
whatsapp: MessageSquare,
|
||||
whatsapp: WhatsAppIcon,
|
||||
other: MoreHorizontal,
|
||||
};
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ export function CompanyList() {
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<CompanyRow>({
|
||||
queryKey: ['companies'],
|
||||
@@ -144,8 +145,7 @@ export function CompanyList() {
|
||||
<SavedViewsDropdown
|
||||
entityType="companies"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
setAllFilters(savedFilters);
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker columns={COMPANY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
|
||||
@@ -170,15 +170,13 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/companies/${companyId}/tags`}
|
||||
currentTags={company.tags ?? []}
|
||||
invalidateKey={['companies', companyId]}
|
||||
/>
|
||||
</div>
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/companies/${companyId}/tags`}
|
||||
currentTags={company.tags ?? []}
|
||||
invalidateKey={['companies', companyId]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,19 +24,17 @@ interface HotDealsResponse {
|
||||
}
|
||||
|
||||
// Local label map intentionally narrowed to the stages this widget
|
||||
// surfaces. Keys MUST match the canonical DB values (deposit_10pct +
|
||||
// in_communication) — the reporting audit caught typos that broke the
|
||||
// rank ladder server-side AND rendered raw enum to the user.
|
||||
// surfaces. Keys MUST match the canonical DB values for the 7-stage
|
||||
// pipeline (post-2026-05 refactor) — the reporting audit caught typos
|
||||
// that broke the rank ladder server-side AND rendered raw enum to the user.
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
contract_signed: 'Contract Signed',
|
||||
contract_sent: 'Contract Sent',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
eoi_signed: 'EOI Signed',
|
||||
eoi_sent: 'EOI Sent',
|
||||
in_communication: 'In Comms',
|
||||
details_sent: 'Details Sent',
|
||||
open: 'Open',
|
||||
completed: 'Completed',
|
||||
contract: 'Contract',
|
||||
deposit_paid: 'Deposit Paid',
|
||||
reservation: 'Reservation',
|
||||
eoi: 'EOI',
|
||||
nurturing: 'Nurturing',
|
||||
qualified: 'Qualified',
|
||||
enquiry: 'Enquiry',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,59 +1,66 @@
|
||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||
import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
|
||||
|
||||
export const expenseFilterDefinitions: FilterDefinition[] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search by establishment or description...',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'multi-select',
|
||||
options: EXPENSE_CATEGORIES.map((c) => ({
|
||||
label: formatEnum(c),
|
||||
value: c,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Unpaid', value: 'unpaid' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Partial', value: 'partial' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dateFrom',
|
||||
label: 'Date From',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
},
|
||||
{
|
||||
key: 'dateTo',
|
||||
label: 'Date To',
|
||||
type: 'text',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
},
|
||||
{
|
||||
key: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. USD, EUR',
|
||||
},
|
||||
{
|
||||
key: 'tripLabel',
|
||||
label: 'Trip / event',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. Palm Beach 2026',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
label: 'Include Archived',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Build the filter-bar definitions. Categories accept the resolved
|
||||
* per-port vocabulary list when callers can fetch it; otherwise the
|
||||
* shipped defaults are used. Kept as a function so the page can read
|
||||
* `/api/v1/vocabularies` on mount and reactively rebuild.
|
||||
*/
|
||||
export function buildExpenseFilterDefinitions(
|
||||
categories: readonly string[] = EXPENSE_CATEGORIES,
|
||||
): FilterDefinition[] {
|
||||
return [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search by establishment or description...',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'multi-select',
|
||||
options: categories.map((c) => ({ label: formatEnum(c), value: c })),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Unpaid', value: 'unpaid' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Partial', value: 'partial' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dateFrom',
|
||||
label: 'Date From',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
key: 'dateTo',
|
||||
label: 'Date To',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
key: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'currency',
|
||||
},
|
||||
{
|
||||
key: 'tripLabel',
|
||||
label: 'Trip / event',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. Palm Beach 2026',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
label: 'Include Archived',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Default list used by SSR / non-vocab-aware consumers. */
|
||||
export const expenseFilterDefinitions: FilterDefinition[] = buildExpenseFilterDefinitions();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Loader2, Upload, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -22,6 +22,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/com
|
||||
import { CurrencyInput } from '@/components/shared/currency-input';
|
||||
import { CurrencySelect } from '@/components/shared/currency-select';
|
||||
import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox';
|
||||
import { UserPicker } from '@/components/shared/user-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { z } from 'zod';
|
||||
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
|
||||
@@ -42,6 +43,17 @@ interface ExpenseFormDialogProps {
|
||||
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!expense;
|
||||
|
||||
// Per-port vocabulary override for expense categories. Falls back to
|
||||
// the shipped EXPENSE_CATEGORIES constant when /api/v1/vocabularies
|
||||
// hasn't loaded yet or returns malformed data — keeps the picker
|
||||
// populated during the first render.
|
||||
const { data: vocab } = useQuery<{ data: Record<string, readonly string[]> }>({
|
||||
queryKey: ['vocabularies'],
|
||||
queryFn: () => apiFetch('/api/v1/vocabularies'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const categoryList = vocab?.data?.expense_categories ?? EXPENSE_CATEGORIES;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedReceipt, setUploadedReceipt] = useState<UploadedReceipt | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
@@ -253,7 +265,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
{categoryList.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{formatEnum(cat)}
|
||||
</SelectItem>
|
||||
@@ -285,7 +297,14 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payer">Payer</Label>
|
||||
<Input id="payer" placeholder="Who paid?" {...register('payer')} />
|
||||
<UserPicker
|
||||
value={(watch('payer') as string | undefined) ?? null}
|
||||
onChange={(v) => setValue('payer', v ?? '', { shouldDirty: true })}
|
||||
placeholder="Who paid?"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a teammate or choose “Other…” to type any name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
31
src/components/icons/whatsapp.tsx
Normal file
31
src/components/icons/whatsapp.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type SVGProps } from 'react';
|
||||
|
||||
/**
|
||||
* WhatsApp brand glyph. Lucide doesn't ship one because the WhatsApp
|
||||
* logo is trademarked, but inline use is permitted for legitimate
|
||||
* channel-indicator UI (no implication of endorsement). The shape is
|
||||
* the standard speech-bubble + handset silhouette.
|
||||
*
|
||||
* Sized via `size`/`className` like other lucide icons so callers can
|
||||
* use it as a drop-in (`<WhatsAppIcon className="h-4 w-4" />`).
|
||||
*/
|
||||
export function WhatsAppIcon({
|
||||
size = 24,
|
||||
className,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement> & { size?: number | string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 2.1.65 4.05 1.75 5.66L2 22l4.55-1.86a9.93 9.93 0 0 0 5.49 1.66h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.9-7.01A9.85 9.85 0 0 0 12.04 2zm0 18.14h-.01a8.2 8.2 0 0 1-4.18-1.15l-.3-.18-3.1 1.27.83-3.02-.2-.31a8.16 8.16 0 0 1-1.25-4.36c0-4.52 3.68-8.2 8.21-8.2 2.19 0 4.25.86 5.8 2.4a8.17 8.17 0 0 1 2.4 5.8c0 4.53-3.68 8.2-8.2 8.2zm4.5-6.14c-.25-.12-1.46-.72-1.69-.8-.23-.08-.39-.12-.56.12-.16.25-.64.8-.78.97-.14.16-.29.18-.54.06-.25-.12-1.04-.38-1.98-1.22-.73-.65-1.23-1.46-1.37-1.7-.14-.25-.02-.39.11-.51.11-.11.25-.29.37-.43.13-.14.16-.25.25-.41.08-.16.04-.31-.02-.43-.06-.12-.56-1.34-.76-1.83-.2-.49-.41-.42-.56-.43-.14-.01-.31-.01-.48-.01-.16 0-.43.06-.66.31-.23.25-.86.84-.86 2.05 0 1.21.88 2.37 1 2.54.12.16 1.74 2.66 4.22 3.73.59.25 1.05.41 1.41.52.59.19 1.13.16 1.55.1.47-.07 1.46-.6 1.66-1.17.2-.58.2-1.07.14-1.17-.06-.1-.22-.16-.47-.28z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export function AddBerthToInterestDialog({
|
||||
checked={choice === 'exploring'}
|
||||
title="Just exploring"
|
||||
description="The berth is being considered or covered by the EOI bundle, but not pitched specifically."
|
||||
consequence="This berth is hidden from the public map."
|
||||
consequence="This berth stays marked “Available” on the public map — the link is internal only."
|
||||
icon={<EyeOff className="size-4" aria-hidden />}
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
124
src/components/interests/assigned-to-chip.tsx
Normal file
124
src/components/interests/assigned-to-chip.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { UserCircle2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-edit ownership chip for the interest detail header. Stores the
|
||||
* assignee's user-id on `interests.assigned_to`. The "Unassigned" path writes
|
||||
* null so the chip falls back to a muted ghost state.
|
||||
*/
|
||||
export function AssignedToChip({
|
||||
interestId,
|
||||
currentAssignedTo,
|
||||
currentAssignedToName,
|
||||
}: {
|
||||
interestId: string;
|
||||
currentAssignedTo: string | null;
|
||||
currentAssignedToName: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = useQuery<{ data: UserOption[] }>({
|
||||
queryKey: ['user-options'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/users/options'),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const users = data?.data ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (next: string | null) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}`, {
|
||||
method: 'PATCH',
|
||||
body: { assignedTo: next },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toastError(err);
|
||||
},
|
||||
});
|
||||
|
||||
const label = currentAssignedToName
|
||||
? currentAssignedToName
|
||||
: currentAssignedTo
|
||||
? `User ${currentAssignedTo.slice(0, 8)}`
|
||||
: 'Unassigned';
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Change deal owner (currently ${label})`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
currentAssignedTo
|
||||
? 'border-sky-200 bg-sky-50 text-sky-800 hover:bg-sky-100'
|
||||
: 'border-border bg-muted/50 text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<UserCircle2 className="size-3" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search users…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup heading="Assign to">
|
||||
{users.map((u) => (
|
||||
<CommandItem
|
||||
key={u.id}
|
||||
value={u.displayName ?? u.id}
|
||||
onSelect={() => mutation.mutate(u.id)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{u.displayName ?? u.id.slice(0, 8)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{currentAssignedTo ? (
|
||||
<CommandGroup heading="Or">
|
||||
<CommandItem
|
||||
value="__unassign__"
|
||||
onSelect={() => mutation.mutate(null)}
|
||||
className="text-muted-foreground"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Unassign
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -345,6 +345,10 @@ export function BerthRecommenderPanel({
|
||||
const [amenityFilters, setAmenityFilters] = useState<AmenityFilters>({});
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [pendingBerth, setPendingBerth] = useState<Recommendation | null>(null);
|
||||
// Area-letter filter — chips above the list let reps narrow to a
|
||||
// single pier (e.g. "show me only A-row matches"). Client-side over
|
||||
// the already-fetched result set; no service change required.
|
||||
const [selectedAreas, setSelectedAreas] = useState<string[]>([]);
|
||||
|
||||
const hasDimensions = desiredLengthFt !== null;
|
||||
|
||||
@@ -367,7 +371,27 @@ export function BerthRecommenderPanel({
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const recommendations = data ?? [];
|
||||
const allRecommendations = data ?? [];
|
||||
|
||||
// Build the set of dock-letter chips from whatever came back, then
|
||||
// filter the visible recommendations by the active selection. Empty
|
||||
// selection = show everything (default).
|
||||
const areaChips = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const r of allRecommendations) {
|
||||
const m = r.mooringNumber.match(/^([A-Z]+)/);
|
||||
if (m?.[1]) set.add(m[1]);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [allRecommendations]);
|
||||
|
||||
const recommendations =
|
||||
selectedAreas.length === 0
|
||||
? allRecommendations
|
||||
: allRecommendations.filter((r) => {
|
||||
const m = r.mooringNumber.match(/^([A-Z]+)/);
|
||||
return m?.[1] ? selectedAreas.includes(m[1]) : false;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -410,6 +434,43 @@ export function BerthRecommenderPanel({
|
||||
{filtersOpen && hasDimensions ? (
|
||||
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
|
||||
) : null}
|
||||
{hasDimensions && areaChips.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Area:</span>
|
||||
{areaChips.map((letter) => {
|
||||
const active = selectedAreas.includes(letter);
|
||||
return (
|
||||
<button
|
||||
key={letter}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedAreas((prev) =>
|
||||
prev.includes(letter) ? prev.filter((l) => l !== letter) : [...prev, letter],
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-input bg-background text-foreground hover:bg-muted',
|
||||
)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{selectedAreas.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedAreas([])}
|
||||
className="text-xs text-muted-foreground underline ml-1"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasDimensions ? (
|
||||
|
||||
78
src/components/interests/deal-pulse-chip.tsx
Normal file
78
src/components/interests/deal-pulse-chip.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
|
||||
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
|
||||
warm: 'border-amber-200 bg-amber-50 text-amber-800',
|
||||
cold: 'border-rose-200 bg-rose-50 text-rose-800',
|
||||
};
|
||||
|
||||
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
|
||||
hot: 'Hot',
|
||||
warm: 'Warm',
|
||||
cold: 'Cold',
|
||||
};
|
||||
|
||||
/**
|
||||
* Header chip surfacing the rule-based deal-health score. The tooltip
|
||||
* exposes every signal that contributed to the score so the calculation is
|
||||
* transparent — stakeholders averse to AI black boxes can read exactly
|
||||
* which dates / stages drove the verdict.
|
||||
*/
|
||||
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
|
||||
// Closed / archived deals don't get a pulse — UX would be confusing.
|
||||
if (interest.archivedAt || interest.outcome) return null;
|
||||
|
||||
const health = computeDealHealth(interest);
|
||||
const tint = PULSE_TINT[health.pulse];
|
||||
const label = PULSE_LABEL[health.pulse];
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
|
||||
tint,
|
||||
)}
|
||||
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
|
||||
>
|
||||
<Activity className="size-3" aria-hidden />
|
||||
{label} · {health.score}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p className="font-semibold mb-1.5">
|
||||
Deal pulse — {label} ({health.score}/100)
|
||||
</p>
|
||||
{health.signals.length === 0 ? (
|
||||
<p className="text-xs">
|
||||
Baseline score (50) — nothing notable yet. Log contact or progress the stage to move
|
||||
the dial.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-xs">
|
||||
{health.signals.map((s) => (
|
||||
<li key={s.id} className="flex gap-2">
|
||||
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
|
||||
{s.delta > 0 ? `+${s.delta}` : s.delta}
|
||||
</span>
|
||||
<span>{s.detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-[10px] opacity-70">
|
||||
Rule-based. Every signal traces to a date or stage you can see — no AI.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -121,11 +121,11 @@ export function InlineStagePicker({
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Rewind-to-open guard: if the rep is dropping the stage back to
|
||||
// 'open' AND the interest still has linked berths, intercept to ask
|
||||
// Rewind-to-enquiry guard: if the rep is dropping the stage back to
|
||||
// 'enquiry' AND the interest still has linked berths, intercept to ask
|
||||
// whether to unlink them. Skipped when there are no linked berths
|
||||
// (the prompt would be noise) or when the rep already came from open.
|
||||
if (next === 'open' && stage !== 'open' && linkedBerthCount > 0) {
|
||||
// (the prompt would be noise) or when the rep is already at enquiry.
|
||||
if (next === 'enquiry' && stage !== 'enquiry' && linkedBerthCount > 0) {
|
||||
setOpenConfirmTarget(next);
|
||||
setOpen(false);
|
||||
return;
|
||||
|
||||
76
src/components/interests/interest-berth-status-banner.tsx
Normal file
76
src/components/interests/interest-berth-status-banner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface BerthRow {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
status: string;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
interface BerthsResponse {
|
||||
data: BerthRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Surfaces when one of the interest's linked berths is sold or under offer
|
||||
* to a different deal. We don't block the rep from proceeding (the user
|
||||
* explicitly wanted v1 to still let the deal advance — the assumption is
|
||||
* that the rep is aware and treating the current deal as a fallback if
|
||||
* the other one falls through), but the banner makes the conflict visible
|
||||
* so they aren't surprised when the rules engine flags it.
|
||||
*
|
||||
* Fires only for active (non-archived, non-closed) interests — banners on
|
||||
* lost deals are noise.
|
||||
*/
|
||||
export function InterestBerthStatusBanner({
|
||||
interestId,
|
||||
interestPipelineStage,
|
||||
interestOutcome,
|
||||
archivedAt,
|
||||
}: {
|
||||
interestId: string;
|
||||
interestPipelineStage: string;
|
||||
interestOutcome?: string | null;
|
||||
archivedAt?: string | null;
|
||||
}) {
|
||||
const { data } = useQuery<BerthsResponse>({
|
||||
queryKey: ['interest-berths', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
||||
});
|
||||
|
||||
if (archivedAt || interestOutcome) return null;
|
||||
// The banner is most useful before the rep is committed to the deal —
|
||||
// once contract is in motion, the conflict is moot.
|
||||
if (interestPipelineStage === 'contract') return null;
|
||||
|
||||
const berths = data?.data ?? [];
|
||||
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
|
||||
if (conflicts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
|
||||
>
|
||||
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{conflicts.length === 1
|
||||
? `Berth ${conflicts[0]!.mooringNumber} is ${
|
||||
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer'
|
||||
} to another deal.`
|
||||
: `${conflicts.length} linked berths are no longer freely available.`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-rose-800">
|
||||
You can still progress this interest as a backup, but the rep on the other deal owns the
|
||||
primary path. If their deal falls through, this one can step in.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
CalendarDays,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Mic,
|
||||
MicOff,
|
||||
MoreVertical,
|
||||
Phone,
|
||||
Pencil,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { useVoiceTranscription } from '@/hooks/use-voice-transcription';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -58,12 +61,16 @@ interface InterestContactLogTabProps {
|
||||
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
|
||||
type Direction = 'outbound' | 'inbound';
|
||||
|
||||
type Template = 'call' | 'visit' | 'email';
|
||||
|
||||
interface ContactLogEntry {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
channel: Channel;
|
||||
direction: Direction;
|
||||
summary: string;
|
||||
voiceTranscript: string | null;
|
||||
templateUsed: string | null;
|
||||
followUpAt: string | null;
|
||||
reminderId: string | null;
|
||||
createdBy: string;
|
||||
@@ -71,10 +78,40 @@ interface ContactLogEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
|
||||
/** Quick-template seeds — drop a starting structure into the summary so reps
|
||||
* spend their typing on the substance, not the scaffolding. */
|
||||
const TEMPLATE_SEEDS: Record<
|
||||
Template,
|
||||
{ channel: Channel; direction: Direction; summary: string; label: string; icon: ChannelIcon }
|
||||
> = {
|
||||
call: {
|
||||
channel: 'phone',
|
||||
direction: 'outbound',
|
||||
summary: 'Called the client. Discussed:\n\n• \n\nNext step: ',
|
||||
label: 'Call',
|
||||
icon: Phone,
|
||||
},
|
||||
visit: {
|
||||
channel: 'in_person',
|
||||
direction: 'outbound',
|
||||
summary: 'Met with the client in person. Discussed:\n\n• \n\nNext step: ',
|
||||
label: 'Visit',
|
||||
icon: Users,
|
||||
},
|
||||
email: {
|
||||
channel: 'email',
|
||||
direction: 'outbound',
|
||||
summary: 'Emailed the client.\n\nTopic: \n\nResponse expected: ',
|
||||
label: 'Email',
|
||||
icon: Mail,
|
||||
},
|
||||
};
|
||||
|
||||
type ChannelIcon = React.ComponentType<{ className?: string }>;
|
||||
const CHANNEL_META: Record<Channel, { label: string; icon: ChannelIcon; tone: string }> = {
|
||||
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
|
||||
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
|
||||
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
|
||||
whatsapp: { label: 'WhatsApp', icon: WhatsAppIcon, tone: 'bg-emerald-100 text-emerald-700' },
|
||||
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
|
||||
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
|
||||
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
|
||||
@@ -306,6 +343,44 @@ function ComposeDialogBody({
|
||||
const [followUpAt, setFollowUpAt] = useState<string>(
|
||||
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
|
||||
);
|
||||
const [templateUsed, setTemplateUsed] = useState<Template | null>(
|
||||
(existing?.templateUsed as Template | undefined) ?? null,
|
||||
);
|
||||
// Voice transcript is captured separately so an edit to summary doesn't
|
||||
// overwrite the rep's original raw utterance. Preserved on the row.
|
||||
const [voiceTranscript, setVoiceTranscript] = useState<string>(existing?.voiceTranscript ?? '');
|
||||
|
||||
const voice = useVoiceTranscription();
|
||||
// Append committed transcript chunks into the summary as the rep speaks.
|
||||
// We diff against the previous final transcript so we only append the new
|
||||
// tail — otherwise the entire transcript gets re-pasted on every event.
|
||||
const previousFinalRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
const prev = previousFinalRef.current;
|
||||
if (voice.transcript === prev) return;
|
||||
const added = voice.transcript.slice(prev.length).trim();
|
||||
if (added.length === 0) {
|
||||
previousFinalRef.current = voice.transcript;
|
||||
return;
|
||||
}
|
||||
setSummary((prevSummary) => {
|
||||
const sep =
|
||||
prevSummary && !prevSummary.endsWith(' ') && !prevSummary.endsWith('\n') ? ' ' : '';
|
||||
return prevSummary + sep + added;
|
||||
});
|
||||
setVoiceTranscript((prev2) => (prev2 ? `${prev2} ${added}` : added));
|
||||
previousFinalRef.current = voice.transcript;
|
||||
}, [voice.transcript]);
|
||||
|
||||
function applyTemplate(t: Template) {
|
||||
const seed = TEMPLATE_SEEDS[t];
|
||||
setChannel(seed.channel);
|
||||
setDirection(seed.direction);
|
||||
// Don't clobber if the rep already typed something — append a divider
|
||||
// so the template scaffolds the *next* block.
|
||||
setSummary((cur) => (cur.trim().length === 0 ? seed.summary : `${cur}\n\n${seed.summary}`));
|
||||
setTemplateUsed(t);
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -314,6 +389,8 @@ function ComposeDialogBody({
|
||||
channel,
|
||||
direction,
|
||||
summary,
|
||||
voiceTranscript: voiceTranscript.trim().length > 0 ? voiceTranscript : null,
|
||||
templateUsed,
|
||||
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
|
||||
};
|
||||
if (isEdit) {
|
||||
@@ -350,6 +427,35 @@ function ComposeDialogBody({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
{/* Quick-template buttons. Tap one to seed the channel + direction
|
||||
+ a starter summary so the rep can focus on the substance.
|
||||
Hidden when editing — templates are a fresh-entry affordance. */}
|
||||
{!isEdit ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(Object.keys(TEMPLATE_SEEDS) as Template[]).map((t) => {
|
||||
const seed = TEMPLATE_SEEDS[t];
|
||||
const Icon = seed.icon;
|
||||
const active = templateUsed === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(t)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-sky-300 bg-sky-50 text-sky-800'
|
||||
: 'border-border bg-muted/40 text-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3" aria-hidden />
|
||||
{seed.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-channel">Channel</Label>
|
||||
@@ -391,7 +497,44 @@ function ComposeDialogBody({
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="cl-summary">Summary</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cl-summary">Summary</Label>
|
||||
{voice.supported ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
voice.isListening ? 'Stop voice transcription' : 'Start voice transcription'
|
||||
}
|
||||
onClick={() => (voice.isListening ? voice.stop() : voice.start())}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
voice.isListening
|
||||
? 'border-rose-300 bg-rose-50 text-rose-800 animate-pulse'
|
||||
: 'border-border bg-muted/40 text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{voice.isListening ? (
|
||||
<>
|
||||
<Mic className="size-3" aria-hidden />
|
||||
Recording…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MicOff className="size-3" aria-hidden />
|
||||
Voice
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title="Voice transcription isn't supported in this browser."
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<MicOff className="size-3" aria-hidden />
|
||||
Voice unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="cl-summary"
|
||||
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
|
||||
@@ -399,6 +542,12 @@ function ComposeDialogBody({
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
/>
|
||||
{voice.isListening && voice.interim ? (
|
||||
<p className="text-[11px] italic text-muted-foreground">{voice.interim}…</p>
|
||||
) : null}
|
||||
{voice.error ? (
|
||||
<p className="text-[11px] text-rose-700">Voice error: {voice.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
XCircle,
|
||||
RefreshCcw,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
Phone,
|
||||
AlarmClock,
|
||||
} from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,6 +25,9 @@ import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
|
||||
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
|
||||
import { AssignedToChip } from '@/components/interests/assigned-to-chip';
|
||||
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { formatOutcome } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -86,6 +89,21 @@ interface InterestDetailHeaderProps {
|
||||
outcome?: string | null;
|
||||
outcomeReason?: string | null;
|
||||
dateLastContact?: string | null;
|
||||
dateFirstContact?: string | null;
|
||||
dateEoiSent?: string | null;
|
||||
dateEoiSigned?: string | null;
|
||||
dateReservationSigned?: string | null;
|
||||
dateContractSent?: string | null;
|
||||
dateContractSigned?: string | null;
|
||||
dateDepositReceived?: string | null;
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */
|
||||
recentActivityCount?: number | null;
|
||||
/** Sales rep who owns this deal — populated by the AssignedToChip. */
|
||||
assignedTo?: string | null;
|
||||
assignedToName?: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
@@ -235,6 +253,33 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
{interest.activeReminderCount}
|
||||
</span>
|
||||
) : null}
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<AssignedToChip
|
||||
interestId={interest.id}
|
||||
currentAssignedTo={interest.assignedTo ?? null}
|
||||
currentAssignedToName={interest.assignedToName ?? null}
|
||||
/>
|
||||
</PermissionGate>
|
||||
<MultiEoiChip interestId={interest.id} />
|
||||
<DealPulseChip
|
||||
interest={{
|
||||
pipelineStage: interest.pipelineStage,
|
||||
outcome: interest.outcome,
|
||||
archivedAt: interest.archivedAt,
|
||||
dateFirstContact: interest.dateFirstContact,
|
||||
dateLastContact: interest.dateLastContact,
|
||||
dateEoiSent: interest.dateEoiSent,
|
||||
dateEoiSigned: interest.dateEoiSigned,
|
||||
dateReservationSigned: interest.dateReservationSigned,
|
||||
dateContractSent: interest.dateContractSent,
|
||||
dateContractSigned: interest.dateContractSigned,
|
||||
dateDepositReceived: interest.dateDepositReceived,
|
||||
eoiDocStatus: interest.eoiDocStatus,
|
||||
reservationDocStatus: interest.reservationDocStatus,
|
||||
contractDocStatus: interest.contractDocStatus,
|
||||
recentActivityCount: interest.recentActivityCount,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{meta.length > 0 ? (
|
||||
@@ -311,7 +356,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Message on WhatsApp`}
|
||||
>
|
||||
<MessageCircle />
|
||||
<WhatsAppIcon className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
},
|
||||
@@ -189,7 +189,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
reset({
|
||||
clientId: defaultClientId ?? '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'open',
|
||||
pipelineStage: 'enquiry',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
});
|
||||
@@ -389,7 +389,9 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Yacht</Label>
|
||||
<Label>
|
||||
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
</Label>
|
||||
{selectedClientId && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -82,7 +82,7 @@ export function InterestList() {
|
||||
|
||||
// Bulk-action dialog state
|
||||
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
|
||||
const [stageChoice, setStageChoice] = useState<PipelineStage>('open');
|
||||
const [stageChoice, setStageChoice] = useState<PipelineStage>('enquiry');
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
null,
|
||||
);
|
||||
@@ -99,6 +99,7 @@ export function InterestList() {
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<InterestRow>({
|
||||
queryKey: ['interests'],
|
||||
@@ -237,8 +238,7 @@ export function InterestList() {
|
||||
<SavedViewsDropdown
|
||||
entityType="interests"
|
||||
onApplyView={(savedFilters) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
setAllFilters(savedFilters);
|
||||
}}
|
||||
/>
|
||||
<ColumnPicker
|
||||
@@ -284,7 +284,7 @@ export function InterestList() {
|
||||
icon: ArrowRight,
|
||||
onClick: (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
setStageChoice('open');
|
||||
setStageChoice('enquiry');
|
||||
setStageDialog({ ids });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import { format, formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
|
||||
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -18,6 +18,8 @@ import { RecommendationList } from '@/components/interests/recommendation-list';
|
||||
import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel';
|
||||
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
|
||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||
import { WonStatusPanel } from '@/components/interests/won-status-panel';
|
||||
import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button';
|
||||
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
|
||||
import {
|
||||
LEAD_CATEGORIES,
|
||||
@@ -28,6 +30,10 @@ import {
|
||||
} from '@/lib/constants';
|
||||
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
|
||||
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
|
||||
import { PaymentsSection } from '@/components/interests/payments-section';
|
||||
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
|
||||
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
|
||||
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
|
||||
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -65,10 +71,23 @@ interface InterestTabsOptions {
|
||||
contractStatus: string | null;
|
||||
depositStatus: string | null;
|
||||
reservationStatus: string | null;
|
||||
/** Captured at reservation-agreement time. Drives the deposit-paid
|
||||
* auto-advance once payment totals catch up. */
|
||||
depositExpectedAmount?: string | null;
|
||||
depositExpectedCurrency?: string | null;
|
||||
/** Doc-bearing stage sub-status badges — drive the milestone past/current
|
||||
* classification independently of the pipeline stage. NULL until the
|
||||
* matching stage is reached. */
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
/** Final outcome — 'won' surfaces the wrap-up checklist panel. */
|
||||
outcome?: string | null;
|
||||
dateFirstContact: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateReservationSigned?: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
@@ -401,7 +420,7 @@ function FutureMilestones({
|
||||
currentStage,
|
||||
}: {
|
||||
milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
status: string | null;
|
||||
@@ -410,7 +429,7 @@ function FutureMilestones({
|
||||
}>;
|
||||
stageMutation: ReturnType<typeof useStageMutation>;
|
||||
advance: (stage: string) => void | Promise<void>;
|
||||
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
|
||||
activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null;
|
||||
currentStage: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -511,17 +530,19 @@ function OverviewTab({
|
||||
// genuinely skips stages — the click then routes through the same
|
||||
// override-confirm flow as the inline stage picker.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
|
||||
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
|
||||
const contractIdx = PIPELINE_STAGES.indexOf('contract');
|
||||
|
||||
// Sub-status carries the "is this milestone's doc actually signed?" bit
|
||||
// for the doc-bearing stages (eoi / reservation / contract). A milestone
|
||||
// is 'past' when stage is BEYOND its index OR when stage equals its index
|
||||
// AND the doc sub-status is 'signed'.
|
||||
const eoiSigned = interest.eoiDocStatus === 'signed';
|
||||
const reservationSigned = interest.reservationDocStatus === 'signed';
|
||||
const contractSigned = interest.contractDocStatus === 'signed';
|
||||
|
||||
const phaseFor = (milestoneEndStageIdx: number): Phase => {
|
||||
if (stageIdx === -1) return 'future';
|
||||
if (stageIdx >= milestoneEndStageIdx) return 'past';
|
||||
// The "current" milestone is the one whose end-stage hasn't been
|
||||
// reached and whose start-stage is at-or-before the current stage.
|
||||
return 'current';
|
||||
};
|
||||
// Berth Interest milestone — first thing the rep needs to capture
|
||||
// (especially for general_interest leads). Completes the moment ANY
|
||||
// berth is linked to the interest via the junction. While unset, it
|
||||
@@ -531,39 +552,59 @@ function OverviewTab({
|
||||
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
|
||||
const berthInterestPhase: Phase = hasLinkedBerth
|
||||
? 'past'
|
||||
: stageIdx === -1 || stageIdx >= eoiSignedIdx
|
||||
: stageIdx === -1 || stageIdx >= eoiIdx
|
||||
? 'past'
|
||||
: 'current';
|
||||
|
||||
const eoiPhase = phaseFor(eoiSignedIdx);
|
||||
// Deposit is current once the EOI is signed but before deposit is in.
|
||||
const eoiPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
|
||||
? 'past'
|
||||
: stageIdx === eoiIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
const reservationPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
|
||||
? 'past'
|
||||
: stageIdx === reservationIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
// Deposit becomes 'current' once the reservation is signed; auto-advance
|
||||
// moves it to 'past' the moment the running deposit total catches up.
|
||||
const depositPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= depositIdx
|
||||
: stageIdx > depositIdx
|
||||
? 'past'
|
||||
: stageIdx >= eoiSignedIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
: stageIdx === depositIdx
|
||||
? 'past'
|
||||
: stageIdx === reservationIdx && reservationSigned
|
||||
? 'current'
|
||||
: 'future';
|
||||
const contractPhase: Phase =
|
||||
stageIdx === -1
|
||||
? 'future'
|
||||
: stageIdx >= contractSignedIdx
|
||||
: stageIdx === contractIdx && contractSigned
|
||||
? 'past'
|
||||
: stageIdx >= depositIdx
|
||||
: stageIdx === contractIdx
|
||||
? 'current'
|
||||
: 'future';
|
||||
|
||||
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
|
||||
const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null =
|
||||
berthInterestPhase === 'current'
|
||||
? 'berth_interest'
|
||||
: eoiPhase === 'current'
|
||||
? 'eoi'
|
||||
: depositPhase === 'current'
|
||||
? 'deposit'
|
||||
: contractPhase === 'current'
|
||||
? 'contract'
|
||||
: null;
|
||||
: reservationPhase === 'current'
|
||||
? 'reservation'
|
||||
: depositPhase === 'current'
|
||||
? 'deposit'
|
||||
: contractPhase === 'current'
|
||||
? 'contract'
|
||||
: null;
|
||||
|
||||
const toNum = (v: string | null | undefined): number | null => {
|
||||
if (v === null || v === undefined) return null;
|
||||
@@ -572,7 +613,7 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
const milestones: Array<{
|
||||
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
|
||||
key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract';
|
||||
phase: Phase;
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
@@ -612,18 +653,20 @@ function OverviewTab({
|
||||
phase: eoiPhase,
|
||||
title: 'EOI',
|
||||
icon: Send,
|
||||
status: interest.eoiStatus,
|
||||
status: interest.eoiDocStatus ?? interest.eoiStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'EOI sent',
|
||||
date: interest.dateEoiSent,
|
||||
advanceStage: 'eoi_sent',
|
||||
advanceStage: 'eoi',
|
||||
actionLabel: 'Mark EOI as sent',
|
||||
},
|
||||
{
|
||||
label: 'EOI signed',
|
||||
date: interest.dateEoiSigned,
|
||||
advanceStage: 'eoi_signed',
|
||||
// Stage stays at 'eoi'; the sub-status badge flips via a separate
|
||||
// PATCH (see MilestoneAdvanceButton.onConfirm fallback below).
|
||||
advanceStage: 'eoi',
|
||||
actionLabel: 'Mark EOI as signed',
|
||||
},
|
||||
],
|
||||
@@ -631,6 +674,24 @@ function OverviewTab({
|
||||
? `Signed ${formatDate(interest.dateEoiSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
{
|
||||
key: 'reservation',
|
||||
phase: reservationPhase,
|
||||
title: 'Reservation',
|
||||
icon: FileSignature,
|
||||
status: interest.reservationDocStatus ?? null,
|
||||
steps: [
|
||||
{
|
||||
label: 'Reservation agreement signed',
|
||||
date: interest.dateReservationSigned ?? null,
|
||||
advanceStage: 'reservation',
|
||||
actionLabel: 'Mark reservation as signed',
|
||||
},
|
||||
],
|
||||
pastSummary: interest.dateReservationSigned
|
||||
? `Signed ${formatDate(interest.dateReservationSigned)}`
|
||||
: 'Completed',
|
||||
},
|
||||
{
|
||||
key: 'deposit',
|
||||
phase: depositPhase,
|
||||
@@ -641,25 +702,22 @@ function OverviewTab({
|
||||
{
|
||||
label: 'Deposit received',
|
||||
date: interest.dateDepositReceived,
|
||||
advanceStage: 'deposit_10pct',
|
||||
advanceStage: 'deposit_paid',
|
||||
hideAutoButton: true,
|
||||
},
|
||||
],
|
||||
footer:
|
||||
depositPhase === 'current' && !interest.dateDepositReceived ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
|
||||
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Create deposit invoice
|
||||
</Link>
|
||||
</Button>
|
||||
<MilestoneAdvanceButton
|
||||
label="Mark received manually"
|
||||
variant="ghostLink"
|
||||
disabled={stageMutation.isPending}
|
||||
onConfirm={(date) => advance('deposit_10pct', date)}
|
||||
onConfirm={(date) => advance('deposit_paid', date)}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Or record a payment in the Payments section.
|
||||
</span>
|
||||
</div>
|
||||
) : null,
|
||||
pastSummary: interest.dateDepositReceived
|
||||
@@ -671,18 +729,18 @@ function OverviewTab({
|
||||
phase: contractPhase,
|
||||
title: 'Contract',
|
||||
icon: FileSignature,
|
||||
status: interest.contractStatus,
|
||||
status: interest.contractDocStatus ?? interest.contractStatus,
|
||||
steps: [
|
||||
{
|
||||
label: 'Contract sent',
|
||||
date: interest.dateContractSent,
|
||||
advanceStage: 'contract_sent',
|
||||
advanceStage: 'contract',
|
||||
actionLabel: 'Mark contract as sent',
|
||||
},
|
||||
{
|
||||
label: 'Contract signed',
|
||||
date: interest.dateContractSigned,
|
||||
advanceStage: 'contract_signed',
|
||||
advanceStage: 'contract',
|
||||
actionLabel: 'Mark contract as signed',
|
||||
},
|
||||
],
|
||||
@@ -698,6 +756,35 @@ function OverviewTab({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Skip-ahead nudge — informational only; fires when the deal jumped
|
||||
past a milestone without stamping the matching date. */}
|
||||
<SkipAheadBanner interest={interest} />
|
||||
|
||||
{/* Conflict callout — fires when a linked berth is sold or already
|
||||
under offer to another active deal. Doesn't block the rep; just
|
||||
surfaces the situation so they treat the deal as a backup. */}
|
||||
<InterestBerthStatusBanner
|
||||
interestId={interestId}
|
||||
interestPipelineStage={interest.pipelineStage}
|
||||
interestOutcome={interest.outcome}
|
||||
archivedAt={null}
|
||||
/>
|
||||
|
||||
{/* Qualification checklist — surfaces the port's per-port criteria so
|
||||
the rep can mark each one confirmed before the deal advances out
|
||||
of 'enquiry'. Hidden when the port has no enabled criteria. */}
|
||||
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
|
||||
|
||||
{/* Payments — bank-issued invoices live elsewhere; this is the
|
||||
internal audit record of money received against the deal. The
|
||||
running deposit total here drives the auto-advance into the
|
||||
deposit_paid stage server-side. */}
|
||||
<PaymentsSection
|
||||
interestId={interestId}
|
||||
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
||||
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
||||
/>
|
||||
|
||||
{/* Sales-process milestones — phase-aware so the user only sees
|
||||
what's actionable now. Past milestones collapse into a tight
|
||||
history strip; the current milestone gets the full card; future
|
||||
@@ -842,21 +929,30 @@ function OverviewTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/interests/${interestId}/tags`}
|
||||
currentTags={interest.tags ?? []}
|
||||
invalidateKey={['interests', interestId]}
|
||||
/>
|
||||
</div>
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/interests/${interestId}/tags`}
|
||||
currentTags={interest.tags ?? []}
|
||||
invalidateKey={['interests', interestId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see
|
||||
what's already linked before browsing more options. Each row exposes
|
||||
per-berth role-flag toggles and the EOI bypass control (only visible
|
||||
once the parent interest's primary EOI is signed). */}
|
||||
{/* Won-status wrap-up checklist — only renders when this interest's
|
||||
outcome is `won`. Surfaces upload slots for the manual paperwork
|
||||
that didn't flow through the EOI->Contract chain automatically. */}
|
||||
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
|
||||
|
||||
{/* Pre-EOI supplemental info request. Sends the client a one-time
|
||||
public form pre-filled with what's on file so they can confirm /
|
||||
correct details before the EOI is drafted. Hides itself once
|
||||
the EOI is signed. */}
|
||||
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
|
||||
|
||||
<LinkedBerthsList interestId={interestId} />
|
||||
|
||||
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
|
||||
@@ -886,17 +982,19 @@ export function getInterestTabs({
|
||||
// documents; if a deal regresses the past docs remain accessible
|
||||
// via the generic Documents tab.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
|
||||
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
|
||||
// EOI: from details_sent through contract_signed (the deal's whole life)
|
||||
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
|
||||
// Contract: appears once the deposit's been paid (deal is committed)
|
||||
// and stays visible until the contract is signed
|
||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
|
||||
// Reservation: appears once the contract's signed and stays visible
|
||||
// through completion (reservation is the post-contract milestone)
|
||||
const showReservationTab = stageIdx >= contractSignedIdx;
|
||||
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
|
||||
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
|
||||
const contractIdx = PIPELINE_STAGES.indexOf('contract');
|
||||
// EOI: from qualified through contract (the deal's whole life past lead-only).
|
||||
const showEoiTab = stageIdx >= qualifiedIdx;
|
||||
// Reservation: once the EOI is signed onward — the reservation agreement
|
||||
// is the v1 step between EOI and deposit. Stays visible through contract
|
||||
// so the rep can re-open the signed reservation later.
|
||||
const showReservationTab = stageIdx >= reservationIdx;
|
||||
// Contract: from deposit_paid onward (deal is committed and the contract
|
||||
// becomes the next active document).
|
||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
|
||||
|
||||
const tabs: DetailTab[] = [
|
||||
{
|
||||
|
||||
@@ -114,8 +114,10 @@ function formatDimensions(
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
const SPECIFIC_CONSEQUENCE_ON = 'This berth will appear as under interest on the public map.';
|
||||
const SPECIFIC_CONSEQUENCE_OFF = 'This berth is hidden from the public map.';
|
||||
const SPECIFIC_CONSEQUENCE_ON =
|
||||
'This berth will show as “Under Offer” on the public-facing marina map.';
|
||||
const SPECIFIC_CONSEQUENCE_OFF =
|
||||
'This berth stays marked “Available” on the public map — the link is internal only.';
|
||||
|
||||
// ─── Hooks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -238,9 +240,19 @@ interface RowProps {
|
||||
onUpdate: (berthId: string, patch: PatchPayload) => void;
|
||||
onRemove: (berthId: string) => void;
|
||||
isPending: boolean;
|
||||
/** When true, this is the deal berth — render with elevated styling. */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPending }: RowProps) {
|
||||
function LinkedBerthRowItem({
|
||||
row,
|
||||
portSlug,
|
||||
eoiStatus,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
isPending,
|
||||
highlight,
|
||||
}: RowProps) {
|
||||
const [bypassOpen, setBypassOpen] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
|
||||
@@ -250,7 +262,7 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border bg-card p-3 text-sm',
|
||||
row.isPrimary ? 'border-brand-300 ring-1 ring-brand-200' : 'border-border',
|
||||
highlight ? 'border-brand-300 ring-1 ring-brand-200 shadow-sm' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@@ -480,6 +492,30 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||
const eoiStatus = data?.meta.eoiStatus ?? null;
|
||||
const isPending = updateMutation.isPending || removeMutation.isPending;
|
||||
|
||||
// Three-bucket split per the Deal-berth + Bundle model:
|
||||
// • dealBerth: the single is_primary row — the one templates/EOI
|
||||
// resolve through ("the berth for this deal").
|
||||
// • bundleRows: in EOI bundle but not primary.
|
||||
// • exploringRows: everything else (also-considering, internal-only links).
|
||||
// The same row never appears in two buckets — primary takes precedence,
|
||||
// then bundle, then exploring.
|
||||
const dealBerth = rows.find((r) => r.isPrimary) ?? null;
|
||||
const bundleRows = rows.filter((r) => !r.isPrimary && r.isInEoiBundle);
|
||||
const exploringRows = rows.filter((r) => !r.isPrimary && !r.isInEoiBundle);
|
||||
|
||||
const renderRow = (row: LinkedBerthRow, options?: { highlight?: boolean }) => (
|
||||
<LinkedBerthRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
portSlug={portSlug}
|
||||
eoiStatus={eoiStatus}
|
||||
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
|
||||
onRemove={(berthId) => removeMutation.mutate(berthId)}
|
||||
isPending={isPending}
|
||||
highlight={options?.highlight}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -488,7 +524,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||
Linked berths{rows.length > 0 ? ` (${rows.length})` : ''}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-5">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((i) => (
|
||||
@@ -500,19 +536,36 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||
No berths linked yet. Use the recommender below to add one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<LinkedBerthRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
portSlug={portSlug}
|
||||
eoiStatus={eoiStatus}
|
||||
onUpdate={(berthId, patch) => updateMutation.mutate({ berthId, patch })}
|
||||
onRemove={(berthId) => removeMutation.mutate(berthId)}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<BerthSection
|
||||
title="Deal berth"
|
||||
hint="The one berth this interest is anchored to — drives templates, the EOI primary slot, and the public-map status. Promote any other berth to take its place."
|
||||
emptyText="No deal berth selected. Pick one of the linked berths below as the primary."
|
||||
count={dealBerth ? 1 : 0}
|
||||
>
|
||||
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
|
||||
</BerthSection>
|
||||
|
||||
{bundleRows.length > 0 || dealBerth ? (
|
||||
<BerthSection
|
||||
title="In EOI bundle"
|
||||
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."
|
||||
count={bundleRows.length}
|
||||
>
|
||||
{bundleRows.map((row) => renderRow(row))}
|
||||
</BerthSection>
|
||||
) : null}
|
||||
|
||||
{exploringRows.length > 0 ? (
|
||||
<BerthSection
|
||||
title="Also considering"
|
||||
hint="Linked for sales context (alternates the client glanced at, fallback options, etc.). No EOI coverage; toggle “In EOI bundle” to promote one here."
|
||||
count={exploringRows.length}
|
||||
>
|
||||
{exploringRows.map((row) => renderRow(row))}
|
||||
</BerthSection>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{updateMutation.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
@@ -528,3 +581,43 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header + body wrapper for the three-bucket layout. Kept inline
|
||||
* because it's only used here — promoting it to /shared isn't worth the
|
||||
* indirection for a card-header + a help line. */
|
||||
function BerthSection({
|
||||
title,
|
||||
hint,
|
||||
count,
|
||||
emptyText,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
count: number;
|
||||
emptyText?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
{title}
|
||||
{count > 0 ? (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">({count})</span>
|
||||
) : null}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
{count === 0 && emptyText ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{emptyText}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">{children}</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/interests/multi-eoi-chip.tsx
Normal file
47
src/components/interests/multi-eoi-chip.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileSignature } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface DocumentRow {
|
||||
id: string;
|
||||
documentType: string;
|
||||
status: string;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
interface DocumentsResponse {
|
||||
data: DocumentRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtle chip that surfaces when an interest has multiple in-flight EOI
|
||||
* documents (status != voided, not archived). Per product direction we
|
||||
* intentionally allow multi-EOI cases (sometimes a deal really does need
|
||||
* a second EOI for a different berth combo), but the rep should see the
|
||||
* conflict at a glance so they don't accidentally re-send.
|
||||
*/
|
||||
export function MultiEoiChip({ interestId }: { interestId: string }) {
|
||||
const { data } = useQuery<DocumentsResponse>({
|
||||
queryKey: ['documents', { interestId, documentType: 'eoi' }],
|
||||
queryFn: () => apiFetch(`/api/v1/documents?interestId=${interestId}&documentType=eoi`),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const inflight = (data?.data ?? []).filter(
|
||||
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
|
||||
);
|
||||
if (inflight.length < 2) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={`This interest has ${inflight.length} in-flight EOI documents — review on the EOI tab.`}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-800"
|
||||
>
|
||||
<FileSignature className="size-3" aria-hidden />
|
||||
{inflight.length} EOIs
|
||||
</span>
|
||||
);
|
||||
}
|
||||
377
src/components/interests/payments-section.tsx
Normal file
377
src/components/interests/payments-section.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Receipt } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface PaymentRow {
|
||||
id: string;
|
||||
paymentType: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
receivedAt: string;
|
||||
receiptFileId: string | null;
|
||||
notes: string | null;
|
||||
recordedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaymentsResponse {
|
||||
data: {
|
||||
payments: PaymentRow[];
|
||||
depositTotal: { total: string; currency: string };
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
deposit: 'Deposit',
|
||||
balance: 'Balance',
|
||||
refund: 'Refund',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
const TYPE_TINT: Record<string, string> = {
|
||||
deposit: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
balance: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
refund: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
other: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
function formatMoney(amount: string, currency: string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return `${amount} ${currency}`;
|
||||
try {
|
||||
return new Intl.NumberFormat('en-EU', { style: 'currency', currency }).format(n);
|
||||
} catch {
|
||||
return `${n.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PaymentsSection({
|
||||
interestId,
|
||||
depositExpectedAmount,
|
||||
depositExpectedCurrency,
|
||||
}: {
|
||||
interestId: string;
|
||||
depositExpectedAmount: string | null;
|
||||
depositExpectedCurrency: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [recordOpen, setRecordOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<PaymentsResponse>({
|
||||
queryKey: ['interest-payments', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/payments`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (paymentId: string) =>
|
||||
apiFetch(`/api/v1/payments/${paymentId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="rounded-lg border p-4 text-sm text-muted-foreground">
|
||||
Loading payments…
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const payments = data?.data.payments ?? [];
|
||||
const total = data?.data.depositTotal;
|
||||
const expectedAmount = depositExpectedAmount ? Number(depositExpectedAmount) : null;
|
||||
const expectedCurrency = depositExpectedCurrency ?? 'EUR';
|
||||
const runningTotal = total ? Number(total.total) : 0;
|
||||
const remaining =
|
||||
expectedAmount !== null && Number.isFinite(expectedAmount)
|
||||
? Math.max(0, expectedAmount - runningTotal)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Payments</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Records that money was received or refunded. No invoices are issued — the bank handles
|
||||
that.
|
||||
</p>
|
||||
</div>
|
||||
<PermissionGate resource="invoices" action="record_payment">
|
||||
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Record payment
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
{expectedAmount !== null ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
<span>
|
||||
Expected deposit:{' '}
|
||||
<strong>{formatMoney(String(expectedAmount), expectedCurrency)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Received so far: <strong>{formatMoney(total?.total ?? '0', expectedCurrency)}</strong>
|
||||
</span>
|
||||
{remaining !== null ? (
|
||||
<span className={remaining === 0 ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{remaining === 0
|
||||
? 'Fully received'
|
||||
: `${formatMoney(String(remaining), expectedCurrency)} outstanding`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<p className="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No payments recorded yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded border">
|
||||
{payments.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||||
TYPE_TINT[p.paymentType] ?? TYPE_TINT.other
|
||||
}`}
|
||||
>
|
||||
{TYPE_LABELS[p.paymentType] ?? p.paymentType}
|
||||
</span>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatMoney(p.amount, p.currency)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatDate(p.receivedAt)}
|
||||
</span>
|
||||
{p.notes ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">· {p.notes}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{p.receiptFileId ? (
|
||||
<Receipt className="size-3 text-emerald-600" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<PermissionGate resource="invoices" action="record_payment">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete payment record"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Delete this payment record? This cannot be undone.')) {
|
||||
deleteMutation.mutate(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<RecordPaymentSheet
|
||||
open={recordOpen}
|
||||
onOpenChange={setRecordOpen}
|
||||
interestId={interestId}
|
||||
defaultCurrency={expectedCurrency}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordPaymentSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
interestId,
|
||||
defaultCurrency,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
interestId: string;
|
||||
defaultCurrency: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [paymentType, setPaymentType] = useState<string>('deposit');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [currency, setCurrency] = useState(defaultCurrency);
|
||||
const [receivedAt, setReceivedAt] = useState(() => {
|
||||
const today = new Date();
|
||||
return today.toISOString().slice(0, 10);
|
||||
});
|
||||
const [notes, setNotes] = useState('');
|
||||
const [acknowledgedNoReceipt, setAcknowledgedNoReceipt] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/payments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
interestId,
|
||||
paymentType,
|
||||
amount,
|
||||
currency,
|
||||
receivedAt: new Date(receivedAt).toISOString(),
|
||||
notes: notes || null,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
onOpenChange(false);
|
||||
// Reset form for next use
|
||||
setAmount('');
|
||||
setNotes('');
|
||||
setAcknowledgedNoReceipt(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
amount.trim().length > 0 && receivedAt && acknowledgedNoReceipt && !mutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Record payment</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture that money was received (or refunded). Reps don't issue invoices — the bank
|
||||
does that — so this is just an audit record.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
className="mt-5 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-type">Type</Label>
|
||||
<Select value={paymentType} onValueChange={setPaymentType}>
|
||||
<SelectTrigger id="payment-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deposit">Deposit</SelectItem>
|
||||
<SelectItem value="balance">Balance</SelectItem>
|
||||
<SelectItem value="refund">Refund</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_100px] gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-amount">Amount</Label>
|
||||
<Input
|
||||
id="payment-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-currency">Currency</Label>
|
||||
<Input
|
||||
id="payment-currency"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-date">Received on</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
value={receivedAt}
|
||||
onChange={(e) => setReceivedAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-notes">Notes (optional)</Label>
|
||||
<Input
|
||||
id="payment-notes"
|
||||
placeholder="Reference, payer name, etc."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledgedNoReceipt}
|
||||
onChange={(e) => setAcknowledgedNoReceipt(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
I understand that recording a payment without an attached receipt may make later
|
||||
verification harder, and that the bank-issued receipt is the canonical proof.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<SheetFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{mutation.isPending ? 'Saving…' : 'Record payment'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
157
src/components/interests/qualification-checklist.tsx
Normal file
157
src/components/interests/qualification-checklist.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { CheckCircle2, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface QualificationRow {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
displayOrder: number;
|
||||
confirmed: boolean;
|
||||
confirmedAt: string | null;
|
||||
confirmedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface QualificationResponse {
|
||||
data: {
|
||||
criteria: QualificationRow[];
|
||||
fullyQualified: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-interest qualification checklist. Hidden when the port has no
|
||||
* enabled criteria. When the rep has confirmed every enabled criterion AND
|
||||
* the deal is still in 'enquiry', a soft hint surfaces a Promote button
|
||||
* that advances the stage to 'qualified' through the standard transition
|
||||
* endpoint (no override; this is the canonical adjacent move).
|
||||
*/
|
||||
export function QualificationChecklist({
|
||||
interestId,
|
||||
currentStage,
|
||||
}: {
|
||||
interestId: string;
|
||||
currentStage: string;
|
||||
}) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<QualificationResponse>({
|
||||
queryKey: ['interest-qualifications', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/qualifications`),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (vars: { criterionKey: string; confirmed: boolean }) =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/qualifications`, {
|
||||
method: 'PUT',
|
||||
body: { criterionKey: vars.criterionKey, confirmed: vars.confirmed },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-qualifications', interestId] });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const promoteMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'POST',
|
||||
body: { pipelineStage: 'qualified' },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
if (!data) return null;
|
||||
const criteria = data.data.criteria;
|
||||
if (criteria.length === 0) return null;
|
||||
|
||||
const fullyQualified = data.data.fullyQualified;
|
||||
const showPromoteHint = fullyQualified && currentStage === 'enquiry';
|
||||
// Avoid referencing `params` in the JSX so the unused destructure passes
|
||||
// strict noUnused checks; it stays available for future deep-link hooks.
|
||||
void params;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Qualification</h3>
|
||||
{fullyQualified ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-700">
|
||||
<CheckCircle2 className="size-3.5" aria-hidden />
|
||||
All confirmed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{criteria.map((c) => (
|
||||
<li key={c.key} className="flex items-start gap-2.5">
|
||||
<Checkbox
|
||||
id={`qual-${c.key}`}
|
||||
checked={c.confirmed}
|
||||
disabled={toggleMutation.isPending}
|
||||
onCheckedChange={(v) =>
|
||||
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`qual-${c.key}`}
|
||||
className={cn(
|
||||
'flex-1 text-sm cursor-pointer',
|
||||
c.confirmed ? 'text-foreground' : 'text-foreground/90',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
{c.description ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
|
||||
) : null}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{showPromoteHint ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">
|
||||
<p className="text-xs text-emerald-800">
|
||||
All criteria confirmed — this lead is ready to qualify.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={promoteMutation.isPending}
|
||||
onClick={() => promoteMutation.mutate()}
|
||||
>
|
||||
Promote to Qualified
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
76
src/components/interests/skip-ahead-banner.tsx
Normal file
76
src/components/interests/skip-ahead-banner.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
|
||||
interface SkipAheadInterest {
|
||||
pipelineStage: string;
|
||||
dateEoiSent?: string | null;
|
||||
dateEoiSigned?: string | null;
|
||||
dateReservationSigned?: string | null;
|
||||
dateDepositReceived?: string | null;
|
||||
dateContractSent?: string | null;
|
||||
dateContractSigned?: string | null;
|
||||
eoiDocStatus?: string | null;
|
||||
reservationDocStatus?: string | null;
|
||||
contractDocStatus?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft banner that fires when a rep skip-advanced a deal past earlier
|
||||
* milestones without backfilling the matching dates / doc-status badges.
|
||||
*
|
||||
* Why we care: the funnel/conversion analytics rely on these timestamps to
|
||||
* compute how long deals sit in each stage. A deal that jumped straight to
|
||||
* deposit_paid with no dateEoiSent looks like a 0-day-EOI in the report,
|
||||
* which skews the cohort.
|
||||
*
|
||||
* The banner is informational only — no enforcement. Reps still have the
|
||||
* override path; we just nudge them to fill in the gaps.
|
||||
*/
|
||||
export function SkipAheadBanner({ interest }: { interest: SkipAheadInterest }) {
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
|
||||
if (stageIdx < 0) return null;
|
||||
const eoiIdx = PIPELINE_STAGES.indexOf('eoi');
|
||||
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
|
||||
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
|
||||
|
||||
const gaps: string[] = [];
|
||||
// Past EOI but never stamped sent → likely a skip.
|
||||
if (stageIdx > eoiIdx && !interest.dateEoiSent) gaps.push('EOI sent date');
|
||||
if (stageIdx > eoiIdx && interest.eoiDocStatus !== 'signed' && !interest.dateEoiSigned) {
|
||||
gaps.push('EOI signed date');
|
||||
}
|
||||
if (
|
||||
stageIdx > reservationIdx &&
|
||||
interest.reservationDocStatus !== 'signed' &&
|
||||
!interest.dateReservationSigned
|
||||
) {
|
||||
gaps.push('Reservation signed date');
|
||||
}
|
||||
if (stageIdx > depositIdx && !interest.dateDepositReceived) {
|
||||
gaps.push('Deposit received date');
|
||||
}
|
||||
|
||||
if (gaps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900"
|
||||
>
|
||||
<AlertCircle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{gaps.length === 1
|
||||
? 'A past milestone is missing its date.'
|
||||
: `${gaps.length} past milestones are missing their dates.`}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-800">
|
||||
Backfill {gaps.join(' · ')} below so reports show accurate cycle times.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/interests/supplemental-info-request-button.tsx
Normal file
102
src/components/interests/supplemental-info-request-button.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { ClipboardCopy, Mail } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Props {
|
||||
interestId: string;
|
||||
/** Hide the button when EOI has already been sent / signed — at that
|
||||
* point the supplemental step is past its window. Caller passes the
|
||||
* current eoiStatus so we can render contextually. */
|
||||
eoiStatus?: string | null;
|
||||
}
|
||||
|
||||
interface IssueResponse {
|
||||
data: {
|
||||
link: string;
|
||||
expiresAt: string;
|
||||
emailSent: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click "Request more info" action. Fires the supplemental-info-
|
||||
* request endpoint, which emails the client a public form pre-filled
|
||||
* with what's on file. On success we display the generated link + a
|
||||
* copy-to-clipboard button in case the rep needs to share it through
|
||||
* another channel.
|
||||
*
|
||||
* Hidden once the EOI is `signed` — the supplemental step only makes
|
||||
* sense before the signed EOI freezes the data into the contract path.
|
||||
*/
|
||||
export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) {
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<IssueResponse>(`/api/v1/interests/${interestId}/supplemental-info-request`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLink(res.data.link);
|
||||
if (res.data.emailSent) {
|
||||
toast.success('Email sent — link also shown below for sharing manually.');
|
||||
} else {
|
||||
toast.message('Link generated — no client email on file, share manually.');
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to generate the form link.'),
|
||||
});
|
||||
|
||||
if (eoiStatus === 'signed') return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email the client a one-time link to a public form pre-filled with what we have on file.
|
||||
Submissions auto-update this client + interest record.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
<Mail className="mr-1.5 size-3.5" aria-hidden />
|
||||
{mutation.isPending ? 'Generating…' : link ? 'Resend' : 'Request more info'}
|
||||
</Button>
|
||||
{link ? (
|
||||
<>
|
||||
<Input value={link} readOnly className="h-8 text-xs font-mono flex-1 min-w-[260px]" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(link);
|
||||
toast.success('Link copied');
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy className="mr-1.5 size-3.5" aria-hidden />
|
||||
Copy
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,9 @@ const SILENT_DAYS_THRESHOLD = 7;
|
||||
const EOI_AWAITING_DAYS_THRESHOLD = 14;
|
||||
const DEPOSIT_PENDING_DAYS_THRESHOLD = 21;
|
||||
|
||||
const ACTIVE_MID_FUNNEL_STAGES = new Set(['details_sent', 'in_communication']);
|
||||
// Mid-funnel = post-enquiry, pre-EOI. Surfaces the silent-deal warning so
|
||||
// reps notice deals stuck in qualifying/nurturing without recent contact.
|
||||
const ACTIVE_MID_FUNNEL_STAGES = new Set(['qualified', 'nurturing']);
|
||||
|
||||
export interface InterestUrgencyInput {
|
||||
pipelineStage: string;
|
||||
@@ -74,8 +76,11 @@ export function computeUrgencyBadges(row: InterestUrgencyInput): UrgencyBadge[]
|
||||
}
|
||||
}
|
||||
|
||||
// EOI signed but deposit not received.
|
||||
if (row.pipelineStage === 'eoi_signed' && !row.dateDepositReceived && row.dateEoiSent) {
|
||||
// EOI signed (or further along) but deposit not received yet. The deposit
|
||||
// is its own stage now; we trigger the warning while the deal is past EOI
|
||||
// signing but hasn't reached deposit_paid + has no dateDepositReceived.
|
||||
const eoiOrPast = row.pipelineStage === 'eoi' || row.pipelineStage === 'reservation';
|
||||
if (eoiOrPast && !row.dateDepositReceived && row.dateEoiSent) {
|
||||
const days = daysSince(row.dateEoiSent);
|
||||
if (days !== null && days >= DEPOSIT_PENDING_DAYS_THRESHOLD) {
|
||||
badges.push({
|
||||
|
||||
211
src/components/interests/won-status-panel.tsx
Normal file
211
src/components/interests/won-status-panel.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle2, FileUp, Trophy, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Won-status manual-upload panel.
|
||||
*
|
||||
* Renders only when the interest's outcome is `won`. Detects whether
|
||||
* a Contract / EOI / Other doc is already attached (via the existing
|
||||
* files endpoint) and shows an "Upload {missing}" slot for each one
|
||||
* that isn't there yet. Naturally hides itself once the rep has filed
|
||||
* everything.
|
||||
*
|
||||
* Reps that reached Won through the natural EOI → Contract → Deposit
|
||||
* chain will typically see all three slots already filled and the
|
||||
* panel collapses to a green confirmation strip.
|
||||
*/
|
||||
interface WonStatusPanelProps {
|
||||
interestId: string;
|
||||
/** Outcome of the interest. Panel hides unless 'won'. */
|
||||
outcome: string | null;
|
||||
}
|
||||
|
||||
interface FileRow {
|
||||
id: string;
|
||||
category: string | null;
|
||||
filename: string;
|
||||
originalName: string | null;
|
||||
}
|
||||
|
||||
interface FilesResponse {
|
||||
data: FileRow[];
|
||||
}
|
||||
|
||||
interface UploadSlot {
|
||||
key: 'contract' | 'eoi' | 'other';
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const SLOTS: UploadSlot[] = [
|
||||
{
|
||||
key: 'contract',
|
||||
label: 'Signed contract',
|
||||
description: 'Final purchase / lease agreement signed by both sides.',
|
||||
category: 'contract',
|
||||
},
|
||||
{
|
||||
key: 'eoi',
|
||||
label: 'Signed EOI',
|
||||
description: "Required only if you didn't run the EOI through the in-app signing flow.",
|
||||
category: 'eoi',
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: 'Other supporting docs',
|
||||
description: 'Insurance certificates, ID, anything else worth attaching to the closed deal.',
|
||||
category: 'other',
|
||||
},
|
||||
];
|
||||
|
||||
export function WonStatusPanel({ interestId, outcome }: WonStatusPanelProps) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Fetch the files attached to this interest so we can hide slots that
|
||||
// are already filled. The endpoint accepts `entityType` + `entityId`
|
||||
// for polymorphic ownership; non-interest files are filtered out.
|
||||
const { data } = useQuery<FilesResponse>({
|
||||
queryKey: ['interest-files', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/files?entityType=interest&entityId=${interestId}&limit=100`),
|
||||
enabled: outcome === 'won',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const existing = data?.data ?? [];
|
||||
|
||||
if (outcome !== 'won') return null;
|
||||
|
||||
const slots = SLOTS.map((s) => ({
|
||||
...s,
|
||||
files: existing.filter((f) => (f.category ?? '').toLowerCase() === s.category),
|
||||
}));
|
||||
|
||||
const allFilled = slots[0]!.files.length > 0 && slots[1]!.files.length > 0;
|
||||
|
||||
return (
|
||||
<Card className="border-emerald-200 bg-emerald-50/40">
|
||||
<CardHeader className="gap-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base text-emerald-900">
|
||||
<Trophy className="size-4" aria-hidden />
|
||||
Won — wrap-up checklist
|
||||
</CardTitle>
|
||||
<p className="text-xs text-emerald-800/80">
|
||||
Upload anything that didn't flow through the system automatically. Reservations,
|
||||
deposit invoicing, and client billing are handled outside the CRM — this checklist is for
|
||||
the paperwork that lives on the deal itself.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{slots.map((s) => (
|
||||
<UploadSlotRow
|
||||
key={s.key}
|
||||
slot={s}
|
||||
interestId={interestId}
|
||||
onUploaded={() => qc.invalidateQueries({ queryKey: ['interest-files', interestId] })}
|
||||
/>
|
||||
))}
|
||||
{allFilled ? (
|
||||
<p className="pt-1 text-xs text-emerald-800/80 italic">
|
||||
All required documents are attached. Anything else you upload here will appear in the
|
||||
client's signed-docs folder.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadSlotRow({
|
||||
slot,
|
||||
interestId,
|
||||
onUploaded,
|
||||
}: {
|
||||
slot: UploadSlot & { files: FileRow[] };
|
||||
interestId: string;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const filled = slot.files.length > 0;
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('entityType', 'interest');
|
||||
fd.append('entityId', interestId);
|
||||
fd.append('category', slot.category);
|
||||
const res = await fetch('/api/v1/files/upload', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
throw new Error(payload.error?.message ?? `Upload failed (${res.status})`);
|
||||
}
|
||||
},
|
||||
onMutate: () => setUploading(true),
|
||||
onSuccess: () => {
|
||||
toast.success(`${slot.label} uploaded`);
|
||||
onUploaded();
|
||||
},
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Upload failed'),
|
||||
onSettled: () => setUploading(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
filled ? 'border-emerald-300 bg-white' : 'border-input bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{filled ? <CheckCircle2 className="size-4 text-emerald-600" aria-hidden /> : null}
|
||||
{slot.label}
|
||||
{filled ? (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
({slot.files.length} on file)
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{slot.description}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filled ? 'outline' : 'default'}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<FileUp className="mr-1.5 size-3.5" aria-hidden />
|
||||
)}
|
||||
{filled ? 'Add another' : 'Upload'}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) upload.mutate(f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -363,7 +363,7 @@ function SidebarContent({
|
||||
</ScrollArea>
|
||||
|
||||
{/* User footer - entire row is the trigger for the UserMenu so the
|
||||
user can click their name/avatar to access Profile / Settings /
|
||||
user can click their name/avatar to access Settings /
|
||||
port-switcher / sign-out. The same UserMenu component drives the
|
||||
top-right avatar dropdown, so the menu items stay consistent. */}
|
||||
<div className={cn('border-t border-slate-200 p-2', collapsed && 'flex justify-center')}>
|
||||
|
||||
@@ -160,28 +160,36 @@ function ReminderFormBody({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Reminder' : 'New Reminder'}</SheetTitle>
|
||||
<SheetTitle>{isEdit ? 'Edit reminder' : 'New reminder'}</SheetTitle>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Reminders are personal nudges — a follow-up call, a note to yourself, or something a
|
||||
teammate needs to action by a date. They show up in your dashboard, the daily digest
|
||||
email, and on whichever client / interest / berth you link them to.
|
||||
</p>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-title">Title</Label>
|
||||
<Label htmlFor="reminder-title">What's the reminder for?</Label>
|
||||
<Input
|
||||
id="reminder-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Follow up with client..."
|
||||
placeholder="e.g. Follow up about EOI, Check insurance docs"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Short label so future-you knows what this is at a glance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-note">Note</Label>
|
||||
<Label htmlFor="reminder-note">Note (optional)</Label>
|
||||
<Textarea
|
||||
id="reminder-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Additional details..."
|
||||
placeholder="Anything else you want to remember when this fires…"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -241,10 +249,13 @@ function ReminderFormBody({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Link to entity (optional)</Label>
|
||||
<Label className="text-xs text-muted-foreground">Attach to client / deal / berth</Label>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Pick a client first to scope the interest and berth dropdowns to that client's
|
||||
deals.
|
||||
Linking a reminder pins it onto that record so anyone who opens the page sees it on
|
||||
the Reminders tab. Useful for “chase this client for signed EOI”,
|
||||
“recheck B12 power capacity before contract”, etc. Pick a client first to
|
||||
scope the interest and berth dropdowns to that client's deals. Leaving these
|
||||
blank keeps the reminder private to you on your dashboard only.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<ClientPicker
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { WhatsAppIcon } from '@/components/icons/whatsapp';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -158,10 +159,11 @@ export function ResidentialClientsList() {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="WhatsApp"
|
||||
aria-label="Message on WhatsApp"
|
||||
className="text-emerald-600 hover:text-emerald-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
WA
|
||||
<WhatsAppIcon className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -875,13 +875,19 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
|
||||
|
||||
if (include('clients')) {
|
||||
for (const c of results.clients) {
|
||||
// Prefer the actual matched-contact value (real email/phone the
|
||||
// query hit) over a generic "matched on X" label. Falls back to
|
||||
// the typed matchedOn when no value is available (e.g. trigram
|
||||
// similarity match on the name itself).
|
||||
let sub: string | null = c.matchedContact ?? null;
|
||||
if (!sub && c.matchedOn) sub = `matched on ${c.matchedOn}`;
|
||||
rows.push({
|
||||
kind: 'result',
|
||||
key: `clients:${c.id}`,
|
||||
bucket: 'clients',
|
||||
icon: User,
|
||||
label: c.fullName,
|
||||
sub: c.matchedContact ?? null,
|
||||
sub,
|
||||
href: `/${portSlug}/clients/${c.id}`,
|
||||
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
|
||||
relatedVia: c.relatedVia ?? null,
|
||||
|
||||
@@ -25,6 +25,30 @@ interface BerthOption {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group berth options by area letter extracted from the canonical mooring
|
||||
* format `^[A-Z]+\d+$` (A1, B12, etc). Falls back to a single bucket
|
||||
* keyed by empty string when no letter is present so callers still see
|
||||
* every row. Sorts by area letter then natural-numeric within each group
|
||||
* so A1, A2, A10 reads in human order rather than lexicographic.
|
||||
*/
|
||||
export function groupOptionsByArea(options: BerthOption[]): [string, BerthOption[]][] {
|
||||
const map = new Map<string, BerthOption[]>();
|
||||
for (const o of options) {
|
||||
const m = o.mooringNumber.match(/^([A-Z]+)/);
|
||||
const key = m?.[1] ?? '';
|
||||
const bucket = map.get(key) ?? [];
|
||||
bucket.push(o);
|
||||
map.set(key, bucket);
|
||||
}
|
||||
// Natural sort within bucket: split letter prefix from number suffix.
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
for (const bucket of map.values()) {
|
||||
bucket.sort((a, b) => collator.compare(a.mooringNumber, b.mooringNumber));
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
interface BerthPickerProps {
|
||||
value: string | null;
|
||||
onChange: (berthId: string | null) => void;
|
||||
@@ -117,6 +141,9 @@ export function BerthPicker({
|
||||
const labelFor = (o: BerthOption) =>
|
||||
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
|
||||
|
||||
// Group helper outside render so memoization works; takes/returns plain
|
||||
// values so the same logic plugs into linked-berths and recommender pickers later.
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
const match = options.find((o) => o.id === value);
|
||||
@@ -150,8 +177,8 @@ export function BerthPicker({
|
||||
<CommandEmpty>
|
||||
{clientId ? 'No berths linked to this client.' : 'No berths found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{value ? (
|
||||
{value ? (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
@@ -162,23 +189,27 @@ export function BerthPicker({
|
||||
>
|
||||
Clear selection
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={o.id}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{labelFor(o)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{groupOptionsByArea(options).map(([letter, group]) => (
|
||||
<CommandGroup key={letter || '_'} heading={letter || 'Other'}>
|
||||
{group.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
value={o.id}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === o.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">{labelFor(o)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -15,8 +15,20 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { CurrencySelect } from '@/components/shared/currency-select';
|
||||
import type { CountryCode } from '@/lib/i18n/countries';
|
||||
|
||||
export type FilterType = 'text' | 'select' | 'multi-select' | 'date-range' | 'boolean' | 'relation';
|
||||
export type FilterType =
|
||||
| 'text'
|
||||
| 'select'
|
||||
| 'multi-select'
|
||||
| 'date-range'
|
||||
| 'date'
|
||||
| 'boolean'
|
||||
| 'relation'
|
||||
| 'currency'
|
||||
| 'country';
|
||||
|
||||
export interface FilterOption {
|
||||
label: string;
|
||||
@@ -256,6 +268,43 @@ function FilterField({
|
||||
);
|
||||
}
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{definition.label}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'currency':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{definition.label}</Label>
|
||||
<CurrencySelect
|
||||
value={(value as string) ?? undefined}
|
||||
onValueChange={(v) => onChange(v || undefined)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'country':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{definition.label}</Label>
|
||||
<CountryCombobox
|
||||
value={(value as CountryCode | null) ?? null}
|
||||
onChange={(c) => onChange(c ?? undefined)}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface InlineTagEditorProps {
|
||||
invalidateKey: readonly unknown[];
|
||||
/** Hide the "+ Add tag" button (read-only mode). */
|
||||
readOnly?: boolean;
|
||||
/** Optional section heading rendered above the chips. When supplied and
|
||||
* there are no tags configured port-wide AND none currently applied,
|
||||
* the entire block (heading + editor) hides — keeps detail pages clean
|
||||
* for ports that haven't set up tagging. */
|
||||
heading?: string;
|
||||
/** Optional wrapper class applied around heading + editor. */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function InlineTagEditor({
|
||||
@@ -31,15 +38,20 @@ export function InlineTagEditor({
|
||||
currentTags,
|
||||
invalidateKey,
|
||||
readOnly,
|
||||
heading,
|
||||
wrapperClassName,
|
||||
}: InlineTagEditorProps) {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Always fetch so we can hide the editor entirely when no tags are
|
||||
// configured AND the entity has no tags already applied — keeps the
|
||||
// detail page clean for ports that haven't set up tagging yet. The
|
||||
// list is cheap, port-scoped, and cached for a minute.
|
||||
const { data: allTags } = useQuery<{ data: Tag[] }>({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => apiFetch('/api/v1/tags'),
|
||||
staleTime: 60_000,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const setTags = useMutation({
|
||||
@@ -60,7 +72,15 @@ export function InlineTagEditor({
|
||||
setTags.mutate(currentTags.filter((t) => t.id !== tagId).map((t) => t.id));
|
||||
}
|
||||
|
||||
return (
|
||||
// Hide the whole editor when the port has no tags configured AND this
|
||||
// entity has none applied. Once an admin adds the first tag in
|
||||
// Admin → Tags, the editor reappears on next mount/refetch.
|
||||
const portHasNoTags = allTags && allTags.data.length === 0;
|
||||
if (portHasNoTags && currentTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{currentTags.map((t) => (
|
||||
<span
|
||||
@@ -129,4 +149,13 @@ export function InlineTagEditor({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!heading) return editor;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', wrapperClassName)}>
|
||||
<h3 className="text-sm font-medium mb-2">{heading}</h3>
|
||||
{editor}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
160
src/components/shared/user-picker.tsx
Normal file
160
src/components/shared/user-picker.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker over the current port's users. Stores either a user ID (when
|
||||
* a user is selected) or a plain string (when "Other..." is chosen and
|
||||
* a custom name is typed). Callers pass `value` as a plain string and
|
||||
* the picker maps it back to a user when one matches the id.
|
||||
*
|
||||
* Used by the expense form where the payer can be either a staff member
|
||||
* or an external party (vendor employee paying the bill, etc.).
|
||||
*/
|
||||
export function UserPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select user…',
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
value: string | null | undefined;
|
||||
onChange: (next: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [otherMode, setOtherMode] = useState(false);
|
||||
const { data } = useQuery<{ data: UserOption[] }>({
|
||||
queryKey: ['user-options'],
|
||||
queryFn: () => apiFetch('/api/v1/admin/users/options'),
|
||||
staleTime: 5 * 60_000,
|
||||
// Don't fetch until the popover opens — keeps the page light when
|
||||
// most reps never expand this field.
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const users = data?.data ?? [];
|
||||
const matched = value ? users.find((u) => u.id === value) : null;
|
||||
|
||||
// When the stored value isn't one of the fetched users' ids, treat it
|
||||
// as a free-text payer name (the "Other..." path).
|
||||
const displayLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
if (matched) return matched.displayName ?? matched.id.slice(0, 8);
|
||||
return value;
|
||||
})();
|
||||
|
||||
if (otherMode) {
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Custom payer name"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOtherMode(false);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
Pick user
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between', !value && 'text-muted-foreground', className)}
|
||||
>
|
||||
<span className="truncate">{displayLabel}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search users…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((u) => (
|
||||
<CommandItem
|
||||
key={u.id}
|
||||
value={u.displayName ?? u.id}
|
||||
onSelect={() => {
|
||||
onChange(u.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === u.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{u.displayName ?? u.id.slice(0, 8)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Or">
|
||||
<CommandItem
|
||||
value="__other__"
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
setOtherMode(true);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
Other…
|
||||
</CommandItem>
|
||||
{value && !matched ? (
|
||||
<CommandItem
|
||||
value="__clear__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear (currently: {value})
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,9 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
// Centered fade + subtle zoom-in (no slide-from-corner — drops
|
||||
// the jarring fly-from-top-left effect the Dialog primitive had).
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -25,7 +25,11 @@ const PopoverContent = React.forwardRef<
|
||||
// on narrow viewports the calc() ceiling kicks in.
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'z-50 w-[min(calc(100vw-2rem),18rem)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)',
|
||||
// max-h pinned to Radix's available-height var so tall popovers
|
||||
// (multi-field filter panels, long picker lists) scroll inside
|
||||
// the popover instead of overflowing below the viewport. The
|
||||
// overflow-y-auto pairs with it so scroll actually engages.
|
||||
'z-50 max-h-[var(--radix-popover-content-available-height)] w-[min(calc(100vw-2rem),18rem)] overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -79,6 +79,7 @@ export function YachtList() {
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
setAllFilters,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<YachtRow>({
|
||||
queryKey: ['yachts'],
|
||||
@@ -133,8 +134,7 @@ export function YachtList() {
|
||||
<SavedViewsDropdown
|
||||
entityType="yachts"
|
||||
onApplyView={(savedFilters, _savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
setAllFilters(savedFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -234,15 +234,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Tags</h3>
|
||||
<InlineTagEditor
|
||||
endpoint={`/api/v1/yachts/${yachtId}/tags`}
|
||||
currentTags={yacht.tags ?? []}
|
||||
invalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/yachts/${yachtId}/tags`}
|
||||
currentTags={yacht.tags ?? []}
|
||||
invalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user