mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
change contrat creation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 & mark hazardous
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user