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:
2026-05-14 03:39:21 +02:00
parent b10bf9bf8e
commit 6b28459c45
110 changed files with 5402 additions and 796 deletions

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);
}

View 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>
);
}