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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user