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