feat(uat-batch-8): qualification rework — intent auto-confirm + derived-only + collapse-when-done

Three coordinated changes to the per-interest qualification checklist
that collectively trim it from a noisy gate into an out-of-the-way
audit log once the deal moves forward.

- Auto-confirm `intent_confirmed` once `pipelineStage > qualified`.
  Signing an EOI (or later) is the strongest signal of intent; the
  checklist no longer requires a redundant explicit tick. Evidence
  string reads "Stage advanced past Qualified".
- `dimensions` becomes derived-only — explicit ticks no longer
  override removed evidence. When the rep deletes a yacht link or
  clears desired dims, the row un-ticks immediately. Judgement-based
  criteria keep the OR semantic so a manual confirmation survives an
  evidence change.
- Checklist auto-collapses when fully confirmed: header shows ✓ All
  confirmed (label · label) with a chevron; rep clicks to expand and
  inspect or untick. Forced-expanded whenever an item is still
  outstanding. ARIA-controlled.
- `qualification.service` gains a `pipelineStage` column-select and
  threads it through `AutoCtx`; `DERIVED_ONLY_KEYS` Set sentinel
  drives the new merge semantic.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 17:47:38 +02:00
parent b9d388a362
commit 51ca875665
2 changed files with 147 additions and 90 deletions

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { CheckCircle2, ChevronRight } from 'lucide-react'; import { CheckCircle2, ChevronDown, ChevronRight } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -49,6 +50,10 @@ export function QualificationChecklist({
}) { }) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// When the checklist is fully confirmed, default to collapsed and let
// the rep expand on demand. `null` means "follow the auto-default";
// an explicit boolean reflects a rep click.
const [manuallyExpanded, setManuallyExpanded] = useState(false);
const { data, isLoading } = useQuery<QualificationResponse>({ const { data, isLoading } = useQuery<QualificationResponse>({
queryKey: ['interest-qualifications', interestId], queryKey: ['interest-qualifications', interestId],
@@ -87,88 +92,122 @@ export function QualificationChecklist({
const fullyQualified = data.data.fullyQualified; const fullyQualified = data.data.fullyQualified;
const showPromoteHint = fullyQualified && currentStage === 'enquiry'; const showPromoteHint = fullyQualified && currentStage === 'enquiry';
// Auto-collapse when fully confirmed — rep can expand to inspect.
// Force-expanded whenever there's still an outstanding item.
const expanded = manuallyExpanded || !fullyQualified;
// Avoid referencing `params` in the JSX so the unused destructure passes // Avoid referencing `params` in the JSX so the unused destructure passes
// strict noUnused checks; it stays available for future deep-link hooks. // strict noUnused checks; it stays available for future deep-link hooks.
void params; void params;
return ( return (
<section className="rounded-lg border bg-card/40 p-4 space-y-3"> <section className="rounded-lg border bg-card/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3"> <button
<h3 className="text-sm font-semibold">Qualification</h3> type="button"
onClick={() => fullyQualified && setManuallyExpanded((v) => !v)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
fullyQualified ? 'cursor-pointer' : 'cursor-default',
)}
aria-expanded={expanded}
aria-controls="qualification-checklist-body"
disabled={!fullyQualified}
>
<span className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Qualification</h3>
{fullyQualified && (
<ChevronDown
className={cn(
'size-3.5 text-muted-foreground transition-transform',
!expanded && '-rotate-90',
)}
aria-hidden
/>
)}
</span>
{fullyQualified ? ( {fullyQualified ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-700"> <span className="inline-flex items-center gap-1 text-xs text-emerald-700">
<CheckCircle2 className="size-3.5" aria-hidden /> <CheckCircle2 className="size-3.5" aria-hidden />
All confirmed All confirmed
{!expanded && criteria.length > 0 ? (
<span className="ml-1 text-muted-foreground">
({criteria.map((c) => c.label.toLowerCase()).join(' · ')})
</span>
) : null}
</span> </span>
) : ( ) : (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed {criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed
</span> </span>
)} )}
</div> </button>
<ul className="space-y-1.5"> {expanded ? (
{criteria.map((c) => ( <ul id="qualification-checklist-body" className="space-y-1.5">
<li {criteria.map((c) => (
key={c.key} <li
className={cn( key={c.key}
'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
// Unconfirmed rows get a subtle amber accent (left border +
// tinted background) so reps can scan the checklist and
// immediately see what's outstanding. Confirmed rows stay
// muted with line-through; auto-satisfied rows are functionally
// confirmed and follow the confirmed styling.
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
)}
>
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
// Auto-satisfied rows can't be unchecked from the UI — the
// underlying data signal would just re-tick the box on the next
// refetch. The rep clears the dimensions tick by removing the
// yacht dims or desired-berth dims from the interest.
disabled={toggleMutation.isPending || c.autoSatisfied}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
className="mt-0.5"
/>
<label
htmlFor={`qual-${c.key}`}
className={cn( className={cn(
'flex-1 text-sm', 'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer', // Unconfirmed rows get a subtle amber accent (left border +
c.confirmed ? 'text-muted-foreground' : 'text-foreground', // tinted background) so reps can scan the checklist and
// immediately see what's outstanding. Confirmed rows stay
// muted with line-through; auto-satisfied rows are functionally
// confirmed and follow the confirmed styling.
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
)} )}
> >
<span className="flex flex-wrap items-center gap-1.5"> <Checkbox
<span id={`qual-${c.key}`}
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')} checked={c.confirmed}
> // Auto-satisfied rows can't be unchecked from the UI — the
{c.label} // underlying data signal would just re-tick the box on the next
</span> // refetch. The rep clears the dimensions tick by removing the
{c.autoSatisfied && ( // yacht dims or desired-berth dims from the interest.
<span disabled={toggleMutation.isPending || c.autoSatisfied}
className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" onCheckedChange={(v) =>
title="System-derived from data on this interest" toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
> }
Auto className="mt-0.5"
</span> />
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
)} )}
</span> >
{c.description ? ( <span className="flex flex-wrap items-center gap-1.5">
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p> <span
) : null} className={cn(
{c.autoSatisfied && c.evidence ? ( 'font-medium',
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300"> c.confirmed && 'line-through text-muted-foreground',
{c.evidence} )}
</p> >
) : null} {c.label}
</label> </span>
</li> {c.autoSatisfied && (
))} <span
</ul> className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
title="System-derived from data on this interest"
>
Auto
</span>
)}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
) : null}
{c.autoSatisfied && c.evidence ? (
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
{c.evidence}
</p>
) : null}
</label>
</li>
))}
</ul>
) : null}
{showPromoteHint ? ( {showPromoteHint ? (
<div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2"> <div className="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2">

View File

@@ -14,6 +14,7 @@ import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema'; import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server'; import { emitToRoom } from '@/lib/socket/server';
import type { import type {
@@ -261,6 +262,7 @@ export async function listInterestQualifications(
desiredLengthFt: true, desiredLengthFt: true,
desiredWidthFt: true, desiredWidthFt: true,
desiredDraftFt: true, desiredDraftFt: true,
pipelineStage: true,
}, },
}); });
if (!interest) throw new NotFoundError('Interest'); if (!interest) throw new NotFoundError('Interest');
@@ -295,36 +297,34 @@ export async function listInterestQualifications(
return criteria.map((c) => { return criteria.map((c) => {
const s = stateByKey.get(c.key); const s = stateByKey.get(c.key);
const autoSatisfied = computeAutoSatisfied(c.key, { const ctx = {
yachtDims, yachtDims,
desiredDims: { desiredDims: {
lengthFt: interest.desiredLengthFt ?? null, lengthFt: interest.desiredLengthFt ?? null,
widthFt: interest.desiredWidthFt ?? null, widthFt: interest.desiredWidthFt ?? null,
draftFt: interest.desiredDraftFt ?? null, draftFt: interest.desiredDraftFt ?? null,
}, },
}); pipelineStage: (interest.pipelineStage ?? 'enquiry') as PipelineStage,
};
const autoSatisfied = computeAutoSatisfied(c.key, ctx);
const explicit = s?.confirmed ?? false; const explicit = s?.confirmed ?? false;
const evidence = autoSatisfied const evidence = autoSatisfied ? computeEvidence(c.key, ctx) : '';
? computeEvidence(c.key, { // Derived-only criteria (e.g. `dimensions`) ignore the explicit tick
yachtDims, // entirely — if the underlying evidence disappears, the row un-ticks.
desiredDims: { // Judgement-based criteria keep the OR semantic so a rep's explicit
lengthFt: interest.desiredLengthFt ?? null, // confirmation survives an evidence change.
widthFt: interest.desiredWidthFt ?? null, const confirmed = isDerivedOnly(c.key) ? autoSatisfied : explicit || autoSatisfied;
draftFt: interest.desiredDraftFt ?? null,
},
})
: '';
return { return {
key: c.key, key: c.key,
label: c.label, label: c.label,
description: c.description, description: c.description,
enabled: c.enabled, enabled: c.enabled,
displayOrder: c.displayOrder, displayOrder: c.displayOrder,
// Surface ticked state when either signal is true. Explicit confirmation // Surface ticked state per `confirmed` above. Explicit confirmation
// still gets its confirmedAt/By stamps; auto-satisfied state leaves // still gets its confirmedAt/By stamps; auto-satisfied state leaves
// those null so the rep can see "this was system-derived, not an // those null so the rep can see "this was system-derived, not an
// explicit sign-off". // explicit sign-off".
confirmed: explicit || autoSatisfied, confirmed,
confirmedAt: s?.confirmedAt ?? null, confirmedAt: s?.confirmedAt ?? null,
confirmedBy: s?.confirmedBy ?? null, confirmedBy: s?.confirmedBy ?? null,
notes: s?.notes ?? null, notes: s?.notes ?? null,
@@ -339,13 +339,26 @@ export async function listInterestQualifications(
* (and any linked records) and decide whether the criterion is satisfied * (and any linked records) and decide whether the criterion is satisfied
* without an explicit rep tick. Add new rules by branching on `key`. * without an explicit rep tick. Add new rules by branching on `key`.
*/ */
function computeAutoSatisfied( interface AutoCtx {
key: string, yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
ctx: { desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null; pipelineStage: PipelineStage;
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null }; }
},
): boolean { /**
* Keys whose `confirmed` state should be purely derived (no explicit
* tick respected). Removing the underlying evidence un-ticks the row.
* Compare with keys carrying real rep judgement (e.g. intent_confirmed
* before auto-derivation kicks in), which retain explicit-vs-auto OR
* semantics.
*/
const DERIVED_ONLY_KEYS: ReadonlySet<string> = new Set(['dimensions']);
function isDerivedOnly(key: string): boolean {
return DERIVED_ONLY_KEYS.has(key);
}
function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean {
if (key === 'dimensions') { if (key === 'dimensions') {
const hasYachtDims = const hasYachtDims =
!!ctx.yachtDims && !!ctx.yachtDims &&
@@ -356,6 +369,14 @@ function computeAutoSatisfied(
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt; !!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
return hasYachtDims || hasDesiredDims; return hasYachtDims || hasDesiredDims;
} }
if (key === 'intent_confirmed') {
// Signing an EOI (or later) is the strongest signal of intent —
// auto-tick once the rep has moved past Qualified. The criterion
// can still be ticked manually before then.
const stageIdx = PIPELINE_STAGES.indexOf(ctx.pipelineStage);
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
return stageIdx > qualifiedIdx;
}
return false; return false;
} }
@@ -364,13 +385,7 @@ function computeAutoSatisfied(
* auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI * auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
* can render "Auto · <evidence>" — closes the "why is this ticked?" gap. * can render "Auto · <evidence>" — closes the "why is this ticked?" gap.
*/ */
function computeEvidence( function computeEvidence(key: string, ctx: AutoCtx): string {
key: string,
ctx: {
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
},
): string {
if (key === 'dimensions') { if (key === 'dimensions') {
const hasYacht = const hasYacht =
!!ctx.yachtDims && !!ctx.yachtDims &&
@@ -387,6 +402,9 @@ function computeEvidence(
} }
return ''; return '';
} }
if (key === 'intent_confirmed') {
return 'Stage advanced past Qualified';
}
return ''; return '';
} }