mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
fix issue, add transit flow, fix cancellation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal file
107
apps/edr-freight-web/backoffice/src/components/PhoneField.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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'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't
|
||||
already used.
|
||||
We'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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -609,6 +609,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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -975,13 +975,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,
|
||||
),
|
||||
|
||||
@@ -545,7 +545,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),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user