change contrat creation

This commit is contained in:
Marshal
2026-07-24 13:26:19 +00:00
parent 668b5e1c9d
commit 286390a3cf
23 changed files with 2439 additions and 56 deletions

View File

@@ -5635,9 +5635,12 @@ export class TrainSchedulingService {
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
// No `relations` on this query: Postgres refuses FOR UPDATE through the
// nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
// applied to the nullable side of an outer join"). Lock the row alone,
// then attach its type with a separate unlocked lookup.
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
@@ -5654,6 +5657,10 @@ export class TrainSchedulingService {
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
);
}
wagon.wagonType =
(await manager
.getRepository(WagonType)
.findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
added.push(wagon);
}

View File

@@ -582,6 +582,19 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
// ── Hazardous cargo documents ───────────────────────────────────────────────
// Asked for in the contract wizard the moment the customer flags the cargo as
// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
// the backoffice file-settings editor.
const HAZARDOUS_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
{
code: "hazardous_documents",
label: "Hazardous cargo documents",
entity: CONTRACT_INTAKE_ENTITY,
fields: [],
},
];
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -633,6 +646,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
...HAZARDOUS_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents required when a one-time contract's cargo is flagged hazardous.",
})),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:

View File

@@ -257,6 +257,20 @@ function usePlacesSearch(): PlacesSearch | null {
}, [placesLib]);
}
/**
* Coerce a coordinate to a finite number or null. Numeric-typed API fields
* (e.g. contract/booking `..Lat`/`..Lng`) come back from Postgres `numeric`
* columns as strings — the TS type says `number` but the value crossing the
* network is not. Google Maps' `panTo`/`Marker` throw on anything that isn't
* a real finite number, so every coordinate is normalised at this one shared
* entry point rather than trusting each caller to have converted it.
*/
function toFiniteNumber(v: number | string | null | undefined): number | null {
if (v == null) return null;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
@@ -290,12 +304,17 @@ export interface LocationPickerProps {
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker(props: LocationPickerProps) {
const value: LocationValue = {
...props.value,
lat: toFiniteNumber(props.value.lat),
lng: toFiniteNumber(props.value.lng),
};
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
{props.variant === "modal" ? (
<LocationPickerModal {...props} />
<LocationPickerModal {...props} value={value} />
) : (
<LocationPickerInline {...props} />
<LocationPickerInline {...props} value={value} />
)}
</APIProvider>
);

View File

@@ -42,21 +42,27 @@ export function Step2ServiceType({
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
// Only clear a mile when the current service doesn't include it — this ran
// unconditionally before, so it wiped an already-entered (or prefilled, or
// draft-restored) pickup/delivery address on every service change, even one
// that still includes that mile.
useEffect(() => {
if (includesFirstMile) return;
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesFirstMile]);
}, [includesFirstMile, form]);
useEffect(() => {
if (includesLastMile) return;
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
}, [includesLastMile]);
}, [includesLastMile, form]);
useEffect(() => {
const prev = prevServiceType.current;
@@ -82,6 +88,10 @@ export function Step2ServiceType({
);
useEffect(() => {
// Skip right after a hydration `form.reset()` (draft restore / renewal
// prefill) — a restored selection is trusted as-is until the customer
// actually changes something live.
if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!bookableServices.some((s) => s.id === currentId)) {

View File

@@ -100,12 +100,17 @@ export function Step4Route({
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
// Skipped right after a hydration `form.reset()` (draft restore / renewal
// prefill) — a restored yard is trusted as-is until the customer changes
// something live; `isDirty` is false only in that pristine post-reset render.
useEffect(() => {
if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}

View File

@@ -335,6 +335,11 @@ export function Step2ServiceType({
);
useEffect(() => {
// Skip right after a hydration `form.reset()` (edit mode) — a saved
// contract's service is trusted as-is until the customer actually
// changes something live; `isDirty` is false only in that pristine
// post-hydration render, so this never clears a value nobody touched.
if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!standaloneServices.some((s) => s.id === currentId)) {

View File

@@ -1,23 +1,44 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
// Snowflake — restore with the Refrigerated Cargo switch below.
import { Container, Flame, RotateCcw } from "lucide-react";
import {
Box,
Button,
Group,
Loader,
Modal,
Select,
Skeleton,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { SmartFileInput } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import {
CONTAINER_SIZES,
ContractFormInputValues,
type ContractDocuments,
type ContractFormValues,
} from "./schema";
import { operationToTradeDirection } from "./helpers";
import { fieldStyles, SelectField, StepLabel } from "./shared";
/**
* file_upload_settings code holding the hazardous-cargo document requirements.
* Fields are configured by an admin in the backoffice file-settings editor —
* whatever is configured there is what the modal asks for.
*/
const HAZARDOUS_DOC_SETTING_CODE = "hazardous_documents";
function hasUploaded(value: File | File[] | null | undefined): boolean {
if (!value) return false;
return Array.isArray(value) ? value.length > 0 : true;
}
const CARGO_TYPE_OPTIONS = [
{ value: "container", label: "Containerized (20ft / 40ft)" },
{ value: "bulk", label: "General / Bulk cargo" },
@@ -51,6 +72,101 @@ export function Step3CargoScope({
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
// Hazardous cargo is a ONE-TIME-contract-only option; refrigerated cargo and
// empty-container return are offered on IMPORT operations only.
const operationType = form.watch("operationType");
const isOneTime = (form.watch("contractKind") ?? "one_time") === "one_time";
const isImport = operationType
? operationToTradeDirection(operationType) === "IMPORT"
: false;
const hazardSettingQuery = useQuery(
api.fileUploadSettings.getByCode.queryOptions({
input: { code: HAZARDOUS_DOC_SETTING_CODE },
enabled: isOneTime,
}),
);
const hazardFields = hazardSettingQuery.data?.fields ?? [];
const hazardKeys = useMemo(
() => hazardFields.map((f) => f.fileKey),
[hazardFields],
);
const [hazardModalOpen, setHazardModalOpen] = useState(false);
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
/** Drop every hazardous document from the contract's document map. */
const clearHazardDocs = () => {
const docs = { ...((form.getValues("documents") ?? {}) as ContractDocuments) };
let changed = false;
for (const key of hazardKeys) {
if (key in docs) {
delete docs[key];
changed = true;
}
}
if (changed) form.setValue("documents", docs, { shouldDirty: true });
};
const openHazardModal = () => {
const docs = (form.getValues("documents") ?? {}) as ContractDocuments;
setHazardDraft(
Object.fromEntries(
hazardKeys.filter((k) => k in docs).map((k) => [k, docs[k]]),
),
);
setHazardErrors({});
setHazardModalOpen(true);
};
const confirmHazardDocs = () => {
const missing = hazardFields.filter(
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
);
if (missing.length > 0) {
setHazardErrors(
Object.fromEntries(
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
),
);
return;
}
form.setValue(
"documents",
{
...((form.getValues("documents") ?? {}) as ContractDocuments),
...hazardDraft,
},
{ shouldDirty: true },
);
form.setValue("isHazardous", true, { shouldDirty: true });
setHazardModalOpen(false);
};
// A hidden flag must never leak into the payload: a general contract can't be
// hazardous, and a non-import contract carries neither reefer nor empty return.
useEffect(() => {
if (isOneTime) return;
if (form.getValues("isHazardous")) {
form.setValue("isHazardous", false, { shouldDirty: true });
}
// Runs again once the hazard field list loads — a no-op when nothing matches.
clearHazardDocs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOneTime, hazardKeys, form]);
useEffect(() => {
// Reefer has no switch right now, so it must never leave the form true.
if (form.getValues("isRefrigerated")) {
form.setValue("isRefrigerated", false, { shouldDirty: true });
}
if (isImport) return;
if (form.getValues("equipmentReturn") === "with_return") {
form.setValue("equipmentReturn", "without_return", { shouldDirty: true });
}
}, [isImport, form]);
// Reset the commodity child only when the parent group really changes.
const prevParentIdRef = useRef<string | undefined>(parentId);
useEffect(() => {
@@ -233,40 +349,55 @@ export function Step3CargoScope({
<Box>
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a reefer surcharge."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
{/* Empty-container return is a container-only service. Like hazardous,
enabling it here adds the return surcharge as a unit rate; at
booking time the customer/GL sets how many containers return. */}
{cargoType === "container" && (
{/* Hazardous cargo is only carried under a one-time contract, and
enabling it requires the configured hazard documents up front. */}
{isOneTime && (
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
checked={field.value ?? false}
onChange={(v) => {
if (v) {
openHazardModal();
return;
}
field.onChange(false);
clearHazardDocs();
}}
/>
)}
/>
)}
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
The effect above keeps isRefrigerated false while it's off.
{isImport && (
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a reefer surcharge."
checked={field.value ?? false}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)} */}
{/* Empty-container return is a container-only IMPORT service. Like
hazardous, enabling it here adds the return surcharge as a unit
rate; at booking time the customer/GL sets how many return. */}
{isImport && cargoType === "container" && (
<Controller
name="equipmentReturn"
control={form.control}
@@ -287,6 +418,54 @@ export function Step3CargoScope({
)}
</Stack>
</Box>
<Modal
opened={hazardModalOpen}
onClose={() => setHazardModalOpen(false)}
title="Hazardous cargo documents"
size="lg"
centered
radius={14}
>
<Stack gap={16}>
<Text fz={13} c="#6B7C8E">
Hazardous cargo can only move once the documents below are attached
to the contract.
</Text>
{hazardSettingQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" color="edr-green" />
</Group>
) : hazardFields.length > 0 && hazardSettingQuery.data ? (
<SmartFileInput
file={hazardSettingQuery.data}
value={hazardDraft}
onChange={(v) => setHazardDraft(v)}
errors={hazardErrors}
/>
) : (
<Text fz={13} c="#6B7C8E">
No hazardous document requirements are configured yet. You can
still flag the cargo as hazardous EDR will request the paperwork
during review.
</Text>
)}
<Group justify="flex-end" gap={10}>
<Button
variant="default"
radius={10}
onClick={() => setHazardModalOpen(false)}
>
Cancel
</Button>
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
Save &amp; mark hazardous
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}

View File

@@ -72,12 +72,17 @@ export function Step4Route({
const direction = getRouteDirection(origin, dest);
// Clear yards that no longer match the operation's required country.
// Skipped right after a hydration `form.reset()` (edit mode) — a saved
// contract's yards are trusted as-is until the customer changes something
// live; `isDirty` is false only in that pristine post-hydration render.
useEffect(() => {
if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}