Merge pull request #1453 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-29 10:40:53 +03:00
committed by GitHub
66 changed files with 6593 additions and 337 deletions

View File

@@ -97,6 +97,7 @@
"react-markdown": "^9.1.0",
"react-pdf": "^10.4.1",
"react-pdf-html": "^2.1.5",
"react-phone-number-input": "^3.4.17",
"react-quill-new": "^3.8.3",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.27.0",

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { isSmsReachable, isValidPhone } from "./PhoneField";
/**
* `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two
* must agree: this one greys out the SMS option, that one decides whether the
* message is actually sent, and a disagreement means the UI promises a text
* nobody sends (or hides one that would have worked). These cases are the same
* ones the API spec asserts.
*/
describe("isSmsReachable", () => {
it.each(["+251986680099", "0986680099", "251986680099"])(
"accepts Ethiopian mobile form %s",
(phone) => expect(isSmsReachable(phone)).toBe(true),
);
it.each(["+25377123456", "25377123456", "77123456"])(
"accepts Djibouti mobile form %s",
(phone) => expect(isSmsReachable(phone)).toBe(true),
);
it.each([
"+14155550123",
"+447911123456",
"0712345678",
"+2519866",
"12345",
// Djibouti fixed line — valid number, not a mobile the gateway serves.
"+25321350000",
"+25366123456",
])("rejects unreachable or malformed %s", (phone) =>
expect(isSmsReachable(phone)).toBe(false),
);
it.each([undefined, null, ""])("treats %s as unreachable", (phone) =>
expect(isSmsReachable(phone)).toBe(false),
);
});
/**
* The country-picker input emits a PARTIAL E.164 while the user is still
* typing — "+25377" is a non-empty string that will post happily and come back
* as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and
* "complete" as different questions, so this is the check they call.
*/
describe("isValidPhone", () => {
it.each(["+25377834567", "+251911223344"])(
"accepts the complete number %s",
(phone) => expect(isValidPhone(phone)).toBe(true),
);
it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])(
"rejects the partial number %s the picker emits mid-typing",
(phone) => expect(isValidPhone(phone)).toBe(false),
);
it.each([undefined, null, ""])("treats %s as invalid", (phone) =>
expect(isValidPhone(phone)).toBe(false),
);
});

View File

@@ -0,0 +1,107 @@
import { Input, TextInput } from "@mantine/core";
import RPNInput, { isValidPhoneNumber } from "react-phone-number-input";
import "react-phone-number-input/style.css";
import "./phone-field.css";
/**
* The countries the railway operates between, and the only two the SMS gateway
* is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's
* otp.service). Restricting the picker to them keeps staff from entering a
* number that would validate but could never receive an activation link.
*/
export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const;
/**
* Djibouti — most accounts entered here (transit agents above all) are
* Djibouti-side, so it saves the picker interaction on the common case.
*/
export const DEFAULT_PHONE_COUNTRY = "DJ";
/**
* Re-exported so callers can validate before submitting.
*
* Needed because the input emits a PARTIAL E.164 while the user is still
* typing — "+25377" and "+2537712" are non-empty strings that reach a payload
* happily and then come back as a 400 from the API's own `IsValidPhone`. A
* caller must treat "non-empty" and "complete" as different questions.
*/
export const isValidPhone = (value?: string | null): boolean =>
!!value && isValidPhoneNumber(value);
/**
* Whether the SMS gateway can actually reach this number.
*
* Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and
* Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is
* queued and silently lost, so the UI offers email instead of promising an SMS.
*/
export function isSmsReachable(rawPhone?: string | null): boolean {
if (!rawPhone) return false;
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
const bare = digits.replace(/^\+/, "").replace(/^0+/, "");
const normalized = digits.startsWith("+")
? digits
: /^251\d{9}$|^253\d{8}$/.test(digits)
? `+${digits}`
: /^9\d{8}$|^7\d{8}$/.test(bare)
? `+251${bare}`
: /^77\d{6}$/.test(bare)
? `+253${bare}`
: digits;
return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized);
}
export interface PhoneFieldProps {
label?: string;
value?: string;
onChange: (value: string | undefined) => void;
error?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
description?: string;
}
/**
* Phone input with a country selector, limited to Ethiopia and Djibouti.
* Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API
* never has to guess a country from a bare local number.
*/
export function PhoneField({
label,
value,
onChange,
error,
required,
disabled,
placeholder = "77 83 45 67",
description,
}: PhoneFieldProps) {
return (
<Input.Wrapper
label={label}
required={required}
error={error}
description={description}
styles={{ label: { fontWeight: 600, fontSize: 14, color: "#10202F" } }}
>
<div
className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}
>
<RPNInput
international
defaultCountry={DEFAULT_PHONE_COUNTRY}
countries={[...SUPPORTED_PHONE_COUNTRIES]}
countryCallingCodeEditable={false}
value={value}
onChange={onChange}
inputComponent={TextInput}
disabled={disabled}
placeholder={placeholder}
/>
</div>
</Input.Wrapper>
);
}
export default PhoneField;

View File

@@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
);
const isBulk = booking.freightType === "BULK";
// NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the
// count the customer fixed is what allocation and per-wagon pricing use, so
// it belongs on the card next to the weight.
const requestedWagons =
isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS"
? Number(booking.bulkRequestedWagons ?? 0) || null
: null;
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
@@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{requestedWagons != null ? (
<Badge variant="light" color="grape" radius="sm">
{requestedWagons} wagon{requestedWagons === 1 ? "" : "s"}
</Badge>
) : null}
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
@@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{requestedWagons != null && (
<MetricTile label="Wagons booked" value={`${requestedWagons}`} />
)}
{items != null && <MetricTile label="Items" value={`${items}`} />}
<MetricTile
label="Hazardous"

View File

@@ -0,0 +1,82 @@
/* Align react-phone-number-input with the portal's Mantine field styling:
44px height, 10px radius, edr border, brand-green focus ring. */
.edr-phone-wrapper .PhoneInput {
display: flex;
align-items: stretch;
gap: 8px;
}
/* Country selector — a compact pill matching the input height/radius. */
.edr-phone-wrapper .PhoneInputCountry {
margin: 0;
padding: 0 10px;
height: 2.25rem;
border: 0.0625rem solid #b0bfce;
border-radius: 6px;
background: #fff;
display: flex;
align-items: center;
gap: 6px;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-wrapper .PhoneInputCountryIcon {
width: 22px;
height: 16px;
box-shadow: none;
}
.edr-phone-wrapper .PhoneInputCountrySelectArrow {
color: #6b7c8e;
opacity: 0.8;
}
/* The number input itself. */
.edr-phone-input {
flex: 1;
min-width: 0;
height: 44px;
padding: 0 12px;
border: 1px solid #e6ecf2;
border-radius: 10px;
font-size: 14px;
color: #10202f;
background: #fff;
outline: none;
transition:
border-color 120ms ease,
box-shadow 120ms ease;
}
.edr-phone-input::placeholder {
color: #9aa8b5;
}
.edr-phone-input:focus {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-wrapper .PhoneInputCountry:focus-within {
border-color: #0ea371;
box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15);
}
.edr-phone-input:disabled,
.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon {
opacity: 0.6;
cursor: not-allowed;
}
/* Error state mirrors Mantine's invalid styling. */
.edr-phone-wrapper--error .edr-phone-input,
.edr-phone-wrapper--error .PhoneInputCountry {
border-color: #e03131;
}
.edr-phone-wrapper--error .edr-phone-input:focus {
box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12);
}

View File

@@ -22,6 +22,7 @@ import {
RULE_ENGINE_SELECT_NONE,
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import PhoneField, { isValidPhone } from "@/components/PhoneField";
import type { RuleEngineRecord } from "@/types/rule-engine";
import { RULE_ENGINE_POSITION_END } from "./ruleEngineOrder.utils";
@@ -46,7 +47,11 @@ type FormRow =
/** One editable distance tier of a tierList field (raw input strings). */
type TierRow = { minKm: string; maxKm: string; rateValue: string };
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" });
const emptyTier = (fromKm = ""): TierRow => ({
minKm: fromKm,
maxKm: "",
rateValue: "",
});
/**
* Validate a tier set before submit: every tier complete, ranges sane, no
@@ -87,7 +92,11 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
while (index < fields.length) {
const field = fields[index];
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
if (
field.type === "textarea" ||
field.type === "boolean" ||
field.type === "tierList"
) {
rows.push({ kind: "single", field });
index += 1;
continue;
@@ -113,9 +122,10 @@ const buildInitialValues = (
): Record<string, unknown> => {
const values: Record<string, unknown> = {};
for (const field of fields) {
const raw = field.getInitialValue && record
? field.getInitialValue(record)
: record?.[field.name];
const raw =
field.getInitialValue && record
? field.getInitialValue(record)
: record?.[field.name];
if (field.type === "multiselect") {
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
} else if (field.type === "tierList") {
@@ -160,10 +170,13 @@ const resolveSelectValue = (
};
const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
label: {
fontWeight: 600,
marginBottom: 6,
color: "var(--mantine-color-gray-8)",
},
} as const;
const RuleEngineFormDialog = ({
open,
onOpenChange,
@@ -195,13 +208,17 @@ const RuleEngineFormDialog = ({
fields.filter((field) => {
if (
field.hideWhen &&
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
field.hideWhen.equals.includes(
String(values[field.hideWhen.field] ?? ""),
)
) {
return false;
}
if (
field.showWhen &&
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
!field.showWhen.equals.includes(
String(values[field.showWhen.field] ?? ""),
)
) {
return false;
}
@@ -228,7 +245,10 @@ const RuleEngineFormDialog = ({
// Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new
// allowed set instead of submitting a stale, rejected unit.
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
if (
(name === "appliesTo" || name === "trigger") &&
"rateUnit" in current
) {
next.rateUnit = "";
}
// The legal yards depend on what the rate is for and which way it runs, so
@@ -342,6 +362,20 @@ const RuleEngineFormDialog = ({
[field.name]: `${field.label} is required.`,
}));
blocked = true;
} else if (field.type === "phone" && raw !== "" && raw !== undefined) {
// The country-picker input emits a PARTIAL E.164 while the user is
// still typing ("+25377"), which is non-empty and would post straight
// through to a 400 from the API's own validator. Reject it here, on the
// field, instead of as a server error the admin has to decode.
if (!isValidPhone(String(raw))) {
setFieldErrors((current) => ({
...current,
[field.name]: `${field.label} is not a complete phone number.`,
}));
blocked = true;
} else {
payload[field.name] = raw;
}
} else if (raw === "" || raw === undefined) {
if (!field.required) continue;
payload[field.name] = raw;
@@ -350,13 +384,19 @@ const RuleEngineFormDialog = ({
}
}
if (fields.some((f) => f.name === "code" && typeof payload.code === "string")) {
if (
fields.some((f) => f.name === "code" && typeof payload.code === "string")
) {
payload.code = String(payload.code).toUpperCase();
}
if (blocked) return;
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
if (
!initialRecord &&
positionOptions &&
position !== RULE_ENGINE_POSITION_END
) {
payload.insertAfterId = position;
}
@@ -421,7 +461,9 @@ const RuleEngineFormDialog = ({
const setRows = (next: TierRow[]) => setField(field.name, next);
const setRow = (index: number, key: keyof TierRow, value: string) => {
if (value.trim().startsWith("-")) return;
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
setRows(
rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)),
);
};
return (
<Box key={field.name}>
@@ -443,7 +485,9 @@ const RuleEngineFormDialog = ({
step="any"
placeholder="0"
value={row.minKm}
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
onChange={(e) =>
setRow(index, "minKm", e.currentTarget.value)
}
size="md"
radius="md"
styles={inputStyles}
@@ -456,7 +500,9 @@ const RuleEngineFormDialog = ({
step="any"
placeholder="No limit"
value={row.maxKm}
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
onChange={(e) =>
setRow(index, "maxKm", e.currentTarget.value)
}
size="md"
radius="md"
styles={inputStyles}
@@ -469,7 +515,9 @@ const RuleEngineFormDialog = ({
step="any"
placeholder="Rate per km"
value={row.rateValue}
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
onChange={(e) =>
setRow(index, "rateValue", e.currentTarget.value)
}
size="md"
radius="md"
styles={inputStyles}
@@ -494,7 +542,12 @@ const RuleEngineFormDialog = ({
size="xs"
leftSection={<Plus size={14} />}
// The next tier naturally starts where the previous one ends.
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
onClick={() =>
setRows([
...rows,
emptyTier(rows[rows.length - 1]?.maxKm ?? ""),
])
}
>
Add tier
</Button>
@@ -531,7 +584,10 @@ const RuleEngineFormDialog = ({
onChange={(v) => setField(field.name, v)}
disabled={selectOptionsLoading}
data={options
.filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE)
.filter(
(opt) =>
opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE,
)
.map((opt) => ({ label: opt.label, value: opt.value }))}
searchable
clearable
@@ -545,7 +601,9 @@ const RuleEngineFormDialog = ({
if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
const options = field.optionsFromValues
? field.optionsFromValues(values)
: (field.options ?? []);
// A derived select shows (and submits) its computed value and is locked,
// matching the text-input branch — used by fields the shape decides on the
// admin's behalf, e.g. a shipping-line rate's import-only direction.
@@ -558,14 +616,18 @@ const RuleEngineFormDialog = ({
label={label}
description={field.description}
placeholder={
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
selectOptionsLoading
? "Loading options..."
: (field.placeholder ?? "Select an option")
}
value={
computedSelect !== undefined
? computedSelect
: resolveSelectValue(field, values)
}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
onChange={(v) =>
setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)
}
disabled={
selectOptionsLoading ||
field.disabled ||
@@ -635,8 +697,29 @@ const RuleEngineFormDialog = ({
);
}
if (field.type === "phone") {
// Country-picker input restricted to Ethiopia and Djibouti — the two the
// SMS gateway reaches. Emits E.164, so the API never guesses a country
// from a bare local number.
return (
<PhoneField
key={field.name}
label={label}
description={field.description}
value={String(values[field.name] ?? "")}
onChange={(v) => setField(field.name, v ?? "")}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
required={field.required}
error={fieldErrors[field.name] || undefined}
placeholder={field.placeholder}
/>
);
}
const isNumber = field.type === "number";
const computed = field.computeValue ? field.computeValue(values) : undefined;
const computed = field.computeValue
? field.computeValue(values)
: undefined;
return (
<TextInput
@@ -654,8 +737,14 @@ const RuleEngineFormDialog = ({
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
// so let the field carry decimals and let a 400 catch the rest.
step={isNumber ? "any" : undefined}
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
disabled={
field.disabled ||
(field.disabledOnEdit && !!initialRecord) ||
computed !== undefined
}
value={String(
(computed !== undefined ? computed : values[field.name]) ?? "",
)}
onChange={(e) => {
const next = e.currentTarget.value;
if (isNumber && next.trim().startsWith("-")) return;
@@ -703,16 +792,27 @@ const RuleEngineFormDialog = ({
>
<form onSubmit={handleSubmit}>
<Stack gap="lg">
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
<Box
style={{
maxHeight: "calc(65vh - 120px)",
overflowY: "auto",
paddingRight: 4,
}}
>
<Stack gap="md">
{!initialRecord && positionOptions ? (
<Select
label="Position"
description="New items are appended to the end by default."
value={position}
onChange={(value) => setPosition(value ?? RULE_ENGINE_POSITION_END)}
onChange={(value) =>
setPosition(value ?? RULE_ENGINE_POSITION_END)
}
data={[
{ label: "At end (default)", value: RULE_ENGINE_POSITION_END },
{
label: "At end (default)",
value: RULE_ENGINE_POSITION_END,
},
...positionOptions,
]}
searchable
@@ -724,9 +824,17 @@ const RuleEngineFormDialog = ({
) : null}
{formRows.map((row) =>
row.kind === "pair" ? (
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
<SimpleGrid
key={`${row.fields[0].name}-${row.fields[1].name}`}
cols={2}
spacing="md"
>
<Box style={{ minWidth: 0 }}>
{renderField(row.fields[0])}
</Box>
<Box style={{ minWidth: 0 }}>
{renderField(row.fields[1])}
</Box>
</SimpleGrid>
) : (
<Box key={row.field.name}>{renderField(row.field)}</Box>
@@ -752,7 +860,10 @@ const RuleEngineFormDialog = ({
disabled={isSubmitting}
leftSection={
isSubmitting ? (
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
<Loader2
size={18}
style={{ animation: "spin 1s linear infinite" }}
/>
) : undefined
}
radius="md"

View File

@@ -25,12 +25,20 @@ export const formatCell = (
row?: Record<string, unknown>,
): ReactNode => {
if (value === null || value === undefined || value === "") {
return <Text size="sm" c="dimmed"></Text>;
return (
<Text size="sm" c="dimmed">
</Text>
);
}
// Handle stringified objects (e.g., "[object Object]")
if (typeof value === "string" && value.trim() === "[object Object]") {
return <Text size="sm" c="dimmed"></Text>;
return (
<Text size="sm" c="dimmed">
</Text>
);
}
if (format === "boolean") {
@@ -75,9 +83,17 @@ export const formatCell = (
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
status === "VALID"
? "Valid"
: status === "EXPIRED"
? "Expired"
: "Not started";
const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
status === "VALID"
? "edr-green"
: status === "EXPIRED"
? "red"
: "yellow";
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
@@ -85,6 +101,20 @@ export const formatCell = (
);
}
if (format === "accountBadge") {
// `hasAccount` — whether a portal login backs this row. Roster-only rows
// predate accounts and stay legal, so "no" is a neutral dash, not a warning.
return value ? (
<Badge color="edr-green" variant="filled" size="sm" radius="md">
Invited
</Badge>
) : (
<Text size="sm" c="dimmed">
</Text>
);
}
if (format === "code") {
return (
<Badge
@@ -110,7 +140,11 @@ export const formatCell = (
if (format === "number") {
const num = Number(value);
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
return (
<Text size="sm">
{Number.isNaN(num) ? String(value) : num.toLocaleString()}
</Text>
);
}
if (format === "currency") {
@@ -125,14 +159,13 @@ export const formatCell = (
if (format === "date") {
const d = new Date(String(value));
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
if (Number.isNaN(d.getTime()))
return <Text size="sm">{String(value)}</Text>;
return <Text size="sm">{d.toLocaleDateString()}</Text>;
}
if (Array.isArray(value)) {
return (
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
);
return <Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>;
}
if (format === "entityLabel" && value && typeof value === "object") {
@@ -140,7 +173,11 @@ export const formatCell = (
if (label) {
return <Text size="sm">{label}</Text>;
}
return <Text size="sm" c="dimmed"></Text>;
return (
<Text size="sm" c="dimmed">
</Text>
);
}
if (format === "rateLabel") {
@@ -150,7 +187,9 @@ export const formatCell = (
{String(value)}
</Text>
) : (
<Text size="sm" c="dimmed"></Text>
<Text size="sm" c="dimmed">
</Text>
);
}
const rate = value as {
@@ -168,14 +207,20 @@ export const formatCell = (
return parts.length > 0 ? (
<Text size="sm">{parts.join(" · ")}</Text>
) : (
<Text size="sm" c="dimmed"></Text>
<Text size="sm" c="dimmed">
</Text>
);
}
if (typeof value === "object") {
const label = extractLabel(value);
if (label) return <Text size="sm">{label}</Text>;
return <Text size="sm" c="dimmed"></Text>;
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return <Text size="sm">{String(value)}</Text>;

View File

@@ -10,9 +10,10 @@ import {
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useAuth } from "@/auth/useAuth";
import { isSmsReachable } from "@/components/PhoneField";
import { useToast } from "@/hooks/use-toast";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
@@ -21,27 +22,19 @@ import type {
ShippingLineCompany,
} from "@/types/shippingLineCompany";
/**
* Whether the SMS gateway can actually reach this number.
*
* The carrier integration is domestic-only: anything else is queued and
* silently lost, so a foreign number counts as unavailable rather than as a
* send that quietly fails. Mirrors `isDomesticPhone` in the API's otp.service.
*/
function isDomesticPhone(rawPhone: string): boolean {
const digits = rawPhone.trim().replace(/[^\d+]/g, "");
const normalized = digits.startsWith("+")
? digits
: /^251\d{9}$/.test(digits)
? `+${digits}`
: /^9\d{8}$|^7\d{8}$/.test(digits.replace(/^0+/, ""))
? `+251${digits.replace(/^0+/, "")}`
: digits;
return /^\+2519\d{8}$/.test(normalized);
/** The minimum an account needs for the link dialog to describe its channels. */
export interface ActivationTarget {
id: string;
name: string;
email?: string | null;
phoneNumber?: string | null;
}
export interface ResendActivationActionProps {
shippingLine: Pick<ShippingLineCompany, "id" | "name" | "email" | "phoneNumber">;
shippingLine: Pick<
ShippingLineCompany,
"id" | "name" | "email" | "phoneNumber"
>;
}
/**
@@ -56,15 +49,13 @@ export default function ResendActivationAction({
}: ResendActivationActionProps) {
const { user } = useAuth();
const { toast } = useToast();
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("email");
const allowed = hasPermission(user, FREIGHT_PERMS.shippingLines.resetPassword);
const allowed = hasPermission(
user,
FREIGHT_PERMS.shippingLines.resetPassword,
);
const { mutate, isPending } = useMutation(
api.shippingLineCompanies.resendActivation.mutationOptions({
onSuccess: (result) => {
setOpened(false);
toast({
title: "Activation link sent",
description: `The shipping line can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
@@ -82,42 +73,100 @@ export default function ResendActivationAction({
if (!allowed) return null;
const phoneUsable =
!!shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber);
return (
<ActivationLinkDialog
target={shippingLine}
audienceLabel="shipping line"
isPending={isPending}
onSend={(channel, onDone) =>
mutate({ id: shippingLine.id, channel }, { onSuccess: onDone })
}
/>
);
}
export interface ActivationLinkDialogProps {
target: ActivationTarget;
/** How the account is described in the dialog copy ("shipping line", "transit agent"). */
audienceLabel: string;
isPending: boolean;
/**
* Perform the send. `onDone` closes the dialog — the caller owns the mutation
* (and its toast) because each audience posts to its own endpoint.
*/
onSend: (channel: ResetChannel, onDone: () => void) => void;
/** Overrides the icon-button trigger, e.g. a labelled "Invite" button. */
trigger?: (open: () => void) => ReactNode;
title?: string;
submitLabel?: string;
/** Extra fields above the channel picker — the invite flow collects the address here. */
children?: ReactNode;
/** Blocks the send button, e.g. while a required address is still empty. */
submitDisabled?: boolean;
}
/**
* The channel picker behind every activation-link send.
*
* Extracted from the shipping-line action so transit agents get the identical
* dialog — including the domestic-only SMS rule, which is the part most likely
* to be re-implemented subtly wrong.
*/
export function ActivationLinkDialog({
target,
audienceLabel,
isPending,
onSend,
trigger,
title = "Resend activation link",
submitLabel = "Send activation link",
children,
submitDisabled,
}: ActivationLinkDialogProps) {
const [opened, setOpened] = useState(false);
const [channel, setChannel] = useState<ResetChannel>("email");
const phoneUsable = isSmsReachable(target.phoneNumber);
const channelMissing = channel === "phone" && !phoneUsable;
return (
<>
<Tooltip label="Resend activation link" withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Resend activation link to ${shippingLine.name}`}
onClick={(event) => {
// The row itself is not clickable today, but stop here anyway so
// adding a detail-page navigation later cannot swallow this click.
event.stopPropagation();
setOpened(true);
}}
>
<Send size={16} />
</ActionIcon>
</Tooltip>
{trigger ? (
trigger(() => setOpened(true))
) : (
<Tooltip label={title} withArrow>
<ActionIcon
variant="subtle"
color="gray"
aria-label={`${title} to ${target.name}`}
onClick={(event) => {
// The row itself is not clickable today, but stop here anyway so
// adding a detail-page navigation later cannot swallow this click.
event.stopPropagation();
setOpened(true);
}}
>
<Send size={16} />
</ActionIcon>
</Tooltip>
)}
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Resend activation link"
title={title}
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
We&apos;ll send a single-use link to {shippingLine.name}. They choose
their own password you will not see it. The link expires in 24
hours, and sending a new one invalidates nothing they haven&apos;t
already used.
We&apos;ll send a single-use link to this {audienceLabel},{" "}
{target.name}. They choose their own password you will not see it.
The link expires in 24 hours, and sending a new one invalidates
nothing they haven&apos;t already used.
</Text>
{children}
<Radio.Group
value={channel}
onChange={(v) => setChannel(v as ResetChannel)}
@@ -127,18 +176,19 @@ export default function ResendActivationAction({
<Radio
value="email"
label="Email"
description={shippingLine.email}
disabled={!target.email}
description={target.email ?? "No email on this account"}
/>
<Radio
value="phone"
label="SMS"
disabled={!phoneUsable}
description={
!shippingLine.phoneNumber
!target.phoneNumber
? "No phone number on this account"
: !phoneUsable
? `${shippingLine.phoneNumber}foreign number, SMS unavailable; use email`
: shippingLine.phoneNumber
? `${target.phoneNumber}the SMS gateway does not reach this number; use email`
: target.phoneNumber
}
/>
</Stack>
@@ -156,10 +206,10 @@ export default function ResendActivationAction({
<Button
color="edr-green"
loading={isPending}
disabled={channelMissing}
onClick={() => mutate({ id: shippingLine.id, channel })}
disabled={channelMissing || submitDisabled}
onClick={() => onSend(channel, () => setOpened(false))}
>
Send activation link
{submitLabel}
</Button>
</Stack>
</Modal>

View File

@@ -303,6 +303,7 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
// Per-wagon loading/unloading modal for one booking.
const [wagonModal, setWagonModal] = useState<{
isExport: boolean;
bookingId: string;
ref: string;
phase: "load" | "unload";
@@ -908,7 +909,12 @@ export function ScheduleWorkspacePanel({
radius="md"
disabled={!canLoad || !loadWindowStarted}
onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "load" })
setWagonModal({
bookingId: b.id,
ref,
phase: "load",
isExport: b.tradeDirection === "EXPORT",
})
}
>
Wagons
@@ -987,7 +993,12 @@ export function ScheduleWorkspacePanel({
radius="md"
disabled={!canUnload || !unloadWindowStarted}
onClick={() =>
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
setWagonModal({
bookingId: b.id,
ref,
phase: "unload",
isExport: b.tradeDirection === "EXPORT",
})
}
>
Wagons
@@ -1035,6 +1046,7 @@ export function ScheduleWorkspacePanel({
bookingId={wagonModal.bookingId}
reference={wagonModal.ref}
phase={wagonModal.phase}
isExport={wagonModal.isExport}
onClose={() => setWagonModal(null)}
onChanged={onChanged}
/>
@@ -1423,6 +1435,7 @@ function PerWagonModal({
bookingId,
reference,
phase,
isExport,
onClose,
onChanged,
}: {
@@ -1430,13 +1443,35 @@ function PerWagonModal({
bookingId: string;
reference: string;
phase: "load" | "unload";
/**
* EXPORT booking — only these may use the truck-to-train submit, which is
* what the server's handover-mode endpoint enforces too (it 400s on any
* other direction).
*/
isExport: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const { toast } = useToast();
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [edrFault, setEdrFault] = useState(false);
// Wagons ticked for this submit. Load is a batch action now: pick the wagons
// that physically went on, then submit once.
const [picked, setPicked] = useState<Set<string>>(new Set());
// Which submit is in flight — also decides whether the handover mode is set
// first ("truck") or the GRN gate is left to reject ("load").
const [submitting, setSubmitting] = useState<null | "load" | "truck">(null);
// Submit awaiting confirmation. Loading is irreversible from this screen —
// there is no "unload back to the yard" here — and truck-to-train also drops
// the booking's GRN requirement for good, so both go through a confirm step.
// The one open panel, if any. A single slot rather than a flag per panel:
// separate booleans let the cancel form and a submit confirm show at the same
// time, each with its own buttons.
const [confirmSubmit, setConfirmSubmit] = useState<null | "load" | "truck" | "cancel">(
null,
);
const cancelOpen = confirmSubmit === "cancel";
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
input: { bookingId },
@@ -1463,41 +1498,118 @@ function PerWagonModal({
? ((err.response?.data as { message?: string })?.message ?? err.message)
: String(err);
const onWagon = (allocationId: string) => {
act
.mutateAsync({ scheduleId, bookingId, allocationId })
.then((r) => {
void wagonsQuery.refetch();
if (r.completed) {
toast({
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
const toggle = (allocationId: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const pickedPending = pending.filter((w) => picked.has(w.allocationId));
/**
* Submit the ticked wagons. There is no batch endpoint, so they go one at a
* time in order — the server flips the booking to IN_TRANSIT on whichever
* call clears the last unloaded wagon, so sequential is required, not just
* convenient. The first failure stops the run: the wagons already sent stay
* loaded (each call is its own transaction) and the toast names the survivor
* count, so a retry only resends what is left.
*
* `mode: "truck"` first sets DIRECT_TO_TRAIN, which is what makes the GRN
* gate let this booking through — see assertExportReceivedWithGrn. Plain
* "load" sends nothing extra and lets that gate reject unreceived cargo.
*/
const onSubmit = (mode: "load" | "truck") => {
const targets = pickedPending;
if (!targets.length) return;
setConfirmSubmit(null);
setSubmitting(mode);
const run = async () => {
if (mode === "truck") {
await bookingsService.setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN");
}
let done = 0;
let completed = false;
try {
for (const w of targets) {
const r = await act.mutateAsync({
scheduleId,
bookingId,
allocationId: w.allocationId,
});
onChanged();
onClose();
} else {
done += 1;
if (r.completed) completed = true;
}
} catch (err) {
// Partial success is a real outcome here, not a rollback candidate:
// report what landed so the operator knows what to retry.
if (done > 0) {
void wagonsQuery.refetch();
onChanged();
}
throw Object.assign(err as Error, { partial: done });
}
return { done, completed };
};
run()
.then(({ done, completed }) => {
void wagonsQuery.refetch();
onChanged();
setPicked(new Set());
if (completed) {
toast({
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
description:
phase === "load"
? `${reference}: every wagon is loaded — the booking is in transit${mode === "truck" ? " (direct truck-to-train handover)" : ""}.`
: `${reference}: every wagon is unloaded — the booking arrived.`,
});
onClose();
} else {
toast({
title: phase === "load" ? "Wagons loaded" : "Wagons unloaded",
description: `${reference}: ${done} wagon${done === 1 ? "" : "s"} ${phase === "load" ? "loaded" : "unloaded"}.`,
});
}
})
.catch((err) =>
.catch((err) => {
const sent = (err as { partial?: number }).partial ?? 0;
toast({
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
description: errText(err),
description: sent
? `${sent} wagon(s) went through before this: ${errText(err)}`
: errText(err),
variant: "destructive",
}),
);
});
})
.finally(() => setSubmitting(null));
};
const onCancelRemaining = () => {
// Cut exactly the ticked wagons. Sending the ids (rather than omitting them
// and letting the server cut the whole remainder) is what makes a partial
// cancel possible while other wagons are still waiting to load.
const ids = pickedPending.map((w) => w.allocationId);
const count = ids.length;
cancelRemaining
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
.mutateAsync({
bookingId,
scheduleId,
reason: reason.trim(),
edrFault,
wagonAllocationIds: ids,
})
.then(() => {
toast({
title: "Remaining wagons cancelled",
title: "Wagons cancelled",
description: edrFault
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
? `${reference}: ${count} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
: `${reference}: ${count} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
});
setPicked(new Set());
onChanged();
onClose();
})
@@ -1550,6 +1662,15 @@ function PerWagonModal({
<Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{!isDone(w) ? (
<Checkbox
checked={picked.has(w.allocationId)}
onChange={() => toggle(w.allocationId)}
disabled={submitting != null}
color={phase === "load" ? "edr-green" : "orange"}
aria-label={`Select wagon ${w.sequenceNo ?? ""} to ${phase}`}
/>
) : null}
<Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
</Badge>
@@ -1574,50 +1695,216 @@ function PerWagonModal({
>
{phase === "load" ? "Loaded" : "Unloaded"}
</Badge>
) : (
<Button
size="compact-sm"
variant="filled"
color={phase === "load" ? "edr-green" : "orange"}
radius="md"
leftSection={
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
}
loading={
act.isPending && act.variables?.allocationId === w.allocationId
}
onClick={() => onWagon(w.allocationId)}
>
{phase === "load" ? "Load" : "Unload"}
</Button>
)}
) : null}
</Group>
</Paper>
))
)}
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
!cancelOpen ? (
<Button
variant="light"
color="red"
radius="md"
leftSection={<X size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}
</Button>
) : (
<Paper withBorder radius="md" p="sm">
{pending.length > 0 && !confirmSubmit ? (
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap={8}>
<Button
size="compact-sm"
variant="subtle"
radius="md"
disabled={submitting != null}
onClick={() =>
setPicked(
picked.size === pending.length
? new Set()
: new Set(pending.map((w) => w.allocationId)),
)
}
>
{picked.size === pending.length ? "Clear all" : "Select all"}
</Button>
<Text size="xs" c="dimmed">
{pickedPending.length} of {pending.length} selected
</Text>
</Group>
<Group gap="sm">
{phase === "load" ? (
<Tooltip
label="Cancel the selected wagons — they will not ride. Customer fault invoices the cancellation fee; EDR fault charges nothing."
withArrow
>
<Button
variant="light"
color="red"
radius="md"
leftSection={<X size={14} />}
disabled={!pickedPending.length || submitting != null}
onClick={() => setConfirmSubmit("cancel")}
>
Cancel
</Button>
</Tooltip>
) : null}
{phase === "load" && isExport ? (
<Tooltip
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover, then loads the selected wagons."
withArrow
>
<Button
variant="light"
color="blue"
radius="md"
leftSection={<Truck size={14} />}
disabled={!pickedPending.length || submitting != null}
loading={submitting === "truck"}
onClick={() => setConfirmSubmit("truck")}
>
Truck to train
</Button>
</Tooltip>
) : null}
<Tooltip
label={
phase === "load"
? "Load the selected wagons — export cargo must already be received at the warehouse with a GRN."
: "Unload the selected wagons."
}
withArrow
>
<Button
color={phase === "load" ? "edr-green" : "orange"}
radius="md"
leftSection={
phase === "load" ? <PackageCheck size={14} /> : <PackageOpen size={14} />
}
disabled={!pickedPending.length || submitting != null}
loading={submitting === "load"}
onClick={() => setConfirmSubmit("load")}
>
{phase === "load" ? "Load" : "Unload"} {pickedPending.length || ""}
</Button>
</Tooltip>
</Group>
</Group>
) : null}
{confirmSubmit && confirmSubmit !== "cancel" ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={
confirmSubmit === "truck"
? "blue"
: phase === "load"
? "edr-green"
: "orange"
}
>
{confirmSubmit === "truck" ? (
<Truck size={21} />
) : phase === "load" ? (
<PackageCheck size={21} />
) : (
<PackageOpen size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>
{confirmSubmit === "truck"
? "Load as direct truck-to-train?"
: phase === "load"
? `Load ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`
: `Unload ${pickedPending.length} wagon${pickedPending.length === 1 ? "" : "s"}?`}
</Text>
<Text size="xs" c="dimmed">
{reference}
</Text>
</div>
</Group>
<Text size="sm">
{confirmSubmit === "truck"
? `Sets direct truck-to-train handover for the whole booking (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document), then loads the ${pickedPending.length} selected wagon${pickedPending.length === 1 ? "" : "s"}.`
: phase === "load"
? "Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN."
: "Stamps the selected wagons as unloaded and frees them for reuse."}
</Text>
{confirmSubmit === "truck" ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
style={{
borderRadius: 10,
background: "var(--mantine-color-yellow-0)",
border: "1px solid var(--mantine-color-yellow-3)",
}}
>
<AlertTriangle size={16} color="#B54708" />
<Text size="xs" c="yellow.9" fw={500}>
The handover mode applies to the whole booking and stays set
even if a wagon then fails to load its GRN requirement is
dropped for good.
</Text>
</Group>
) : null}
{pickedPending.length < pending.length ? (
<Text size="xs" c="dimmed">
{pending.length - pickedPending.length} wagon
{pending.length - pickedPending.length === 1 ? "" : "s"} left
un{phase === "load" ? "loaded" : "unloaded"} the train cannot
dispatch until they are {phase === "load" ? "loaded" : "unloaded"} or
cancelled.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
Back
</Button>
<Button
color={
confirmSubmit === "truck"
? "blue"
: phase === "load"
? "edr-green"
: "orange"
}
radius="md"
leftSection={
confirmSubmit === "truck" ? (
<Truck size={14} />
) : phase === "load" ? (
<PackageCheck size={14} />
) : (
<PackageOpen size={14} />
)
}
onClick={() => onSubmit(confirmSubmit === "truck" ? "truck" : "load")}
>
{confirmSubmit === "truck"
? "Load direct"
: phase === "load"
? "Load"
: "Unload"}
</Button>
</Group>
</Stack>
</Paper>
) : null}
{phase === "load" && cancelOpen && pickedPending.length > 0 ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Text size="sm" fw={600}>
Cancel {pending.length} unloaded wagon
{pending.length === 1 ? "" : "s"} of {reference}
Cancel {pickedPending.length} selected wagon
{pickedPending.length === 1 ? "" : "s"} of {reference}
</Text>
<Text size="xs" c="dimmed">
The booking shrinks to its loaded wagons and the freed freight
These wagons are cut from the booking and the freed freight
becomes a rebookable credit. Customer fault: the cancellation
fee is invoiced, payable afterwards. EDR fault: no fee.
{pending.length > pickedPending.length
? ` The other ${pending.length - pickedPending.length} unloaded wagon(s) stay on the booking and still have to be loaded or cancelled before dispatch.`
: ""}
</Text>
<Textarea
label="Reason"
@@ -1633,7 +1920,7 @@ function PerWagonModal({
onChange={(e) => setEdrFault(e.currentTarget.checked)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
<Button variant="default" radius="md" onClick={() => setConfirmSubmit(null)}>
Back
</Button>
<Button
@@ -1647,8 +1934,7 @@ function PerWagonModal({
</Button>
</Group>
</Stack>
</Paper>
)
</Paper>
) : null}
</Stack>
</Modal>

View File

@@ -0,0 +1,156 @@
import { Button, Stack, TextInput, Tooltip } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import PhoneField, { isValidPhone } from "@/components/PhoneField";
import { ActivationLinkDialog } from "@/components/shipping-lines/ResendActivationAction";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { transitAgentsService } from "@/services/transit-agents.service";
import type { TransitAgent } from "@/services/transit-agents.service";
import type { ResetChannel } from "@/types/shippingLineCompany";
export interface TransitAgentAccountActionProps {
agent: TransitAgent;
/** Hidden entirely without the update permission, matching the other row controls. */
disabled?: boolean;
}
/**
* The account column's row control: **Invite** for an agent that has no portal
* login yet, **Resend** for one that has.
*
* Inviting is deliberately its own action rather than a side effect of editing
* the email: it mints an IAM user, and a field edit must never do that
* implicitly — the roster rows that predate portal logins would start growing
* accounts the first time anyone corrected a typo.
*/
export default function TransitAgentAccountAction({
agent,
disabled,
}: TransitAgentAccountActionProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [email, setEmail] = useState(agent.email ?? "");
const [phoneNumber, setPhoneNumber] = useState(agent.phoneNumber ?? "");
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
const invite = useMutation({
mutationFn: () =>
transitAgentsService.invite(agent.id, {
email: email.trim(),
phoneNumber: phoneNumber.trim() || undefined,
}),
onSuccess: async (result) => {
await invalidate();
toast({
title: "Portal account created",
description: result.activationSentTo
? `${agent.name} can set their password using the link sent to ${result.activationSentTo}. It expires in 24 hours.`
: `${agent.name} now has a portal account, but the activation link could not be sent — resend it.`,
});
},
onError: (error: Error) =>
toast({
title: "Could not create the portal account",
description: error.message,
variant: "destructive",
}),
});
const resend = useMutation({
mutationFn: (channel: ResetChannel) =>
transitAgentsService.resendActivation(agent.id, channel),
onSuccess: (result) =>
toast({
title: "Activation link sent",
description: `${agent.name} can set their password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`,
}),
onError: (error: Error) =>
toast({
title: "Could not send activation link",
description: error.message,
variant: "destructive",
}),
});
if (disabled) return null;
// Already has a login — the only thing left is another copy of the link, so
// this is exactly the shipping-line dialog with a different endpoint behind it.
if (agent.hasAccount) {
return (
<ActivationLinkDialog
target={agent}
audienceLabel="transit agent"
isPending={resend.isPending}
onSend={(channel, onDone) =>
resend.mutate(channel, { onSuccess: onDone })
}
trigger={(open) => (
<Tooltip label="Resend activation link" withArrow>
<Button size="compact-xs" variant="light" onClick={open}>
Resend
</Button>
</Tooltip>
)}
/>
);
}
return (
<ActivationLinkDialog
// Show the addresses being typed, not the (empty) stored ones, so the
// channel picker enables SMS as soon as a domestic number is entered.
target={{
...agent,
email: email.trim() || null,
phoneNumber: phoneNumber.trim() || null,
}}
audienceLabel="transit agent"
isPending={invite.isPending}
title="Create portal account"
submitLabel="Create account and send link"
// The channel choice is the dialog's, but invite always emails (and texts
// a domestic number) — the API picks both. Sending is what matters here.
// A half-typed number is a non-empty partial E.164 the API rejects with a
// 400 — block it here rather than posting it.
submitDisabled={
!email.trim() || (!!phoneNumber.trim() && !isValidPhone(phoneNumber))
}
onSend={(_channel, onDone) =>
invite.mutate(undefined, { onSuccess: onDone })
}
trigger={(open) => (
<Tooltip label="Give this agent a portal login" withArrow>
<Button size="compact-xs" variant="light" onClick={open}>
Invite
</Button>
</Tooltip>
)}
>
<Stack gap="sm">
<TextInput
label="Email"
placeholder="a.bourhan@transit.dj"
required
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
<PhoneField
label="Phone number"
value={phoneNumber}
onChange={(v) => setPhoneNumber(v ?? "")}
error={
phoneNumber.trim() && !isValidPhone(phoneNumber)
? "Enter the complete number"
: undefined
}
description="Ethiopian and Djiboutian mobiles also receive the link by SMS"
/>
</Stack>
</ActivationLinkDialog>
);
}

View File

@@ -615,6 +615,10 @@ export const URL_CONSTANTS = {
TRANSIT_AGENTS: "/transit-agents",
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
/** Give an existing roster-only agent a portal account and send the link. */
TRANSIT_AGENT_INVITE: (id: string) => `/transit-agents/${id}/invite`,
TRANSIT_AGENT_RESEND_ACTIVATION: (id: string) =>
`/transit-agents/${id}/resend-activation`,
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",

View File

@@ -26,6 +26,8 @@ import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import TransitAgentAccountAction from "@/components/transit-agents/TransitAgentAccountAction";
import type { TransitAgent } from "@/services/transit-agents.service";
import { YardDesksModal } from "@/pages/ruleEngine/YardDesksModal";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
@@ -577,8 +579,9 @@ const RuleEngineResourcePage = () => {
base.push({
id: "actions",
header: "Actions",
size: config.orderConfig ? 200 : 140,
minSize: config.orderConfig ? 180 : 120,
// Transit agents carry an extra Invite/Resend button in this cell.
size: config.orderConfig ? 200 : config.slug === "transit-agents" ? 200 : 140,
minSize: config.orderConfig ? 180 : config.slug === "transit-agents" ? 180 : 120,
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
@@ -597,6 +600,12 @@ const RuleEngineResourcePage = () => {
</Button>
</Tooltip>
) : null}
{config.slug === "transit-agents" ? (
<TransitAgentAccountAction
agent={row.original as unknown as TransitAgent}
disabled={!canUpdateControls}
/>
) : null}
{config.orderConfig && canUpdateControls ? (
<RuleEngineOrderControls
record={row.original}

View File

@@ -11,13 +11,14 @@ export type ColumnFormat =
| "activeBadge"
| "rateStatus"
| "validityBadge"
| "accountBadge"
| "date"
| "number"
| "currency"
| "entityLabel"
| "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "phone" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
export interface ResourceColumn {
id: string;
@@ -669,6 +670,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
accessorKey: "validityStatus",
format: "validityBadge",
},
{
id: "hasAccount",
header: "Portal account",
accessorKey: "hasAccount",
format: "accountBadge",
},
activeColumn,
],
formFields: [
@@ -681,6 +688,21 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
},
{
name: "email",
label: "Email",
type: "email",
optional: true,
description:
"Filling this on create makes the portal account and emails the activation link. On an existing agent, use the Invite button instead — editing here only corrects the address.",
},
{
name: "phoneNumber",
label: "Phone number",
type: "phone",
optional: true,
description: "Ethiopian and Djiboutian mobiles also receive the link by SMS",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},

View File

@@ -993,13 +993,25 @@ export const api = {
),
cancelRemainingWagons: endpoint<
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
{
bookingId: string;
scheduleId: string;
reason: string;
edrFault?: boolean;
/** Cut only these never-loaded wagons; omit for the whole remainder. */
wagonAllocationIds?: string[];
},
unknown
>(
"train-scheduling",
"cancel-remaining-wagons",
({ bookingId, scheduleId, reason, edrFault }) =>
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
({ bookingId, scheduleId, reason, edrFault, wagonAllocationIds }) =>
trainSchedulingService.cancelRemainingWagons(bookingId, {
scheduleId,
reason,
edrFault,
wagonAllocationIds,
}),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),

View File

@@ -546,7 +546,12 @@ export const trainSchedulingService = {
cancelRemainingWagons: async (
bookingId: string,
payload: { scheduleId: string; reason: string; edrFault?: boolean },
payload: {
scheduleId: string;
reason: string;
edrFault?: boolean;
wagonAllocationIds?: string[];
},
): Promise<unknown> => {
const response = await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),

View File

@@ -1,5 +1,6 @@
import { api } from "../auth/http";
import { api as apiClient } from "../auth/http";
import { URL_CONSTANTS } from "../constants/URLS";
import type { ResetChannel } from "../types/shippingLineCompany";
export interface TransitAgent {
id: string;
@@ -7,14 +8,53 @@ export interface TransitAgent {
validFrom: string;
validTo: string;
isActive: boolean;
/** Null on every agent that exists only as a GL-assignable roster entry. */
email?: string | null;
phoneNumber?: string | null;
/** True once an IAM account backs the agent — i.e. it can sign in. */
hasAccount?: boolean;
}
export interface InviteTransitAgentDto {
email: string;
phoneNumber?: string;
username?: string;
}
/** What the API reports back about an activation send. */
export interface ActivationSendResult {
maskedTarget: string;
channel: string;
expiresAt: string;
}
export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */
async listAssignable() {
const response = await api.get<TransitAgent[]>(
const response = await apiClient.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
);
return response.data;
},
/**
* Create the portal account for an agent that has none and send its
* activation link. Separate from the rule-engine CRUD update because it mints
* an IAM user, which a field edit must never do implicitly.
*/
async invite(id: string, dto: InviteTransitAgentDto) {
const response = await apiClient.post<{
agent: TransitAgent;
activationSentTo: string | null;
}>(URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_INVITE(id), dto);
return response.data;
},
async resendActivation(id: string, channel: ResetChannel) {
const response = await apiClient.post<ActivationSendResult>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENT_RESEND_ACTIVATION(id),
{ channel },
);
return response.data;
},
};

View File

@@ -201,6 +201,10 @@ export interface BookingDetail {
cargoTotalWeightVgm: number;
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
bulkTotalWeightTons?: number | null;
/** NUMBER_OF_WAGONS bulk only: the wagon count the customer booked. */
bulkRequestedWagons?: number | null;
/** NUMBER_OF_WAGONS bulk only: informational item count entered with the weight. */
bulkItemCount?: number | null;
isHazardous: boolean;
isReefer?: boolean;
consolidationPartnerId?: string | null;
@@ -264,7 +268,9 @@ export interface BookingDetail {
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
cargoType?: BookingNamedRef;
cargoType?: BookingNamedRef & {
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" | null;
};
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
reviewNotes?: BookingReviewNote[];

View File

@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
import {
Home,
Layers,
LayoutDashboard,
LifeBuoy,
Loader2,
// MapPin,
@@ -68,6 +69,10 @@ import {
ShippingLineInvoicesPage,
ShippingLineSettingsPage,
} from "./pages/shipping-line";
import {
TransitAgentBookingsPage,
TransitAgentOverviewPage,
} from "./pages/transit-agent";
import FaqPage from "./pages/support/FaqPage";
import HelpPage from "./pages/support/HelpPage";
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
@@ -143,15 +148,15 @@ function isOnboardingAllowedPath(pathname: string): boolean {
* captured, so there is nothing for them to onboard — they go straight to home.
*/
function OnboardingGate() {
const { company, onboardingCompleted, isShippingLine } = useAuth();
const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
useAuth();
const location = useLocation();
// Keyed off a positive shipping-line identification, never off "no company":
// that is also true mid-fetch and on error, which would let customers slip
// past onboarding whenever the request failed.
const needsOnboarding = isShippingLine
? false
: !company || !onboardingCompleted;
// Keyed off a positive shipping-line / transit-agent identification, never
// off "no company": that is also true mid-fetch and on error, which would let
// customers slip past onboarding whenever the request failed.
const needsOnboarding =
isShippingLine || isTransitAgent ? false : !company || !onboardingCompleted;
const allowedHere = isOnboardingAllowedPath(location.pathname);
// Open by default while onboarding is pending (covers the login case).
@@ -199,13 +204,14 @@ function OnboardingGate() {
* contract/company-shaped page that has no meaning for it.
*/
function RequireCustomer() {
const { isShippingLine, customerQuery } = useAuth();
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
// RequireCompany already awaits this query, but guard anyway: a refetch can
// flip `isPending` back on, and redirecting on a half-loaded account would
// throw the user into the wrong app.
if (customerQuery.isPending) return <FullScreenSpinner />;
if (isShippingLine) return <Navigate to="/shipping-line" replace />;
if (isTransitAgent) return <Navigate to="/transit-agent" replace />;
return <Outlet />;
}
@@ -218,6 +224,15 @@ function RequireShippingLine() {
return <Outlet />;
}
/** Transit-agent routes, closed to every other account kind. */
function RequireTransitAgent() {
const { isTransitAgent, customerQuery } = useAuth();
if (customerQuery.isPending) return <FullScreenSpinner />;
if (!isTransitAgent) return <Navigate to="/portal" replace />;
return <Outlet />;
}
/**
* Where a signed-in account belongs. Shipping lines and customers have separate
* apps, so every "you're already logged in" redirect has to pick between them.
@@ -225,11 +240,15 @@ function RequireShippingLine() {
* still in flight, which would land a shipping line on the customer home first.
*/
function useHomeRoute(): { ready: boolean; href: string } {
const { isShippingLine, customerQuery } = useAuth();
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
return {
ready: !customerQuery.isPending,
href: isShippingLine ? "/shipping-line" : "/portal",
href: isShippingLine
? "/shipping-line"
: isTransitAgent
? "/transit-agent"
: "/portal",
};
}
@@ -326,6 +345,24 @@ const shippingLineSidebarItems: SidebarItem[] = [
},
];
/**
* Sidebar for transit agents. Two entries only — the rest of the portal
* (contracts, invoices, settings, support) is company-scoped and has no meaning
* for an agent, so nothing is filtered in from the other lists.
*/
const transitAgentSidebarItems: SidebarItem[] = [
{
label: "Overview",
href: "/transit-agent",
icon: <LayoutDashboard size={18} />,
},
{
label: "Bookings",
href: "/transit-agent/bookings",
icon: <Package size={18} />,
},
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -337,6 +374,7 @@ const App = () => {
reapplyProfile,
isAuthenticated,
isShippingLine,
isTransitAgent,
} = useAuth();
// Attribute replays and exceptions to the signed-in user (id/org only).
@@ -478,6 +516,39 @@ const App = () => {
</Route>
)}
{/* Transit-agent app. Two pages, both empty for now, behind their
own layout — there is no support widget because the chat is
company-scoped and an agent has no company, exactly as for a
shipping line. */}
{isTransitAgent && (
<Route element={<RequireTransitAgent />}>
<Route
element={
<AppLayout
title="EDR Freight"
sidebarItems={transitAgentSidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
userName={displayName}
userEmail={userEmail}
showSupportWidget={false}
>
<Outlet />
</AppLayout>
}
>
<Route
path="/transit-agent"
element={<TransitAgentOverviewPage />}
/>
<Route
path="/transit-agent/bookings"
element={<TransitAgentBookingsPage />}
/>
</Route>
</Route>
)}
{/* Customer app — unchanged. */}
<Route element={<RequireCustomer />}>
<Route

View File

@@ -6,6 +6,7 @@ import type {
import {
companiesService,
isShippingLineAccount,
isTransitAgentAccount,
} from "@/services/companies.service";
import type {
LoginPayload,
@@ -184,10 +185,21 @@ const useAuth = () => {
const isShippingLine = isShippingLineAccount(accountInfo);
const shippingLine = isShippingLine ? accountInfo : null;
// Every customer-shaped field below is null/empty for a shipping line.
const companyInfo = isShippingLine
? null
: (accountInfo as CompanyInfoResponse | null);
/**
* Transit agents share the portal with customers and shipping lines but have
* no company, no external profile and no onboarding. Identified positively
* from the backend's discriminator, for the same reason as the shipping line
* above — never from "company is missing".
*/
const isTransitAgent = isTransitAgentAccount(accountInfo);
const transitAgent = isTransitAgent ? accountInfo : null;
// Every customer-shaped field below is null/empty for a shipping line and for
// a transit agent alike.
const companyInfo =
isShippingLine || isTransitAgent
? null
: (accountInfo as CompanyInfoResponse | null);
const companyType = companyInfo?.company?.type ?? null;
const companyStatus = companyInfo?.company?.status ?? null;
// A company can create bookings only once an admin has approved it (active).
@@ -314,6 +326,8 @@ const useAuth = () => {
onboardingStep,
isShippingLine,
shippingLine,
isTransitAgent,
transitAgent,
createProfile,
reapplyProfile,
login,

View File

@@ -235,9 +235,9 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
LOCO
</Text>
</Box>
{wagons.map((w) => (
{wagons.map((w, i) => (
<Tooltip
key={w.sequenceNo}
key={w.allocationId ?? w.sequenceNo}
label={`${w.wagonNumber ?? "Unassigned"} · ${w.wagonType ?? "—"} · ${
STATUS_TONES[w.status]?.label ?? w.status
}`}
@@ -257,7 +257,7 @@ function ConsistStrip({ wagons }: { wagons: BookingWagonAllocation[] }) {
}}
>
<Text fz={10} fw={700} c="#6B7C8E">
W{w.sequenceNo}
W{i + 1}
</Text>
<Text fz={11.5} fw={800} c="#10202F" style={{ fontFamily: "monospace" }}>
{w.wagonNumber ?? "—"}
@@ -302,12 +302,21 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const;
function WagonCard({
wagon,
displayNo,
selectable,
selected,
shared,
onToggle,
}: {
wagon: BookingWagonAllocation;
/**
* 1-based position in THIS booking's wagon list — what the customer sees.
* Deliberately not `sequenceNo`, which is the wagon's slot in the shared
* train set and so starts wherever the previous booking left off (and
* leaves holes when wagons are cancelled). Cancellation keys off
* `allocationId`, so this number is display-only.
*/
displayNo: number;
selectable?: boolean;
selected?: boolean;
/** Shared consolidation wagon — not selectable for cancellation. */
@@ -333,7 +342,7 @@ function WagonCard({
checked={!!selected}
onChange={onToggle}
color="orange"
aria-label={`Select wagon ${wagon.sequenceNo} for cancellation`}
aria-label={`Select wagon ${displayNo} for cancellation`}
/>
)}
<Box
@@ -354,7 +363,7 @@ function WagonCard({
WAGON
</Text>
<Text fz={15} fw={800} lh={1.2}>
{wagon.sequenceNo}
{displayNo}
</Text>
</Box>
<Box>
@@ -727,7 +736,7 @@ export function WagonsTab({
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => {
{wagons.map((w, i) => {
// The shared consolidation wagon carries this booking's lone 20ft —
// its other half belongs to the partner booking, so it can never be
// cancelled on its own (the server rejects it too).
@@ -740,6 +749,7 @@ export function WagonsTab({
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
displayNo={i + 1}
shared={isSharedWagon}
selectable={
canSelect &&

View File

@@ -0,0 +1,454 @@
import {
Alert,
Box,
Button,
Card,
Center,
Group,
Loader,
Pagination,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock3,
FileText,
Lock,
Paperclip,
Search,
Train,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { cv } from "@/pages/MyPortalPage/constants";
import TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentStatus,
} from "@/services/transit-assignments.service";
const PAGE_SIZE = 8;
const PAGE_SIZE_OPTIONS = [
{ value: "8", label: "8 / page" },
{ value: "20", label: "20 / page" },
{ value: "50", label: "50 / page" },
];
/** Status pills use the portal's soft-tint / strong-ink pairs, not raw Mantine colours. */
const STATUS_META: Record<
TransitAssignmentStatus,
{ label: string; bg: string; fg: string }
> = {
NOT_STARTED: {
label: "Not started",
bg: "edr-slate-soft",
fg: "edr-slate",
},
IN_PROGRESS: { label: "In progress", bg: "edr-blue-soft", fg: "edr-blue" },
FINISHED: { label: "Finished", bg: "edr-soft", fg: "edr-green.7" },
};
const STATUS_OPTIONS = [
{ value: "NOT_STARTED", label: "Not started" },
{ value: "IN_PROGRESS", label: "In progress" },
{ value: "FINISHED", label: "Finished" },
];
const SHIPMENT_OPTIONS = [
{ value: "DISPATCHED", label: "Dispatched (in transit)" },
{ value: "SCHEDULED", label: "Scheduled" },
];
/** Minutes as "5h 30m" — the raw integer is unreadable in a grid. */
function formatMinutes(minutes: number | null): string {
if (minutes === null) return "—";
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return hours ? `${hours}h ${rest}m` : `${rest}m`;
}
/** Sentence-cases a SCREAMING_SNAKE enum for display. */
function humanize(value?: string | null): string {
if (!value) return "—";
const spaced = value.toLowerCase().replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function SummaryTile({
icon,
label,
value,
soft,
}: {
icon: React.ReactNode;
label: string;
value: number | string;
soft: string;
}) {
return (
<Box className="rounded-[20px] border border-edr-border bg-edr-card" p={16}>
<Group gap={12} wrap="nowrap">
<Box
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
style={{ background: cv(soft) }}
>
{icon}
</Box>
<Box className="min-w-0">
<Text
fz={22}
fw={800}
lh={1.1}
c="edr-text"
className="tracking-tight"
>
{value}
</Text>
<Text fz={11} fw={600} c="edr-muted" truncate>
{label}
</Text>
</Box>
</Group>
</Box>
);
}
/**
* The transit agent's work list: every booking assigned to them, with the
* document action for each.
*
* Filtering and paging are server-side — the roster grows without bound, so
* neither can depend on holding every row in the browser. Uploading is gated on
* the API's own `canUploadDocuments`, never re-derived here, so a row's action
* cannot promise something the server will reject.
*/
export default function TransitAgentBookingsPage() {
const [active, setActive] = useState<TransitAssignment | null>(null);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [status, setStatus] = useState<string | null>(null);
const [shipment, setShipment] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
// Any filter change invalidates the current page number: staying on page 3 of
// a freshly narrowed result set shows an empty table.
useEffect(() => {
setPage(1);
}, [debouncedSearch, status, shipment, pageSize]);
const query = useQuery({
queryKey: [
"transit-assignments",
{ search: debouncedSearch, status, shipment, page, pageSize },
],
queryFn: () =>
transitAssignmentsService.list({
search: debouncedSearch || undefined,
status: (status as TransitAssignmentStatus) ?? undefined,
schedulingStatus: shipment ?? undefined,
page,
pageSize,
}),
// Keeps the previous page visible while the next one loads, so paging does
// not flash an empty table.
placeholderData: keepPreviousData,
});
const items = query.data?.items ?? [];
const meta = query.data?.meta;
const hasFilters = !!(debouncedSearch || status || shipment);
// Counts describe the CURRENT PAGE, and say so — deriving totals from a
// paginated slice would quietly under-report the agent's real workload.
const pageCounts = useMemo(
() => ({
open: items.filter((a) => a.status !== "FINISHED").length,
uploadable: items.filter((a) => a.canUploadDocuments).length,
documents: items.reduce((sum, a) => sum + (a.files?.length ?? 0), 0),
}),
[items],
);
const clearFilters = () => {
setSearch("");
setStatus(null);
setShipment(null);
};
return (
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap={4}>
<Title order={2}>Bookings</Title>
<Text fz={13} c="edr-muted">
Shipments assigned to you for transit. Documents can be uploaded once
a booking is dispatched, and are locked when you finish it.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 4 }} spacing="sm">
<SummaryTile
icon={<Train size={18} color={cv("edr-blue")} strokeWidth={2} />}
label={meta ? "Assigned to you" : "Assigned"}
value={meta?.total ?? "—"}
soft="edr-blue-soft"
/>
<SummaryTile
icon={
<Clock3 size={18} color={cv("edr-amber-text")} strokeWidth={2} />
}
label="Open on this page"
value={pageCounts.open}
soft="edr-amber-soft"
/>
<SummaryTile
icon={
<CheckCircle2 size={18} color={cv("edr-green.7")} strokeWidth={2} />
}
label="Ready for documents"
value={pageCounts.uploadable}
soft="edr-soft"
/>
<SummaryTile
icon={<Paperclip size={18} color={cv("edr-slate")} strokeWidth={2} />}
label="Documents on this page"
value={pageCounts.documents}
soft="edr-slate-soft"
/>
</SimpleGrid>
<Card withBorder radius="md" p="md">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
flex="1 1 240px"
label="Search"
placeholder="Booking reference or customer"
leftSection={<Search size={15} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<Select
w={190}
label="My status"
placeholder="Any"
data={STATUS_OPTIONS}
value={status}
onChange={setStatus}
clearable
/>
<Select
w={210}
label="Shipment"
placeholder="Any"
data={SHIPMENT_OPTIONS}
value={shipment}
onChange={setShipment}
clearable
/>
{hasFilters ? (
<Button
variant="subtle"
color="edr-slate"
leftSection={<X size={15} />}
onClick={clearFilters}
>
Clear
</Button>
) : null}
</Group>
</Card>
{query.isPending ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : query.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{(query.error as Error).message}
</Alert>
) : items.length === 0 ? (
<Card withBorder radius="md" py={64}>
<Stack align="center" gap="xs">
<FileText size={28} opacity={0.4} />
<Text fz={13} c="edr-muted">
{hasFilters
? "No bookings match these filters."
: "No bookings have been assigned to you yet."}
</Text>
{hasFilters ? (
<Button variant="subtle" size="compact-sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
</Stack>
</Card>
) : (
<Card withBorder radius="md" p={0}>
<Table.ScrollContainer minWidth={880}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Shipment</Table.Th>
<Table.Th>My status</Table.Th>
<Table.Th ta="center">Docs</Table.Th>
<Table.Th>Time after arrival</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((assignment) => {
const statusMeta = STATUS_META[assignment.status];
const docCount = assignment.files?.length ?? 0;
const locked = !assignment.canUploadDocuments;
return (
<Table.Tr key={assignment.id}>
<Table.Td>
<Text size="sm" fw={600}>
{assignment.booking?.reference ?? "—"}
</Text>
</Table.Td>
<Table.Td style={{ maxWidth: 260 }}>
<Tooltip
label={assignment.customerName ?? "—"}
disabled={!assignment.customerName}
multiline
w={280}
>
<Text size="sm" truncate>
{assignment.customerName ?? "—"}
</Text>
</Tooltip>
</Table.Td>
<Table.Td>
<Group gap={7} wrap="nowrap">
<Box
className="size-1.5 shrink-0 rounded-full"
style={{
background:
assignment.booking?.schedulingStatus ===
"DISPATCHED"
? cv("edr-blue-dot")
: cv("edr-step-idle"),
}}
/>
<Text fz={12} c="edr-text">
{humanize(assignment.booking?.schedulingStatus)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Box
px={9}
py={3}
className="inline-flex w-fit rounded-full"
style={{ background: cv(statusMeta.bg) }}
>
<Text
fz={10}
fw={700}
style={{ color: cv(statusMeta.fg) }}
>
{statusMeta.label}
</Text>
</Box>
</Table.Td>
<Table.Td ta="center">
<Text size="sm" c={docCount ? undefined : "dimmed"}>
{docCount || "—"}
</Text>
</Table.Td>
<Table.Td>
<Text fz={13} c="edr-muted">
{formatMinutes(assignment.timeAfterTrainArrives)}
</Text>
</Table.Td>
<Table.Td>
<Group justify="flex-end" wrap="nowrap">
<Button
size="compact-sm"
variant={locked ? "subtle" : "light"}
color={locked ? "gray" : undefined}
leftSection={
locked ? (
<Lock size={13} />
) : (
<FileText size={13} />
)
}
onClick={() => setActive(assignment)}
>
{locked ? "View" : "Documents"}
</Button>
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{meta ? (
<Group
justify="space-between"
p="md"
wrap="wrap"
gap="sm"
style={{
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
<Group gap="xs" wrap="nowrap">
<Text fz={13} c="edr-muted">
Showing {(meta.page - 1) * meta.pageSize + 1}
{Math.min(meta.page * meta.pageSize, meta.total)} of{" "}
{meta.total}
</Text>
<Select
size="xs"
w={110}
aria-label="Rows per page"
data={PAGE_SIZE_OPTIONS}
value={String(pageSize)}
onChange={(v) => setPageSize(Number(v) || PAGE_SIZE)}
allowDeselect={false}
/>
</Group>
{/* Rendered even on a single page: the control disappearing as
the result set shrinks reads as a broken table rather than as
"there is only one page". */}
<Pagination
size="sm"
value={meta.page}
total={meta.totalPages}
onChange={setPage}
withEdges
disabled={meta.totalPages <= 1}
/>
</Group>
) : null}
</Card>
)}
<TransitAgentDocumentsModal
assignment={active}
onClose={() => setActive(null)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,682 @@
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Center,
FileButton,
Group,
Image,
Loader,
Modal,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { resolveViewerKind } from "@edr/ui-common";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Eye,
File as FileIcon,
FileSpreadsheet,
FileText,
Film,
ImageIcon,
Lock,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFileViewer } from "@/hooks/useFileViewer";
import { fetchViewableFile, filesService } from "@/services/files.service";
import {
transitAssignmentsService,
type TransitAssignment,
type TransitAssignmentFile,
} from "@/services/transit-assignments.service";
const formatBytes = (bytes: number): string =>
bytes >= 1024 * 1024
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
: `${Math.max(1, Math.round(bytes / 1024))} KB`;
/** Icon + colour per viewer kind, so a file's type reads at a glance. */
function kindVisuals(name: string, mimeType?: string | null) {
const kind = resolveViewerKind({ name, url: "", mimeType });
switch (kind) {
case "image":
return { icon: <ImageIcon size={18} />, color: "grape" };
case "pdf":
return { icon: <FileText size={18} />, color: "red" };
case "video":
case "audio":
return { icon: <Film size={18} />, color: "indigo" };
case "office":
return { icon: <FileSpreadsheet size={18} />, color: "teal" };
case "text":
return { icon: <FileText size={18} />, color: "blue" };
default:
return { icon: <FileIcon size={18} />, color: "gray" };
}
}
/** One already-uploaded document. */
function UploadedFileCard({
file,
locked,
busy,
onView,
onRemove,
}: {
file: TransitAssignmentFile;
locked: boolean;
busy: boolean;
onView: () => void;
onRemove: () => void;
}) {
const visuals = kindVisuals(file.name, file.mimeType);
const isImage = (file.mimeType ?? "").startsWith("image/");
const [thumb, setThumb] = useState<string | null>(null);
/**
* Thumbnails are fetched through the API, not linked straight from `file.url`.
* That URL points at MinIO, which is not reachable from the browser, and
* `GET /api/files/:id` is JWT-guarded — so a bare `<img src>` gets either a
* DNS failure or a 401. The bytes come down the authenticated axios client
* and become a blob URL, revoked when the card unmounts.
*/
useEffect(() => {
if (!isImage) return;
let objectUrl: string | null = null;
let cancelled = false;
void filesService
.download(file.id)
.then((blob) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setThumb(objectUrl);
})
// A failed thumbnail is not worth surfacing — the card falls back to its
// type icon and the preview button still reports any real error.
.catch(() => undefined);
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [file.id, isImage]);
return (
<Paper withBorder radius="md" p="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
{isImage && thumb ? (
// A thumbnail is worth more than an icon for scanned paperwork, which
// is most of what gets filed here.
<Image
src={thumb}
alt={file.name}
w={44}
h={44}
radius="sm"
fit="cover"
/>
) : (
<ThemeIcon
variant="light"
color={visuals.color}
size={44}
radius="sm"
>
{visuals.icon}
</ThemeIcon>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Tooltip label={file.title || file.name} openDelay={400}>
<Text size="sm" fw={500} truncate>
{file.title || file.name}
</Text>
</Tooltip>
<Text size="xs" c="dimmed">
{formatBytes(file.size)} ·{" "}
{new Date(file.uploadedAt).toLocaleString()}
</Text>
{file.uploadedByName ? (
<Text size="xs" c="dimmed" truncate>
by {file.uploadedByName}
</Text>
) : null}
</Stack>
<Group gap={4} wrap="nowrap">
<Tooltip label="Preview">
<ActionIcon variant="subtle" onClick={onView}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
{locked ? null : (
<Tooltip label="Remove">
<ActionIcon
variant="subtle"
color="red"
disabled={busy}
onClick={onRemove}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
/** A file chosen but not yet uploaded: local preview plus the name to file it under. */
function PendingFileCard({
file,
title,
previewUrl,
onTitleChange,
onView,
onRemove,
disabled,
}: {
file: File;
title: string;
previewUrl: string;
onTitleChange: (title: string) => void;
onView: () => void;
onRemove: () => void;
disabled: boolean;
}) {
const visuals = kindVisuals(file.name, file.type);
const isImage = file.type.startsWith("image/");
return (
<Paper withBorder radius="md" p="xs" bg="var(--mantine-color-blue-light)">
<Group gap="sm" wrap="nowrap">
{isImage ? (
<Image
src={previewUrl}
alt={file.name}
w={36}
h={36}
radius="sm"
fit="cover"
/>
) : (
<ThemeIcon
variant="light"
color={visuals.color}
size={36}
radius="sm"
>
{visuals.icon}
</ThemeIcon>
)}
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<TextInput
size="xs"
placeholder="Document name (optional)"
value={title}
disabled={disabled}
onChange={(e) => onTitleChange(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed" truncate>
{file.name} · {formatBytes(file.size)}
</Text>
</Stack>
<Group gap={2} wrap="nowrap">
<Tooltip label="Preview before uploading">
<ActionIcon variant="subtle" onClick={onView}>
<Eye size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove from selection">
<ActionIcon
variant="subtle"
color="red"
disabled={disabled}
onClick={onRemove}
>
<X size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
}
/**
* Documents for one transit assignment: what has been filed, and what is about
* to be.
*
* Uploading is gated on the server by two rules — the booking must be
* dispatched, and the assignment must not be finished. Rather than
* re-implementing them here, the modal reads the server's own
* `canUploadDocuments`, so the disabled state can never disagree with what the
* API would accept.
*/
export default function TransitAgentDocumentsModal({
assignment,
onClose,
}: {
assignment: TransitAssignment | null;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
/**
* Files chosen but not yet uploaded, each with the name the agent gives it.
* Kept as objects rather than two parallel arrays so removing one entry
* cannot desynchronise a file from its title.
*/
const [pending, setPending] = useState<{ file: File; title: string }[]>([]);
const [note, setNote] = useState("");
/**
* Object URLs for the staged files, keyed by File identity.
*
* Keyed rather than rebuilt as an array: removing one file from the selection
* would otherwise re-create every URL and revoke the old ones, which kills the
* preview of a DIFFERENT file if the viewer happens to be open on it. Only
* URLs whose file has actually left the selection are revoked.
*/
const previewsRef = useRef(new Map<File, string>());
const previews = useMemo(() => {
const next = new Map<File, string>();
for (const { file } of pending) {
next.set(
file,
previewsRef.current.get(file) ?? URL.createObjectURL(file),
);
}
for (const [file, url] of previewsRef.current) {
if (!next.has(file)) URL.revokeObjectURL(url);
}
previewsRef.current = next;
return next;
}, [pending]);
// Last resort: revoke whatever is still held when the modal unmounts.
useEffect(
() => () => {
for (const url of previewsRef.current.values()) URL.revokeObjectURL(url);
previewsRef.current = new Map();
},
[],
);
// Each assignment opens a fresh sheet — files staged for one must not follow
// the modal onto another.
useEffect(() => {
setPending([]);
setNote(assignment?.note ?? "");
}, [assignment?.id, assignment?.note]);
const detailQuery = useQuery({
queryKey: ["transit-assignment", assignment?.id],
queryFn: () => transitAssignmentsService.getById(assignment!.id),
enabled: assignment !== null,
});
const detail = detailQuery.data ?? assignment;
const locked = !detail?.canUploadDocuments;
const isFinished = detail?.status === "FINISHED";
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["transit-assignments"] }),
queryClient.invalidateQueries({
queryKey: ["transit-assignment", assignment?.id],
}),
]);
};
const uploadMutation = useMutation({
mutationFn: () =>
transitAssignmentsService.uploadFiles(assignment!.id, pending),
onSuccess: async () => {
setPending([]);
await invalidate();
},
});
/**
* Save = persist everything the agent has entered, without closing the work.
*
* Staged files are uploaded FIRST, then the note is saved: a Save that left
* the chosen files sitting in the browser would look like it had stored them,
* and they would be silently lost on close. The status only ever moves to
* IN_PROGRESS here — finishing is a separate, deliberate action.
*/
const saveMutation = useMutation({
mutationFn: async () => {
if (pending.length > 0) {
await transitAssignmentsService.uploadFiles(assignment!.id, pending);
}
return transitAssignmentsService.submit(assignment!.id, {
finish: false,
note,
});
},
onSuccess: async () => {
setPending([]);
await invalidate();
},
});
const removeMutation = useMutation({
mutationFn: (fileId: string) =>
transitAssignmentsService.removeFile(assignment!.id, fileId),
onSuccess: invalidate,
});
// Save keeps the assignment open; finish closes it AND locks the documents,
// which is why the button asks first.
const finishMutation = useMutation({
mutationFn: async () => {
// Flush anything still staged before closing: finishing locks uploads, so
// a file left behind here could never be filed afterwards.
if (pending.length > 0) {
await transitAssignmentsService.uploadFiles(assignment!.id, pending);
}
return transitAssignmentsService.submit(assignment!.id, {
finish: true,
note,
});
},
onSuccess: async () => {
setPending([]);
await invalidate();
onClose();
},
});
/**
* Open a stored document in the viewer.
*
* The bytes are fetched through the authenticated API rather than linked from
* `file.url`: that column holds the MinIO object URL, which the browser
* cannot reach, and `GET /api/files/:id` requires the Bearer token that a
* raw `<iframe>`/`<img>` load would not carry.
*/
const [viewerError, setViewerError] = useState<string | null>(null);
const openUploaded = (file: TransitAssignmentFile) => {
setViewerError(null);
void fetchViewableFile(file.id, file.title || file.name)
.then((viewable) =>
view({ ...viewable, mimeType: viewable.mimeType ?? file.mimeType }),
)
.catch((error: Error) =>
setViewerError(error.message || "Could not open this document."),
);
};
const files = detail?.files ?? [];
const busy =
uploadMutation.isPending ||
removeMutation.isPending ||
saveMutation.isPending ||
finishMutation.isPending;
return (
<>
<Modal
opened={assignment !== null}
onClose={onClose}
title={
<Group gap="xs">
<Text fw={600}>Documents</Text>
<Text c="dimmed">{detail?.booking?.reference ?? ""}</Text>
</Group>
}
size="lg"
centered
>
{detailQuery.isPending ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : (
<Stack gap="md">
{detail?.customerName ? (
<Text size="sm" c="dimmed">
{detail.customerName}
</Text>
) : null}
{isFinished ? (
<Alert
color="teal"
variant="light"
icon={<CheckCircle2 size={16} />}
>
This assignment is finished. Its documents are locked and can no
longer be added to or removed.
</Alert>
) : locked ? (
<Alert
color="yellow"
variant="light"
icon={<AlertCircle size={16} />}
>
Documents can be uploaded once this booking has been dispatched.
It is currently{" "}
{detail?.booking?.schedulingStatus
?.toLowerCase()
.replace(/_/g, " ") ?? "not dispatched"}
.
</Alert>
) : null}
{viewerError ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={16} />}
>
{viewerError}
</Alert>
) : null}
<Stack gap="xs">
<Group gap="xs">
<Text size="sm" fw={600}>
Uploaded
</Text>
<Badge size="sm" variant="light" circle={files.length < 10}>
{files.length}
</Badge>
</Group>
{files.length === 0 ? (
<Card withBorder radius="md" py="lg">
<Stack align="center" gap={4}>
<FileIcon size={22} opacity={0.35} />
<Text size="sm" c="dimmed">
No documents uploaded yet.
</Text>
</Stack>
</Card>
) : (
<ScrollArea.Autosize mah={260}>
<Stack gap="xs">
{files.map((file) => (
<UploadedFileCard
key={file.id}
file={file}
locked={locked}
busy={busy}
onView={() => openUploaded(file)}
onRemove={() => removeMutation.mutate(file.id)}
/>
))}
</Stack>
</ScrollArea.Autosize>
)}
</Stack>
{locked ? null : (
<>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" fw={600}>
Add documents
</Text>
<FileButton
multiple
onChange={(chosen) =>
setPending((current) => [
...current,
// Appended, not replaced: picking a second time must
// add to the batch rather than discard the first pick
// and the names already typed for it.
...chosen.map((file) => ({ file, title: "" })),
])
}
>
{(props) => (
<Button
{...props}
size="compact-sm"
variant="light"
leftSection={<Upload size={14} />}
disabled={busy}
>
Choose files
</Button>
)}
</FileButton>
</Group>
{pending.length > 0 ? (
<>
<ScrollArea.Autosize mah={180}>
<Stack gap={6}>
{pending.map((entry, index) => (
<PendingFileCard
key={`${entry.file.name}-${index}`}
file={entry.file}
title={entry.title}
previewUrl={previews.get(entry.file) ?? ""}
disabled={busy}
onTitleChange={(title) =>
setPending((current) =>
current.map((item, i) =>
i === index ? { ...item, title } : item,
),
)
}
onView={() =>
view({
name: entry.title || entry.file.name,
url: previews.get(entry.file) ?? "",
mimeType: entry.file.type,
})
}
onRemove={() =>
setPending((current) =>
current.filter((_, i) => i !== index),
)
}
/>
))}
</Stack>
</ScrollArea.Autosize>
<Group>
<Button
leftSection={<Upload size={15} />}
loading={uploadMutation.isPending}
onClick={() => uploadMutation.mutate()}
>
Upload {pending.length} file
{pending.length === 1 ? "" : "s"}
</Button>
<Button
variant="subtle"
color="gray"
disabled={busy}
onClick={() => setPending([])}
>
Clear
</Button>
</Group>
</>
) : null}
{uploadMutation.isError ? (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={16} />}
>
{(uploadMutation.error as Error).message}
</Alert>
) : null}
</Stack>
<Textarea
label="Note"
placeholder="Anything worth recording about this clearance"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
minRows={3}
autosize
/>
<Group justify="flex-end">
<Button
variant="default"
loading={saveMutation.isPending}
disabled={busy}
onClick={() => saveMutation.mutate()}
>
{pending.length > 0
? `Save & upload ${pending.length}`
: "Save"}
</Button>
<Button
color="teal"
leftSection={<Lock size={15} />}
loading={finishMutation.isPending}
disabled={busy}
onClick={() => {
// Finishing is one-way: it closes the assignment and locks
// its documents, so confirm before doing it.
if (
window.confirm(
pending.length > 0
? `Upload ${pending.length} staged file(s) and finish this assignment? Its documents will then be locked.`
: "Finish this assignment? Its documents will be locked and can no longer be changed.",
)
) {
finishMutation.mutate();
}
}}
>
Finish
</Button>
</Group>
</>
)}
</Stack>
)}
</Modal>
{viewer}
</>
);
}

View File

@@ -0,0 +1,654 @@
import { Box, Button, Center, Group, Loader, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowRight,
FileCheck2,
FileX2,
Paperclip,
Play,
Target,
Timer,
} from "lucide-react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Card } from "@/pages/MyPortalPage/components";
import { cv } from "@/pages/MyPortalPage/constants";
import {
transitAssignmentsService,
type TransitStatItem,
type TransitStats,
} from "@/services/transit-assignments.service";
/** Minutes as a compact "3h 10m" / "45m" — raw integers are unreadable in a grid. */
function formatMinutes(minutes: number | null): string {
if (minutes === null) return "—";
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h ? `${h}h${m ? ` ${m}m` : ""}` : `${m}m`;
}
/** Split a duration so the number and its unit can be styled apart. */
function splitDuration(minutes: number | null): [string, string] {
if (minutes === null) return ["—", ""];
if (minutes < 90) return [String(minutes), "min"];
return [(minutes / 60).toFixed(1), "hrs"];
}
const pctOf = (part: number, total: number) =>
total ? Math.round((part / total) * 100) : 0;
type Tone = "green" | "amber" | "blue" | "slate" | "red";
const TONES: Record<Tone, { soft: string; ink: string }> = {
green: { soft: cv("edr-soft"), ink: cv("edr-green.7") },
amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") },
blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") },
slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") },
red: { soft: cv("edr-red-soft"), ink: cv("edr-red") },
};
/** Section heading shared by every panel, so the rhythm stays identical. */
function PanelHead({
title,
hint,
right,
}: {
title: string;
hint?: string;
right?: React.ReactNode;
}) {
return (
<Group justify="space-between" align="start" wrap="nowrap" mb="md">
<Box>
<Text fz={15} fw={700} c="edr-text">
{title}
</Text>
{hint ? (
<Text fz={12} c="edr-muted" mt={2}>
{hint}
</Text>
) : null}
</Box>
{right}
</Group>
);
}
/**
* One headline metric. Built on the portal's own KPI language — soft icon chip,
* tight numeral, muted label — rather than a generic bordered box per stat.
*/
function Kpi({
icon: Icon,
label,
value,
unit,
caption,
tone,
divider,
}: {
icon: typeof Timer;
label: string;
value: string;
unit: string;
caption: string;
tone: Tone;
divider?: boolean;
}) {
const t = TONES[tone];
return (
<Box
className={
divider
? "flex flex-col border-t border-edr-divider pt-5 lg:border-l lg:border-t-0 lg:pl-6 lg:pt-0"
: "flex flex-col"
}
>
<Group gap={12} wrap="nowrap" align="start">
<Box
className="flex size-10 shrink-0 items-center justify-center rounded-xl"
style={{ background: t.soft }}
>
<Icon size={18} color={t.ink} strokeWidth={2} />
</Box>
<Box className="min-w-0">
<Group gap={5} align="baseline" wrap="nowrap">
<Text
fz={26}
fw={800}
lh={1.05}
c="edr-text"
className="tracking-tight"
>
{value}
</Text>
{unit ? (
<Text fz={12} fw={600} c="edr-muted">
{unit}
</Text>
) : null}
</Group>
<Text fz={12} fw={600} c="edr-text" mt={6} truncate>
{label}
</Text>
<Text fz={11} c="edr-muted" truncate>
{caption}
</Text>
</Box>
</Group>
</Box>
);
}
/**
* One booking's arrival→clearance track: a pickup-lag segment followed by the
* clearance work, both on one shared scale so rows compare directly.
*/
function TimelineRow({
item,
scaleMax,
}: {
item: TransitStatItem;
scaleMax: number;
}) {
const pickup = item.pickupMinutes ?? 0;
const total = item.clearanceMinutes;
const work = total !== null ? Math.max(total - pickup, 0) : 0;
const pct = (v: number) => `${Math.min((v / scaleMax) * 100, 100)}%`;
const tone: Tone =
total === null
? "slate"
: total <= 120
? "green"
: total <= 360
? "amber"
: "red";
return (
<Group gap="md" wrap="nowrap">
<Box className="w-[132px] shrink-0">
<Text fz={12} fw={700} c="edr-text" className="font-mono">
{item.reference?.replace("BK-2026-", "…") ?? "—"}
</Text>
<Text fz={10} c="edr-muted" truncate>
{item.customerName ?? "—"}
</Text>
</Box>
<Box className="flex h-8 flex-1 items-center overflow-hidden rounded-lg bg-edr-slate-soft2">
{pickup > 0 ? (
<Box
className="h-8 shrink-0"
style={{ width: pct(pickup), background: cv("edr-blue-dot") }}
/>
) : null}
{total !== null ? (
<Box
className="h-8 shrink-0"
style={{ width: pct(work), background: TONES[tone].ink }}
/>
) : (
<Text fz={10} c="edr-muted" pl="sm">
{item.status === "NOT_STARTED" ? "not started" : "in progress"}
</Text>
)}
</Box>
<Text
fz={12}
fw={700}
className="w-14 shrink-0 text-right font-mono"
style={{ color: total === null ? cv("edr-muted") : TONES[tone].ink }}
>
{formatMinutes(total)}
</Text>
</Group>
);
}
/** A single SLA band as a labelled proportional bar. */
function SlaBar({
label,
count,
total,
tone,
}: {
label: string;
count: number;
total: number;
tone: Tone;
}) {
const pct = pctOf(count, total);
return (
<Box>
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8} wrap="nowrap">
<Box
className="size-2 shrink-0 rounded-full"
style={{ background: TONES[tone].ink }}
/>
<Text fz={12} fw={600} c="edr-text">
{label}
</Text>
</Group>
<Text fz={12} c="edr-muted">
{count} · {pct}%
</Text>
</Group>
<Box className="h-1.5 overflow-hidden rounded-full bg-edr-slate-soft2">
<Box
className="h-full rounded-full"
style={{ width: `${pct}%`, background: TONES[tone].ink }}
/>
</Box>
</Box>
);
}
/**
* The transit agent's dashboard: how quickly documents are filed after the
* train dispatches and arrives.
*
* Every figure comes from `GET /transit-assignments/my/stats`, which derives
* them from timestamps that already exist. The page renders what the API
* measured rather than recomputing, so the two cannot disagree.
*/
export default function TransitAgentOverviewPage() {
const navigate = useNavigate();
const query = useQuery({
queryKey: ["transit-stats"],
queryFn: transitAssignmentsService.stats,
});
const s: TransitStats | undefined = query.data;
const timeline = useMemo(
() =>
(s?.items ?? [])
.filter((i) => i.schedulingStatus === "DISPATCHED")
.slice(0, 6),
[s],
);
// One shared scale, capped at 6h: a single 47h outlier would otherwise
// compress every other row into an invisible sliver.
const scaleMax = useMemo(() => {
const measured = timeline
.map((i) => i.clearanceMinutes)
.filter((v): v is number => v !== null);
return Math.min(Math.max(...measured, 120) * 1.15, 360);
}, [timeline]);
if (query.isPending) {
return (
<Center h={420}>
<Loader size="sm" color="edr-green" />
</Center>
);
}
if (query.isError || !s) {
return (
<Box p={{ base: 16, sm: 24, lg: 32 }}>
<Card>
<Group gap="sm">
<AlertCircle size={18} color={cv("edr-red")} />
<Text fz={14} c="edr-text">
{(query.error as Error)?.message ??
"Could not load your overview."}
</Text>
</Group>
</Card>
</Box>
);
}
const { totals, performance, sla, coverage } = s;
const [medianValue, medianUnit] = splitDuration(
performance.medianClearanceMinutes,
);
const [pickupValue, pickupUnit] = splitDuration(
performance.medianPickupMinutes,
);
const uncovered = coverage.dispatched - coverage.withDocuments;
const coveragePct = pctOf(coverage.withDocuments, coverage.dispatched);
return (
<Box className="bg-edr-bg" p={{ base: 16, sm: 24, lg: 32 }}>
<Stack gap="lg">
<Group justify="space-between" align="end" wrap="wrap" gap="sm">
<Box>
<Text fz={26} fw={800} c="edr-text" className="tracking-tight">
Clearance performance
</Text>
<Text fz={13} c="edr-muted" mt={4}>
How fast documents are filed after the train dispatches and
arrives
</Text>
</Box>
{totals.open > 0 ? (
<Group
gap={8}
px={12}
py={7}
className="rounded-full"
style={{ background: cv("edr-soft") }}
wrap="nowrap"
>
<Box
className="size-1.5 rounded-full"
style={{ background: cv("edr-green.6") }}
/>
<Text fz={12} fw={700} style={{ color: cv("edr-green.7") }}>
{totals.open} active now
</Text>
</Group>
) : null}
</Group>
<Card>
<Box className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<Kpi
icon={Timer}
label="Median clearance"
value={medianValue}
unit={medianUnit}
caption={`across ${performance.measured} finished`}
tone="green"
/>
<Kpi
icon={Play}
label="Pickup lag"
value={pickupValue}
unit={pickupUnit}
caption="arrival → work started"
tone="blue"
divider
/>
<Kpi
icon={Paperclip}
label="Documents filed"
value={String(totals.documents)}
unit="files"
caption={`across ${totals.assignments} bookings`}
tone={uncovered > 0 ? "amber" : "green"}
divider
/>
<Kpi
icon={Target}
label="On-time rate"
value={
performance.onTimeRate === null
? "—"
: String(performance.onTimeRate)
}
unit={performance.onTimeRate === null ? "" : "%"}
caption="cleared within 6h"
tone="green"
divider
/>
</Box>
</Card>
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Box className="lg:col-span-2">
<Card>
<PanelHead
title="Arrival → clearance"
hint="Time from the train arriving to documents filed"
right={
<Group gap={14} wrap="nowrap">
{(
[
[cv("edr-blue-dot"), "pickup"],
[cv("edr-green.7"), "cleared"],
[cv("edr-red"), "breach"],
] as const
).map(([color, label]) => (
<Group gap={6} key={label} wrap="nowrap">
<Box
className="size-2 rounded-sm"
style={{ background: color }}
/>
<Text fz={11} c="edr-muted">
{label}
</Text>
</Group>
))}
</Group>
}
/>
{timeline.length === 0 ? (
<Text fz={13} c="edr-muted" ta="center" py={40}>
No dispatched bookings yet.
</Text>
) : (
<Stack gap={14}>
{timeline.map((item) => (
<TimelineRow
key={item.id}
item={item}
scaleMax={scaleMax}
/>
))}
</Stack>
)}
</Card>
</Box>
<Stack gap="md">
<Card>
<PanelHead
title="Clearance SLA"
right={
<Text fz={11} c="edr-muted">
{performance.measured} measured
</Text>
}
/>
<Stack gap={14}>
<SlaBar
label="Under 2h"
count={sla.under2h}
total={performance.measured}
tone="green"
/>
<SlaBar
label="2h 6h"
count={sla.under6h}
total={performance.measured}
tone="amber"
/>
<SlaBar
label="Over 6h"
count={sla.over6h}
total={performance.measured}
tone="red"
/>
</Stack>
</Card>
<Card>
<PanelHead
title="Document coverage"
hint="Dispatched bookings with evidence filed"
/>
<Group align="baseline" gap={6} mb={10}>
<Text
fz={30}
fw={800}
lh={1}
c="edr-text"
className="tracking-tight"
>
{coveragePct}%
</Text>
<Text fz={12} c="edr-muted">
{coverage.withDocuments} of {coverage.dispatched}
</Text>
</Group>
<Box className="mb-4 h-2 overflow-hidden rounded-full bg-edr-slate-soft2">
<Box
className="h-full rounded-full"
style={{
width: `${coveragePct}%`,
background:
uncovered > 0 ? cv("edr-amber-text") : cv("edr-green.6"),
}}
/>
</Box>
{uncovered > 0 ? (
<Group
gap={10}
p={12}
align="start"
wrap="nowrap"
className="mb-4 rounded-xl"
style={{ background: cv("edr-amber-soft") }}
>
<FileX2
size={15}
color={cv("edr-amber-text")}
className="shrink-0"
/>
<Text
fz={11}
lh={1.5}
style={{ color: cv("edr-amber-text") }}
>
{uncovered} dispatched booking
{uncovered === 1 ? " has" : "s have"} no documents filed.
</Text>
</Group>
) : (
<Group
gap={10}
p={12}
align="start"
wrap="nowrap"
className="mb-4 rounded-xl"
style={{ background: cv("edr-soft") }}
>
<FileCheck2
size={15}
color={cv("edr-green.7")}
className="shrink-0"
/>
<Text fz={11} lh={1.5} style={{ color: cv("edr-green.7") }}>
Every dispatched booking has evidence filed.
</Text>
</Group>
)}
<Button
fullWidth
color="edr-green"
rightSection={<ArrowRight size={15} />}
onClick={() => navigate("/transit-agent/bookings")}
>
{uncovered > 0 ? "File missing documents" : "Open bookings"}
</Button>
</Card>
</Stack>
</Box>
<Card padding={0}>
<Box px={24} pt={20} pb={14}>
<PanelHead
title="Recent assignments"
right={
<Text fz={11} c="edr-muted">
{totals.assignments} total
</Text>
}
/>
</Box>
<Box>
{s.items.slice(0, 6).map((item) => (
<Group
key={item.id}
justify="space-between"
px={24}
py={14}
wrap="nowrap"
className="border-t border-edr-divider"
>
<Group gap="md" wrap="nowrap" className="min-w-0 flex-1">
<Text
fz={12}
fw={600}
c="edr-text"
className="w-[132px] font-mono"
>
{item.reference ?? "—"}
</Text>
<Box
px={9}
py={3}
className="shrink-0 rounded-full"
style={{
background:
item.status === "FINISHED"
? cv("edr-soft")
: item.status === "IN_PROGRESS"
? cv("edr-blue-soft")
: cv("edr-slate-soft"),
}}
>
<Text
fz={10}
fw={700}
style={{
color:
item.status === "FINISHED"
? cv("edr-green.7")
: item.status === "IN_PROGRESS"
? cv("edr-blue")
: cv("edr-slate"),
}}
>
{item.status === "FINISHED"
? "Finished"
: item.status === "IN_PROGRESS"
? "In progress"
: "Not started"}
</Text>
</Box>
<Text fz={12} c="edr-muted" truncate>
{item.customerName ?? "—"}
</Text>
</Group>
<Group gap={22} wrap="nowrap" className="shrink-0">
<Text fz={12} c="edr-muted" className="font-mono">
{formatMinutes(item.clearanceMinutes)}
</Text>
<Group gap={6} wrap="nowrap" className="w-10">
{item.documentCount > 0 ? (
<FileCheck2 size={13} color={cv("edr-green.6")} />
) : (
<FileX2 size={13} color={cv("edr-step-idle")} />
)}
<Text
fz={12}
className="font-mono"
style={{
color:
item.documentCount > 0
? cv("edr-green.7")
: cv("edr-muted"),
}}
>
{item.documentCount}
</Text>
</Group>
</Group>
</Group>
))}
</Box>
</Card>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,2 @@
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";

View File

@@ -103,7 +103,7 @@ export interface CompanyProfileResponse {
* "shipping line" would skip onboarding for customers whenever the request
* failed. Absent (older responses) means `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
export interface CompanyInfoResponse {
accountKind?: AccountKind;
@@ -138,13 +138,38 @@ export interface ShippingLineInfoResponse {
review: null;
}
/** `GET /companies/getInfo` serves both portal audiences. */
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
/**
* A signed-in transit agent. Like a shipping line it has no company, no
* external profile and no onboarding — the agent record itself is the account.
*/
export interface TransitAgentInfoResponse {
accountKind: "transit_agent";
id: string;
name: string;
email: string | null;
phoneNumber: string | null;
isActive: boolean;
validFrom: string;
validTo: string;
company: null;
profile: null;
review: null;
}
/** `GET /companies/getInfo` serves every portal audience. */
export type AccountInfoResponse =
| CompanyInfoResponse
| ShippingLineInfoResponse
| TransitAgentInfoResponse;
export const isShippingLineAccount = (
info: AccountInfoResponse | null | undefined,
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
export const isTransitAgentAccount = (
info: AccountInfoResponse | null | undefined,
): info is TransitAgentInfoResponse => info?.accountKind === "transit_agent";
/** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse {
id: string;

View File

@@ -0,0 +1,172 @@
import { client } from "@/utils/api";
const BASE = "/api/transit-assignments/my";
/** One document attached to an assignment. */
export interface TransitAssignmentFile {
id: string;
name: string;
title: string | null;
url: string;
size: number;
mimeType: string;
uploadedAt: string;
updatedAt: string;
uploadedByName: string | null;
}
export type TransitAssignmentStatus =
| "NOT_STARTED"
| "IN_PROGRESS"
| "FINISHED";
/**
* One booking assigned to the signed-in transit agent.
*
* `canUploadDocuments` is computed server-side from BOTH gates (the booking is
* dispatched, and the assignment is not finished). The UI reads that flag
* rather than re-deriving the rule, so the button state cannot drift from what
* the API will actually accept.
*/
export interface TransitAssignment {
id: string;
bookingId: string;
transitAgentId: string;
status: TransitAssignmentStatus;
startedAt: string | null;
finishedAt: string | null;
assignedAt: string;
note: string | null;
timeAfterTrainArrives: number | null;
canUploadDocuments: boolean;
/** Whose cargo this is — flattened server-side off the booking's company. */
customerName: string | null;
files?: TransitAssignmentFile[];
booking?: {
id: string;
reference?: string | null;
status?: string | null;
schedulingStatus?: string | null;
tradeDirection?: string | null;
arrivedAt?: string | null;
} | null;
}
export interface TransitAssignmentListParams {
search?: string;
status?: TransitAssignmentStatus;
schedulingStatus?: string;
page?: number;
pageSize?: number;
}
export interface TransitAssignmentListResult {
items: TransitAssignment[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
/** One row behind the overview's timeline and activity list. */
export interface TransitStatItem {
id: string;
reference: string | null;
customerName: string | null;
status: TransitAssignmentStatus;
schedulingStatus: string | null;
transitMinutes: number | null;
pickupMinutes: number | null;
clearanceMinutes: number | null;
documentCount: number;
}
/**
* Overview figures, all derived server-side from existing timestamps. Every
* duration is minutes, and null means "not measurable yet" rather than zero —
* an unfinished assignment has no clearance time.
*/
export interface TransitStats {
totals: {
assignments: number;
open: number;
finished: number;
readyForDocuments: number;
documents: number;
};
performance: {
medianClearanceMinutes: number | null;
medianPickupMinutes: number | null;
fastestClearanceMinutes: number | null;
slowestClearanceMinutes: number | null;
onTimeRate: number | null;
measured: number;
};
sla: { under2h: number; under6h: number; over6h: number };
coverage: { dispatched: number; withDocuments: number };
items: TransitStatItem[];
}
export const transitAssignmentsService = {
/** Dashboard figures for the signed-in agent. */
stats: async (): Promise<TransitStats> => {
const { data } = await client.get(`${BASE}/stats`);
return data.data ?? data;
},
/**
* Bookings assigned to me. Filtering and paging are server-side: an agent's
* roster grows without bound, so the page must not depend on holding every
* row in the browser.
*/
list: async (
params: TransitAssignmentListParams = {},
): Promise<TransitAssignmentListResult> => {
const { data } = await client.get(BASE, { params });
return data.data ?? data;
},
/** One assignment, with its documents. */
getById: async (id: string): Promise<TransitAssignment> => {
const { data } = await client.get(`${BASE}/${id}`);
return data.data ?? data;
},
/**
* Upload staged documents, each with its own display name.
*
* `titles` is positional: one entry appended per file, in the same order, so
* the API can pair index N with file N. Sending them as a keyed object is not
* possible here — two files may legitimately share a filename.
*/
uploadFiles: async (
id: string,
files: { file: File; title?: string }[],
): Promise<TransitAssignmentFile[]> => {
const formData = new FormData();
// One field name for every file — the API keys each record by
// `file.fieldname`, and these are free-form documents with no fixed slots.
for (const entry of files) {
formData.append("document", entry.file);
formData.append("titles", entry.title?.trim() || "");
}
const { data } = await client.post(`${BASE}/${id}/files`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
removeFile: async (id: string, fileId: string): Promise<void> => {
await client.delete(`${BASE}/${id}/files/${fileId}`);
},
/**
* Save progress, or finish. Finishing locks the assignment's documents, so
* the caller should confirm before passing `finish: true`.
*/
submit: async (
id: string,
input: { finish: boolean; note?: string },
): Promise<TransitAssignment> => {
const { data } = await client.post(`${BASE}/${id}/submit`, input);
return data.data ?? data;
},
};