Skip to content

Commit 3480d88

Browse files
committed
feat(tms): capability profiles with graduated enforcement and a deviation ledger
Phase 0 of the multi-mode expansion. Introduces the mode abstraction that later modes (flatbed, bulk/tanker, drayage, final mile) build on, with zero behavior change for existing organizations. A "mode" is a preset of capability flags over three orthogonal axes — service model, equipment class, execution party — rather than an enum on Shipment. Customers run combinations (dedicated reefer, hazmat bulk, flatbed heavy haul), so a single mode field would branch combinatorially by the second mode. Three design commitments: Graduated enforcement replaces boolean feature flags. Every capability rule carries tenant.EnforcementLevel (Ignore/Warn/RequireReview/Block) instead of an on/off switch. Defaults ship at Block so the system drives the process; an organization dials a specific rule down without forking anything. Warn and RequireReview results are recorded, not silenced. They become Deviation records acknowledged with a reason, producing a queryable ledger of where an organization departs from the standard process and why. Resolved policy carries provenance. ShipmentUIPolicy now returns which profile produced each rule, at what enforcement, whether it was overridden and why, plus the rejected candidate profiles and the reason each lost. The UI can answer "why is this required" inline instead of leaving users to guess. Resolution mirrors detention.Policy exactly (priority, specificity score, effective-date windows) rather than introducing a second algorithm. Capability rules compose into the existing validationframework as conditioned rules; no new validation engine. Field descriptors reuse customfield's FieldType, ValidationRules, and UIAttributes. errortypes.MultiError gains an Advisories slice, additive so HasErrors() and every existing validator are untouched. Enforcement migrated to profile-authoritative: cargo.maxShipmentWeight, dispatch.moveRemoval, and the new cargo.temperatureRange. Hazmat segregation and duplicate-BOL are represented in the resolved policy for explainability but keep ShipmentControl enforcement; they migrate in Phase 1. Zero behavior change is enforced two ways: when no profile resolves the validator falls back to ShipmentControl, and the migration backfills one org-default profile per organization with enforcement derived from that organization's existing shipment_controls row. The new temperature rule backfills at Ignore. Client: capability helpers and the explainer live in packages/shared so both apps consume them. ShipmentGeneralInformation is the first converted section — temperature fields now render only when the profile declares TemperatureControl, so the form gets shorter as modes are added rather than longer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01559YzTFTKd3vV6eJWmSDzi
1 parent 4a8620b commit 3480d88

36 files changed

Lines changed: 5191 additions & 102 deletions

File tree

client/apps/web/src/routes/shipment/_components/shipment-general-information.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { InputField } from "@/components/fields/input-field";
22
import { NumberField } from "@/components/fields/number-field";
3+
import { CapabilityExplainer } from "@trenova/shared/components/capability-explainer";
34
import { FormControl, FormGroup, FormSection } from "@trenova/shared/components/ui/form";
5+
import {
6+
CAPABILITIES,
7+
getProfile,
8+
isFieldRequired,
9+
isCapabilitySectionVisible,
10+
} from "@trenova/shared/lib/capability";
411
import { ApiRequestError } from "@trenova/shared/lib/api";
512
import { queries } from "@/lib/queries";
613
import { apiService } from "@/services/api";
@@ -23,31 +30,47 @@ function Inner({ children }: { children: React.ReactNode }) {
2330

2431
export default function ShipmentGeneralInformation() {
2532
const { control } = useFormContext<Shipment>();
33+
const { data: shipmentUIPolicy } = useQuery({ ...queries.shipment.uiPolicy() });
34+
35+
const profile = getProfile(shipmentUIPolicy);
36+
const showTemperature = isCapabilitySectionVisible(
37+
profile,
38+
CAPABILITIES.temperatureControl,
39+
);
40+
const temperatureRequired = isFieldRequired(profile, "temperatureMin");
2641

2742
return (
2843
<Inner>
2944
<FormGroup cols={2}>
3045
<BOLField />
31-
<FormControl>
32-
<NumberField
33-
control={control}
34-
name="temperatureMin"
35-
description="The minimum temperature for the shipment."
36-
label="Temperature Min"
37-
placeholder="Enter Temperature Min"
38-
sideText="°F"
39-
/>
40-
</FormControl>
41-
<FormControl>
42-
<NumberField
43-
control={control}
44-
name="temperatureMax"
45-
label="Temperature Max"
46-
description="The maximum temperature for the shipment."
47-
placeholder="Enter Temperature Max"
48-
sideText="°F"
49-
/>
50-
</FormControl>
46+
{showTemperature && (
47+
<>
48+
<FormControl>
49+
<NumberField
50+
control={control}
51+
name="temperatureMin"
52+
description="The minimum temperature for the shipment."
53+
label="Temperature Min"
54+
placeholder="Enter Temperature Min"
55+
sideText="°F"
56+
rules={{ required: temperatureRequired }}
57+
/>
58+
<CapabilityExplainer profile={profile} field="temperatureMin" />
59+
</FormControl>
60+
<FormControl>
61+
<NumberField
62+
control={control}
63+
name="temperatureMax"
64+
label="Temperature Max"
65+
description="The maximum temperature for the shipment."
66+
placeholder="Enter Temperature Max"
67+
sideText="°F"
68+
rules={{ required: isFieldRequired(profile, "temperatureMax") }}
69+
/>
70+
<CapabilityExplainer profile={profile} field="temperatureMax" />
71+
</FormControl>
72+
</>
73+
)}
5174
</FormGroup>
5275
</Inner>
5376
);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { InfoIcon } from "lucide-react";
2+
import {
3+
describeMatch,
4+
enforcementLabel,
5+
enforcementTone,
6+
rulesForField,
7+
} from "../lib/capability";
8+
import { cn } from "../lib/utils";
9+
import type { ResolvedCapabilityRule, ResolvedModeProfile } from "../types/shipment";
10+
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
11+
12+
type CapabilityExplainerProps = {
13+
profile: ResolvedModeProfile | null | undefined;
14+
field: string;
15+
className?: string;
16+
};
17+
18+
export function CapabilityExplainer({ profile, field, className }: CapabilityExplainerProps) {
19+
const rules = rulesForField(profile, field);
20+
21+
if (!profile || rules.length === 0) {
22+
return null;
23+
}
24+
25+
return (
26+
<Popover>
27+
<PopoverTrigger
28+
type="button"
29+
aria-label="Why does this field behave this way?"
30+
className={cn(
31+
"inline-flex size-4 items-center justify-center rounded-full text-muted-foreground",
32+
"transition-colors hover:text-foreground focus-visible:outline-none",
33+
"focus-visible:ring-2 focus-visible:ring-ring",
34+
className,
35+
)}
36+
>
37+
<InfoIcon className="size-3.5" />
38+
</PopoverTrigger>
39+
<PopoverContent align="start" className="w-96 p-0">
40+
<div className="border-b border-border px-4 py-3">
41+
<p className="text-xs font-medium">Why this field behaves this way</p>
42+
<p className="mt-1 text-xs text-muted-foreground">
43+
Resolved from the <span className="font-medium">{profile.profileName}</span> profile,
44+
matched on {describeMatch(profile.candidates?.find((c) => c.selected)?.matchedOn)}.
45+
</p>
46+
</div>
47+
<ul className="divide-y divide-border">
48+
{rules.map((rule) => (
49+
<RuleExplanation key={rule.key} rule={rule} />
50+
))}
51+
</ul>
52+
</PopoverContent>
53+
</Popover>
54+
);
55+
}
56+
57+
function RuleExplanation({ rule }: { rule: ResolvedCapabilityRule }) {
58+
const { provenance } = rule;
59+
60+
return (
61+
<li className="px-4 py-3">
62+
<div className="flex items-baseline justify-between gap-2">
63+
<p className="text-xs font-medium">{rule.label}</p>
64+
<span className={cn("text-[11px] font-medium", enforcementTone(rule.enforcement))}>
65+
{enforcementLabel(rule.enforcement)}
66+
</span>
67+
</div>
68+
69+
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
70+
{provenance.rationale}
71+
</p>
72+
73+
{provenance.overridden && (
74+
<div className="mt-2 rounded-md bg-muted px-2.5 py-2">
75+
<p className="text-[11px] font-medium">
76+
Your organization changed this from {enforcementLabel(provenance.defaultEnforcement)}
77+
</p>
78+
{provenance.overrideReason && (
79+
<p className="mt-0.5 text-[11px] text-muted-foreground">
80+
{provenance.overrideReason}
81+
</p>
82+
)}
83+
</div>
84+
)}
85+
</li>
86+
);
87+
}

0 commit comments

Comments
 (0)