mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #452 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
// Contract reference for the list column + search (no entity relation on
|
||||
// Booking → contract, so join by id and select just the reference).
|
||||
.leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
|
||||
// Booking → contract, so join the entity by id and select just the
|
||||
// reference — a schema-qualified table string is parsed as alias.relation
|
||||
// by TypeORM and crashes).
|
||||
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||
.addSelect('contract.reference', 'contract_reference')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
|
||||
@@ -138,6 +138,11 @@ export class ContractBookingService {
|
||||
// no override. Checked before any row is written.
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.assertWithinMaxCapacity(contract, dto);
|
||||
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
|
||||
// ≤ the cap, and drawdown bookings never pass through submit — so this is
|
||||
// their only chance to hard-block an unbalanceable set. Entry order is
|
||||
// irrelevant (the check sorts by weight before pairing).
|
||||
await this.assert20ftPairableAtCreate(dto);
|
||||
}
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
@@ -761,6 +766,35 @@ export class ContractBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-block booking creation when the 20ft container weights cannot be
|
||||
* balanced onto wagons (pair diff over the global cap). Same rule the
|
||||
* shipment-form preview reports as `pairingErrors`, enforced server-side.
|
||||
*/
|
||||
private async assert20ftPairableAtCreate(
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const twentyFtUnits = (dto.containers ?? [])
|
||||
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||
.flatMap((line, lineIdx) =>
|
||||
(line.units ?? []).map((u, idx) => ({
|
||||
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
|
||||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
);
|
||||
if (twentyFtUnits.length < 2) return;
|
||||
|
||||
const maxDiff = await this.max20ftPairDiffTons();
|
||||
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
|
||||
if (violations.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
|
||||
.map((v) => v.message)
|
||||
.join(' ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async max20ftPairDiffTons(): Promise<number> {
|
||||
const row = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -82,12 +84,13 @@ interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | string;
|
||||
/** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
}
|
||||
|
||||
interface ContainerLineDraft {
|
||||
containerSize: string;
|
||||
hazardousQuantity: number | string;
|
||||
reeferQuantity: number | string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
@@ -100,7 +103,13 @@ interface BulkLineDraft {
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
return {
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
hazardous: false,
|
||||
reefer: false,
|
||||
};
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
@@ -199,11 +208,13 @@ export default function GlCreateBookingForm() {
|
||||
setContainerLines(
|
||||
lines.containers.map((c) => ({
|
||||
containerSize: c.containerSize,
|
||||
hazardousQuantity: c.hazardousQuantity ?? "0",
|
||||
reeferQuantity: c.reeferQuantity ?? "",
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
|
||||
emptyUnit(),
|
||||
),
|
||||
// The request carries counts; pre-toggle the first N units so GL sees
|
||||
// the customer's declared hazardous/reefer split and can adjust it.
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
|
||||
...emptyUnit(),
|
||||
hazardous: i < Number(c.hazardousQuantity ?? 0),
|
||||
reefer: i < Number(c.reeferQuantity ?? 0),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
} else if (lines.bulk) {
|
||||
@@ -229,8 +240,6 @@ export default function GlCreateBookingForm() {
|
||||
setContainerLines(
|
||||
containerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: [emptyUnit()],
|
||||
})),
|
||||
);
|
||||
@@ -261,8 +270,8 @@ export default function GlCreateBookingForm() {
|
||||
containers: containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
||||
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
||||
})),
|
||||
bulkQuantity: bulkLines.reduce(
|
||||
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
|
||||
@@ -384,12 +393,10 @@ export default function GlCreateBookingForm() {
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
...(l.reeferQuantity !== ""
|
||||
? { reeferQuantity: Number(l.reeferQuantity) }
|
||||
: {}),
|
||||
// Counts are derived from the per-unit toggles — they can never
|
||||
// exceed the line quantity.
|
||||
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
||||
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
@@ -652,7 +659,7 @@ export default function GlCreateBookingForm() {
|
||||
<Text fz={14} fw={700} mb={10}>
|
||||
{line.containerSize} containers
|
||||
</Text>
|
||||
<Group gap={12} grow mb={12} align="flex-start">
|
||||
<Group gap={12} mb={12} align="flex-start">
|
||||
<NumberInput
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
@@ -660,37 +667,24 @@ export default function GlCreateBookingForm() {
|
||||
onChange={(v) => syncUnits(lineIdx, Number(v) || 0)}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
w={160}
|
||||
/>
|
||||
{contract.isHazardous ? (
|
||||
<NumberInput
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { hazardousQuantity: v })
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
<Badge variant="light" color="red" radius="sm" mt={30}>
|
||||
{line.units.filter((u) => u.hazardous).length} hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<NumberInput
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { reeferQuantity: v })
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
<Badge variant="light" color="blue" radius="sm" mt={30}>
|
||||
{line.units.filter((u) => u.reefer).length} refrigerated
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
<Stack gap={10} mt={8}>
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Group key={unitIdx} gap={10} grow align="flex-start">
|
||||
<Group key={unitIdx} gap={10} align="flex-start" wrap="nowrap">
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container number *" : undefined}
|
||||
placeholder="e.g. MSCU1234567"
|
||||
@@ -702,6 +696,7 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal number" : undefined}
|
||||
@@ -714,6 +709,7 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
|
||||
@@ -726,7 +722,52 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{/* Per-unit flags: toggle exactly the containers that are
|
||||
hazardous / refrigerated; line counts derive from these. */}
|
||||
{contract.isHazardous ? (
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
{unitIdx === 0 ? (
|
||||
<Text fz={12} fw={600} c="#4A5A68">
|
||||
Hazardous
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
color="red"
|
||||
size="sm"
|
||||
mt={unitIdx === 0 ? 0 : 8}
|
||||
aria-label={`Container ${unitIdx + 1} hazardous`}
|
||||
checked={unit.hazardous}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
hazardous: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
{unitIdx === 0 ? (
|
||||
<Text fz={12} fw={600} c="#4A5A68">
|
||||
Reefer
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
color="blue"
|
||||
size="sm"
|
||||
mt={unitIdx === 0 ? 0 : 8}
|
||||
aria-label={`Container ${unitIdx + 1} refrigerated`}
|
||||
checked={unit.reefer}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
reefer: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -26,6 +26,70 @@ type BookingForm = UseFormReturn<
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
/**
|
||||
* One numbered toggle per container unit in the line — tap units to mark how
|
||||
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
|
||||
* fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
|
||||
* keeps 1..N-1 — the count is always derived, never free-typed, so it can't
|
||||
* exceed the line quantity.
|
||||
*/
|
||||
function UnitCountToggles({
|
||||
total,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
activeBg,
|
||||
activeBorder,
|
||||
activeColor,
|
||||
}: {
|
||||
total: number;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeColor: string;
|
||||
}) {
|
||||
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
|
||||
{label} · {count}/{total} selected
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: total }, (_, i) => {
|
||||
const selected = i < count;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`Container ${i + 1}`}
|
||||
onClick={() => onChange(String(selected ? i : i + 1))}
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
minWidth: 40,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
|
||||
background: selected ? activeBg : "#fff",
|
||||
color: selected ? activeColor : "#6B7C8E",
|
||||
transition:
|
||||
"background 120ms ease, border-color 120ms ease, color 120ms ease",
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step5CargoDetails({
|
||||
form,
|
||||
referenceData,
|
||||
@@ -144,16 +208,6 @@ export function Step5CargoDetails({
|
||||
const lineQtyOf = (index: number) =>
|
||||
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
|
||||
const lineMax = (index: number) => lineQtyOf(index);
|
||||
// When a flag is switched on, default its count to the whole line.
|
||||
const defaultLineQty = (index: number) => lineQtyOf(index).toString();
|
||||
// Clamp a typed value into 1..lineQty (empty stays empty so the field can be
|
||||
// cleared; the schema flags an empty value as required while the switch is on).
|
||||
const clampToLine = (raw: string, index: number) => {
|
||||
if (raw === "") return "";
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return raw;
|
||||
return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
|
||||
};
|
||||
// After the line quantity changes, pull any active count back within bounds.
|
||||
const clampDependentQty = (index: number, newLineQty: number) => {
|
||||
const max = Math.max(1, newLineQty);
|
||||
@@ -636,7 +690,7 @@ export function Step5CargoDetails({
|
||||
hazField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.hazardousQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
v ? "1" : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
@@ -645,22 +699,22 @@ export function Step5CargoDetails({
|
||||
name={`containers.${index}.hazardousQty`}
|
||||
control={form.control}
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many hazardous?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={hq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
hq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={hq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
<div>
|
||||
<UnitCountToggles
|
||||
total={lineMax(index)}
|
||||
value={hq.value ?? "0"}
|
||||
onChange={hq.onChange}
|
||||
label="Tap the hazardous containers"
|
||||
activeBg="#FBEAE7"
|
||||
activeBorder="#E4A69B"
|
||||
activeColor="#C0392B"
|
||||
/>
|
||||
{fieldState.error?.message ? (
|
||||
<Text fz={11} c="red.7" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
@@ -681,7 +735,7 @@ export function Step5CargoDetails({
|
||||
reeField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.reeferQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
v ? "1" : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
@@ -690,22 +744,22 @@ export function Step5CargoDetails({
|
||||
name={`containers.${index}.reeferQty`}
|
||||
control={form.control}
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many refrigerated?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={rq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
rq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={rq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
<div>
|
||||
<UnitCountToggles
|
||||
total={lineMax(index)}
|
||||
value={rq.value ?? "0"}
|
||||
onChange={rq.onChange}
|
||||
label="Tap the refrigerated containers"
|
||||
activeBg="#E9F0F8"
|
||||
activeBorder="#A9C2E0"
|
||||
activeColor="#2E5B96"
|
||||
/>
|
||||
{fieldState.error?.message ? (
|
||||
<Text fz={11} c="red.7" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
|
||||
Reference in New Issue
Block a user