From a5c46505e372610e5e2dc6ff196e8231cb047465 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 16:37:09 +0000 Subject: [PATCH] Add migration to widen window_duration_hours precision and update related components for duration handling --- ...00000-WidenWindowDurationHoursPrecision.ts | 28 ++++ ...pdate-train-scheduling-global-rules.dto.ts | 4 +- .../train-scheduling-global-rules.entity.ts | 6 +- .../detail/ContractDetailTabCards.tsx | 10 +- .../trainScheduling/DurationField.tsx | 123 ++++++++++++++++++ .../backoffice/src/hooks/use-toast.ts | 7 +- .../contracts/ContractRequestDetailPage.tsx | 52 +++++++- .../TrainSchedulingGlobalRulesPage.tsx | 74 ++++++++--- 8 files changed, 274 insertions(+), 30 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx diff --git a/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts new file mode 100644 index 000000000..194c0d056 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1910000000000-WidenWindowDurationHoursPrecision.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2) + * to numeric(6,4). The UI now lets staff enter the booking-window duration in + * minutes / hours / days and converts to the column's native hours unit; a + * 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min). + * Four decimals store sub-minute durations exactly (0.0667h → 4.00 min). + */ +export class WidenWindowDurationHoursPrecision1910000000000 + implements MigrationInterface +{ + name = "WidenWindowDurationHoursPrecision1910000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(6, 4); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_duration_hours TYPE numeric(4, 2); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 1171b0c90..2e82feb6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -60,11 +60,13 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Max(23) windowOpenHour?: number; + // Stored in hours. The UI enters this in minutes/hours/days and converts to + // hours before sending, so the floor is 1 minute (0.0166h) — not 15 min. @ApiPropertyOptional({ example: 3 }) @IsOptional() @Type(() => Number) @IsNumber() - @Min(0.25) + @Min(0.0166) @Max(12) windowDurationHours?: number; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 7b8d9b26a..1a67bb791 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'window_open_hour', type: 'int', default: 8 }) windowOpenHour!: number; + // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) + // are exact. See WidenWindowDurationHoursPrecision migration. @Column({ name: 'window_duration_hours', type: 'numeric', - precision: 4, - scale: 2, + precision: 6, + scale: 4, default: 3, }) windowDurationHours!: number; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index 44ece975d..de8ff1953 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null { export interface ContractDocumentsCardProps { files: ContractFile[]; + /** Card heading. Defaults to "Documents". */ + title?: string; + /** Message shown when there are no files. */ + emptyText?: string; /** Open the file inline in a viewer modal. */ onView?: (file: ContractFile) => void; /** Download the file to disk. */ @@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps { /** Rich list of the contract's attached documents: type, size, view + download. */ export function ContractDocumentsCard({ files, + title = "Documents", + emptyText = "No documents attached to this contract.", onView, onDownload, }: ContractDocumentsCardProps) { return ( @@ -233,7 +239,7 @@ export function ContractDocumentsCard({ > {files.length === 0 ? ( - No documents attached to this contract. + {emptyText} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx new file mode 100644 index 000000000..06c9013d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group, NumberInput, Select, Stack } from "@mantine/core"; + +export type DurationUnit = "minutes" | "hours" | "days"; + +const UNIT_MINUTES: Record = { + minutes: 1, + hours: 60, + days: 1440, +}; + +const UNIT_OPTIONS: { value: DurationUnit; label: string }[] = [ + { value: "minutes", label: "min" }, + { value: "hours", label: "hr" }, + { value: "days", label: "day" }, +]; + +/** Convert a value expressed in `from` units to `to` units. */ +function convert(value: number, from: DurationUnit, to: DurationUnit): number { + return (value * UNIT_MINUTES[from]) / UNIT_MINUTES[to]; +} + +/** Pick the largest unit that keeps a value a clean-ish whole number, so a + * stored 0.0667h loads back as "4 min" rather than "0.0667 hr". */ +function bestDisplayUnit(minutes: number): DurationUnit { + if (minutes <= 0) return "minutes"; + if (minutes % 1440 === 0) return "days"; + if (minutes % 60 === 0) return "hours"; + return "minutes"; +} + +export interface DurationFieldProps { + label: string; + description?: string; + /** Current value, expressed in `nativeUnit` (what the API/DB stores). */ + value: number | string; + /** The unit the parent stores/sends. The field converts to this on change. */ + nativeUnit: DurationUnit; + /** Called with the value converted back to `nativeUnit` (or "" when blank). */ + onChange: (nativeValue: number | "") => void; + /** Smallest allowed value, in `nativeUnit`. */ + min?: number; + disabled?: boolean; +} + +export default function DurationField({ + label, + description, + value, + nativeUnit, + onChange, + min, + disabled, +}: DurationFieldProps) { + const nativeMinutes = useMemo(() => { + const num = value === "" || value == null ? NaN : Number(value); + return Number.isFinite(num) ? num * UNIT_MINUTES[nativeUnit] : NaN; + }, [value, nativeUnit]); + + // Display unit is user-driven; seed it from the incoming value once. + const [unit, setUnit] = useState(() => + Number.isFinite(nativeMinutes) ? bestDisplayUnit(nativeMinutes) : nativeUnit, + ); + + // The value usually arrives async (after the initial "" render), so the + // useState seed above runs before it exists. Re-pick the friendliest display + // unit the first time a real value shows up — but never again, so the user's + // manual unit choice sticks. + const seeded = useRef(false); + useEffect(() => { + if (!seeded.current && Number.isFinite(nativeMinutes)) { + seeded.current = true; + setUnit(bestDisplayUnit(nativeMinutes)); + } + }, [nativeMinutes]); + + const displayValue: number | "" = Number.isFinite(nativeMinutes) + ? Number(convert(nativeMinutes, "minutes", unit).toFixed(4)) + : ""; + + const emitNative = (display: number | "", displayUnit: DurationUnit) => { + if (display === "" || !Number.isFinite(Number(display))) { + onChange(""); + return; + } + const native = convert(Number(display), displayUnit, nativeUnit); + onChange(Number(native.toFixed(6))); + }; + + return ( + + + + emitNative(v === "" ? "" : Number(v), unit) + } + clampBehavior="none" + allowDecimal + min={min != null ? convert(min, nativeUnit, unit) : 0} + disabled={disabled} + style={{ flex: 1 }} + /> +