Drain the long-tail audit queue captured in alpha-uat-master.md.
- next-intl ripped out (zero useTranslations callers ever existed):
package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
border-r/rounded-l-/rounded-r-) inside JSX className literals.
Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
label enabled at warn; 4 empty <th>/<td> action placeholders gain
sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
"Payments - Not received yet" or "Payments - \$X received - N payments
- Expand"; per-interest collapse state persists in localStorage; the
RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
text-muted-foreground/{60,70,80} hits dropped to plain
text-muted-foreground for AA contrast on muted bg. Icon-only
(aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
rewritten with cumulative state through today. Items genuinely still
open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
pixel E2E verification, and website-cutover work parked here so
they don't get lost in the CRM audit doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
8.7 KiB
TypeScript
232 lines
8.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useParams } from 'next/navigation';
|
||
import { CheckCircle2, ChevronDown, 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;
|
||
autoSatisfied: boolean;
|
||
/** Human-readable explanation of what data drove auto-satisfaction
|
||
* (e.g. "Desired: 60 × 25 × 6 ft"). Empty when not auto-satisfied. */
|
||
evidence: string;
|
||
}
|
||
|
||
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();
|
||
// 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>({
|
||
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';
|
||
// 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
|
||
// 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">
|
||
<button
|
||
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 ? (
|
||
<span className="inline-flex items-center gap-1 text-xs text-emerald-700">
|
||
<CheckCircle2 className="size-3.5" aria-hidden />
|
||
All confirmed
|
||
{!expanded && criteria.length > 0 ? (
|
||
<span className="ml-1 text-muted-foreground">
|
||
({criteria.map((c) => c.label.toLowerCase()).join(' · ')})
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-muted-foreground">
|
||
{criteria.filter((c) => c.confirmed).length} of {criteria.length} confirmed
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{expanded ? (
|
||
<ul id="qualification-checklist-body" className="space-y-1.5">
|
||
{criteria.map((c) => (
|
||
<li
|
||
key={c.key}
|
||
className={cn(
|
||
'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(
|
||
'flex-1 text-sm',
|
||
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
|
||
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
|
||
)}
|
||
>
|
||
<span className="flex flex-wrap items-center gap-1.5">
|
||
<span
|
||
className={cn(
|
||
'font-medium',
|
||
c.confirmed && 'line-through text-muted-foreground',
|
||
)}
|
||
>
|
||
{c.label}
|
||
</span>
|
||
{c.autoSatisfied && (
|
||
<span
|
||
className="rounded bg-emerald-100 px-1.5 py-0.5 text-xs 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 ? (
|
||
<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>
|
||
);
|
||
}
|