fix(clearance): allow customs risk to be corrected and keep the trail

RiskStep returned early to a badge as soon as a risk level existed, so the
control was unreachable and a mis-assigned level could never be corrected.
Both the server and the sibling AssignRiskCard treat risk as correctable
until duty is advised off it — completeWithMetadata has no already-completed
guard and overwrites metadata.riskLevel. RiskStep was stricter than either.

It now keeps the control mounted alongside the assigned badge, offers
"Reassign risk", and locks to badge-only once DUTY_TAXES_ADVISED completes.
The control also reads the persisted level (it was hardcoded to GREEN, so
unhiding it alone would have misreported the assignment), and the T1 gate is
skipped once a level exists, since risk cannot be assigned without a closed
T1 and stale T1 data must not hide the badge.

Correcting a level previously left no record of the old value, who changed
it, or when — thin ground for a customer-visible level that may be disputed.
assignRisk now appends each decision to metadata.riskHistory: the level, the
level it replaced, the timestamp, the user id, and a display name resolved
at assignment time so the trail shows a person rather than a UUID. riskLevel
still carries the current value and always equals the last entry, so
existing consumers are unchanged.

History lives on the existing metadata JSONB column, so no migration is
needed, and the logic sits in assignRisk rather than the shared
completeWithMetadata that adviseDuty and others also use. Re-picking the
level already in force is not recorded — it changed nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-20 07:04:18 +00:00
parent 21df861979
commit 536d6043c2
9 changed files with 241 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "riskHistory"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
@@ -1040,11 +1041,28 @@ function RiskStep({
done: boolean;
onChanged?: () => void;
}) {
const [level, setLevel] = useState<string>("GREEN");
const assigned = done || Boolean(clearance.riskLevel);
// Duty is advised off the risk level, so once that is done the decision is
// final. Until then a mis-assigned level must stay correctable — the server
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
// The clearance view loads (and refetches after a reassignment) after first
// render, so mirror the persisted level onto the control whenever it changes —
// otherwise reopening the step offers GREEN whatever is actually assigned.
useEffect(() => {
if (clearance.riskLevel) setLevel(clearance.riskLevel);
}, [clearance.riskLevel]);
// Only the decisions before the current one — the badge above already states
// the level in force, so repeating it as a trail entry reads as a duplicate.
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
const assignedSummary = assigned ? (
<Stack gap={6}>
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
@@ -1061,12 +1079,35 @@ function RiskStep({
. The customer can see this level.
</Text>
</Group>
);
{priorDecisions.length > 0 ? (
<Stack gap={2} pl="xs">
<Text size="xs" c="dimmed" fw={600}>
Previously
</Text>
{priorDecisions.map((entry, index) => (
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
{entry.level}
{" · "}
{new Date(entry.assignedAt).toLocaleString()}
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
{entry.note ? ` · ${entry.note}` : ""}
</Text>
))}
</Stack>
) : null}
</Stack>
) : null;
// Assigned and final: the badge is all that is left to show.
if (assigned && (locked || !canAct || !bookingId)) {
return assignedSummary;
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet.
if (!clearance.t1?.closed) {
// assignment until the T1 is closed, so do not offer the control yet. Skipped
// once a level exists: risk cannot have been assigned without a closed T1, so
// a still-open T1 here is stale data and must not hide the assigned badge.
if (!assigned && !clearance.t1?.closed) {
return (
<StepStatus
done={false}
@@ -1088,6 +1129,7 @@ function RiskStep({
return (
<Stack gap="sm">
{assignedSummary}
<SegmentedControl
fullWidth
value={level}
@@ -1100,19 +1142,24 @@ function RiskStep({
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
{assigned
? "Correctable until duty is advised. The customer sees the assigned risk level."
: "The customer sees the assigned risk level."}
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
@@ -1121,7 +1168,7 @@ function RiskStep({
}
}}
>
Assign risk
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Stack>