mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
Merge pull request #923 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
@@ -1465,14 +1460,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
// Buttons show only to the step's actual approver (matching
|
||||
// position type): a chief step never offers Approve/Reject to a
|
||||
// marketing officer. Everyone still sees the "next" highlight.
|
||||
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
canAct,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
canAct: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
@@ -372,8 +381,11 @@ function StepRow({
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
{/* One element type per row: action buttons on the active step (they
|
||||
already imply "pending & actionable"), a status badge otherwise.
|
||||
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{isNext && canAct && step.status === "PENDING" ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -395,16 +407,17 @@ function StepRow({
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
// Rebook carries the expired booking's cargo description forward.
|
||||
if (copyFromBooking.cargoFreeText) {
|
||||
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||
}
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||
if (!isContainer || !contract) return [];
|
||||
return containerLines.map((line) => {
|
||||
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||
// one) but the booking as a whole needs at least one container — anchor
|
||||
// that error on the first line's quantity so it renders in the field.
|
||||
const totalQty = containerLines.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
return containerLines.map((line, idx) => {
|
||||
const errs: LineErrors = {};
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (line.quantity.trim() === "") {
|
||||
errs.quantity = "Quantity is required.";
|
||||
} else if (Number.isNaN(qty) || qty < 1) {
|
||||
errs.quantity = "At least 1.";
|
||||
} else if (line.units.length < qty) {
|
||||
} else if (Number.isNaN(qty) || qty < 0) {
|
||||
errs.quantity = "Enter 0 or more.";
|
||||
} else if (idx === 0 && totalQty < 1) {
|
||||
errs.quantity = "Book at least one container (either size).";
|
||||
} else if (qty >= 1 && line.units.length < qty) {
|
||||
errs.units = `Enter details for all ${qty} container(s).`;
|
||||
}
|
||||
if (contract.isHazardous) {
|
||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
const cargoDescriptionError =
|
||||
isContainer && !cargoDescription.trim()
|
||||
? "Describe the cargo carried in the containers."
|
||||
: undefined;
|
||||
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
)
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||
payload.containers = containerLines
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||
error={showErrors ? cargoDescriptionError : undefined}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
error={
|
||||
showErrors
|
||||
|
||||
@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
||||
weightTons?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
maxGrossTons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
||||
maxGrossTons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
let grossTons = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
grossTons += Number(b.weightTons) || 0;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
return {
|
||||
from: stop,
|
||||
to: stops[edge + 1],
|
||||
cargo,
|
||||
intercity,
|
||||
grossTons: Math.round(grossTons * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{seg.grossTons > 0 ? (
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
fw={600}
|
||||
c={
|
||||
maxGrossTons != null && seg.grossTons > maxGrossTons
|
||||
? "red.7"
|
||||
: "dimmed"
|
||||
}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{seg.grossTons}
|
||||
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { canApproveContractStep } from "./permissions";
|
||||
|
||||
const withPositionType = (typeKey: string): AuthUser => ({
|
||||
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
|
||||
});
|
||||
|
||||
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
|
||||
|
||||
const withPermission = (permKey: string): AuthUser => ({
|
||||
permissionKeys: [permKey],
|
||||
});
|
||||
|
||||
describe("canApproveContractStep", () => {
|
||||
it("shows to the matching position type only", () => {
|
||||
const chief = withPositionType("-marketing-chief");
|
||||
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
|
||||
// a marketing officer must NOT see the chief step's buttons
|
||||
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets super/org admins action any step", () => {
|
||||
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves legacy chain roles via their position-type aliases", () => {
|
||||
const director = withPositionType("operation-director");
|
||||
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
|
||||
expect(canApproveContractStep(director, "CEO")).toBe(false);
|
||||
});
|
||||
|
||||
it("honours the role's own legacy approve permission", () => {
|
||||
const staff = withPermission(
|
||||
"edr_freight_app:contracts:approve_director",
|
||||
);
|
||||
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show to holders of an unrelated approve permission", () => {
|
||||
// the dropped blanket fallback: a line-staff approver is not a chief
|
||||
const lineStaff = withPermission(
|
||||
"edr_freight_app:contracts:approve_line_staff",
|
||||
);
|
||||
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without a user or role", () => {
|
||||
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
|
||||
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -240,12 +240,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: "edr_freight_app:config:contract_validity:view",
|
||||
manage: "edr_freight_app:config:contract_validity:manage",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: "edr_freight_app:settings:file_upload:view",
|
||||
@@ -420,6 +414,53 @@ export function hasPermission(
|
||||
return getPermissionKeys(user).includes(key);
|
||||
}
|
||||
|
||||
// Legacy chain roles predate position types; map each to the position types
|
||||
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
|
||||
// button visibility matches what the approve/reject endpoint will accept.
|
||||
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
|
||||
DIRECTOR: ["director", "operation-director"],
|
||||
CEO: ["chief", "deputy"],
|
||||
};
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
|
||||
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
|
||||
* to show Approve/Reject only to the step's actual approver — a chief step
|
||||
* shows only to a chief, a marketing-officer step only to that officer.
|
||||
*
|
||||
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
|
||||
* also lets through anyone holding any contract-approve permission (a fallback
|
||||
* for delegates whose token omits the position type). That blanket is what made
|
||||
* every approver see the button, so it is dropped here: the visibility rule is
|
||||
* admin OR the matching position type (direct / legacy alias) OR the role's own
|
||||
* legacy approve permission. The server still guards the mutation.
|
||||
*/
|
||||
export function canApproveContractStep(
|
||||
user: AuthUser | null | undefined,
|
||||
requiredRole: string | null | undefined,
|
||||
): boolean {
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
|
||||
}
|
||||
|
||||
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface BookingDetail {
|
||||
contractType: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
|
||||
}>;
|
||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||
stops?: Array<{ yardId: string; label: string }>;
|
||||
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
||||
maxGrossWeightTons?: number | null;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user