mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge pull request #453 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
|
||||
isDelegate?: boolean;
|
||||
parentPositionId?: string | null;
|
||||
permissions?: AuthPermission[];
|
||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||
position?: { id?: string; key?: string; name?: LocaleText };
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
|
||||
@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
|
||||
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Active wagon-type options for the cargo-type / container-type "Wagon type"
|
||||
* picker. The FK the selection sets drives train-scheduling wagon resolution.
|
||||
*/
|
||||
export const useWagonTypeOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
...api.wagonTypes.list.queryOptions(),
|
||||
enabled,
|
||||
select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
|
||||
rows
|
||||
.filter((wt) => wt.isActive !== false)
|
||||
.map((wt) => ({
|
||||
label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
|
||||
value: wt.id,
|
||||
})),
|
||||
});
|
||||
|
||||
const LIVE_RATE_PAGE_SIZE = 500;
|
||||
|
||||
export const useLiveRateOptions = (enabled = true) =>
|
||||
|
||||
@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||
/**
|
||||
* Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
|
||||
* Tolerates IAM payload shape variants: the key flat on the employee position,
|
||||
* nested under `position.key`, or the GL modeled as a role instead.
|
||||
*/
|
||||
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const emp of user.employee ?? []) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
if (pos.key) keys.add(pos.key);
|
||||
if (pos.position?.key) keys.add(pos.position.key);
|
||||
}
|
||||
}
|
||||
for (const role of user.roles ?? []) {
|
||||
if (role.key) keys.add(role.key);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
|
||||
@@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() {
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Stack gap="sm">
|
||||
{(contract.cargoScope ?? []).map((s) => {
|
||||
const isContainer = Boolean(s.containerSize);
|
||||
// Bulk lines carry their commodity detail (name + unit);
|
||||
// container lines carry the size (20ft / 40ft).
|
||||
const title = isContainer
|
||||
? `${s.containerSize} container`
|
||||
: (s.cargoType?.cargoTypeName ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoType?.code ??
|
||||
"Bulk cargo");
|
||||
// quantityCap unit: containers for a size line, else the
|
||||
// cargo type's unit of measure (tons / items / …), default tons.
|
||||
const capUnit = isContainer
|
||||
? "containers"
|
||||
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
|
||||
return (
|
||||
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
style={{ marginTop: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
<Group gap={6} mt={2}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isContainer ? "blue" : "grape"}
|
||||
radius="sm"
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
>
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
</Badge>
|
||||
{s.cargoType?.code ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Code: {s.cargoType.code}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.quantityCap != null
|
||||
? `Cap: ${s.quantityCap} ${capUnit}`
|
||||
: "Cap: uncapped"}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import {
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
useWagonTypeOptions,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
@@ -53,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
|
||||
wagonTypeId?: string | null;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
@@ -78,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Wagon type that carries this (bulk) commodity — drives train-scheduling
|
||||
// wagon resolution. Optional: leave "None" for grouping categories and
|
||||
// container/legacy cargo; set it on scheduled bulk commodities.
|
||||
// Options injected at render from useWagonTypeOptions.
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
optional: true,
|
||||
placeholder: "Select wagon type (bulk cargo)",
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
|
||||
},
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
@@ -104,6 +119,24 @@ const CargoTypesPage = () => {
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeId"
|
||||
? {
|
||||
...field,
|
||||
options: [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
...(wagonTypeOptions ?? []),
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||
@@ -349,7 +382,7 @@ const CargoTypesPage = () => {
|
||||
? "Create a top-level cargo category."
|
||||
: "Create a cargo type inside this category. It's attached here automatically."
|
||||
}
|
||||
fields={FORM_FIELDS}
|
||||
fields={formFields}
|
||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
|
||||
const usesLiveRateField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "rateId"),
|
||||
);
|
||||
const usesWagonTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "wagonTypeId"),
|
||||
);
|
||||
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
|
||||
const formFields = useMemo(() => {
|
||||
if (!config) return [];
|
||||
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
|
||||
options: liveRateOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "wagonTypeId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => {
|
||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading)
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
positionLoading={createPositionLoading}
|
||||
|
||||
@@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||
{
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
required: true,
|
||||
description: "Wagon type used to carry this container during train scheduling.",
|
||||
},
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
@@ -211,6 +213,17 @@ export default function ContractDetailPage() {
|
||||
});
|
||||
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
||||
|
||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||
// releases its share and the tracker fills back up. Refetched on window focus so
|
||||
// it reflects newly created / cancelled shipments.
|
||||
const { data: capacityLines = [] } = useQuery({
|
||||
queryKey: ["contract-capacity", id],
|
||||
queryFn: () => contractsService.getCapacity(id!),
|
||||
enabled: !!id && contract?.contractKind === "GENERAL",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const contractBookings = useMemo(
|
||||
() =>
|
||||
(bookingsPage?.items ?? []).filter(
|
||||
@@ -882,6 +895,88 @@ export default function ContractDetailPage() {
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
|
||||
Fills as shipments consume capacity; empties again when a shipment is
|
||||
cancelled/rejected/expired (backend releases it). */}
|
||||
{isGeneral && capacityLines.length > 0 && (
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||
>
|
||||
<SectionLabel mb="md">Contract capacity</SectionLabel>
|
||||
<Stack gap="lg">
|
||||
{capacityLines.map((line, i) => {
|
||||
const cap = line.cap ?? 0;
|
||||
const booked = line.booked ?? 0;
|
||||
const remaining = line.remaining ?? Math.max(0, cap - booked);
|
||||
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
|
||||
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
|
||||
const unit = capacityUnitLabel(contract, line);
|
||||
const label = isContainer
|
||||
? `${line.containerSize ?? "Containers"}`
|
||||
: (contract.cargoScope ?? []).find(
|
||||
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||
)?.cargoType?.cargoTypeName ??
|
||||
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
|
||||
"Bulk commodity";
|
||||
return (
|
||||
<Group
|
||||
key={line.containerSize ?? line.cargoTypeId ?? i}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="lg"
|
||||
>
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={8}
|
||||
roundCaps
|
||||
sections={[
|
||||
{
|
||||
value: remainingPct,
|
||||
color: remaining === 0 ? "red" : GREEN,
|
||||
},
|
||||
]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
|
||||
{remainingPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isContainer ? (
|
||||
<Package size={16} color={MUTED} />
|
||||
) : (
|
||||
<Weight size={16} color={MUTED} />
|
||||
)}
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
{booked} / {cap} {unit} booked
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={usedPct}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={remaining === 0 ? "red" : GREEN}
|
||||
/>
|
||||
<Text fz={12} c="dimmed" mt={6}>
|
||||
{remaining} {unit} remaining
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Signatures */}
|
||||
{(contract.signatures ?? []).length > 0 && (
|
||||
<Card
|
||||
@@ -1351,6 +1446,22 @@ function SectionLabel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
|
||||
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
||||
*/
|
||||
function capacityUnitLabel(
|
||||
contract: Freight.IContract,
|
||||
line: Freight.ContractCapacityLine,
|
||||
): string {
|
||||
if (contract.freightType === "CONTAINER") return "containers";
|
||||
const scope =
|
||||
(contract.cargoScope ?? []).find(
|
||||
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||
) ?? (contract.cargoScope ?? [])[0];
|
||||
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
|
||||
}
|
||||
|
||||
/**
|
||||
* One document row in the Documents tab: the file's kind (passport, business
|
||||
* license, contract, …) derived from its `code` as the primary label, the
|
||||
|
||||
Reference in New Issue
Block a user