mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add migration to widen window_duration_hours precision and update related components for duration handling
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Documents"
|
||||
title={title}
|
||||
accent="indigo"
|
||||
extra={
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No documents attached to this contract.
|
||||
{emptyText}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -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<DurationUnit, number> = {
|
||||
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<DurationUnit>(() =>
|
||||
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 (
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<NumberInput
|
||||
label={label}
|
||||
description={description}
|
||||
value={displayValue}
|
||||
onChange={(v) =>
|
||||
emitNative(v === "" ? "" : Number(v), unit)
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={min != null ? convert(min, nativeUnit, unit) : 0}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
aria-label={`${label} unit`}
|
||||
data={UNIT_OPTIONS}
|
||||
value={unit}
|
||||
onChange={(next) => {
|
||||
if (!next) return;
|
||||
// Only the display unit changes; the stored native value stays put.
|
||||
// displayValue re-derives from it on the next render.
|
||||
setUnit(next as DurationUnit);
|
||||
}}
|
||||
allowDeselect={false}
|
||||
disabled={disabled}
|
||||
w={90}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ToastOptions {
|
||||
@@ -8,7 +9,9 @@ interface ToastOptions {
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (options: ToastOptions) => {
|
||||
// Stable identity so callers can safely list `toast` in effect/callback deps
|
||||
// without re-firing on every render.
|
||||
const showToast = useCallback((options: ToastOptions) => {
|
||||
const { title, description, variant = 'default', duration = 3000 } = options;
|
||||
|
||||
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
|
||||
@@ -18,7 +21,7 @@ export function useToast() {
|
||||
} else {
|
||||
toast.success(message, { duration });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { toast: showToast };
|
||||
}
|
||||
|
||||
@@ -61,9 +61,11 @@ import {
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
// Customer profile documents (national ID, TIN, import/business license) for
|
||||
// the company this contract belongs to. Shown as a separate section in the
|
||||
// Documents tab, alongside the contract's own attached files.
|
||||
const companyId = contract?.companyId ?? "";
|
||||
const profileDocumentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: companyId },
|
||||
enabled: Boolean(companyId),
|
||||
}),
|
||||
);
|
||||
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
|
||||
? profileDocumentsQuery.data
|
||||
: [];
|
||||
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
|
||||
const profileDocuments = profileDocumentsRaw.map(
|
||||
(doc: CustomerDocument) =>
|
||||
({
|
||||
id: doc.id,
|
||||
code: doc.code,
|
||||
name: doc.name,
|
||||
url: doc.url ?? "",
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
resourceId: companyId,
|
||||
resource: "company",
|
||||
}) satisfies NonNullable<Freight.IContract["files"]>[number],
|
||||
);
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
||||
// don't list them as standalone documents in the Documents tab.
|
||||
const contractDocuments = files.filter(
|
||||
(f) => !f.code.startsWith("signature_"),
|
||||
);
|
||||
const hasContractDocument = Boolean(
|
||||
contractPdf || contract.contractGeneratedAt,
|
||||
);
|
||||
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
contractDocuments.length + profileDocuments.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
{contractDocuments.length + profileDocuments.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
|
||||
) : currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
files={contractDocuments}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
emptyText={
|
||||
profileDocumentsQuery.isLoading
|
||||
? "Loading customer documents…"
|
||||
: "No profile documents on file for this customer."
|
||||
}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
||||
@@ -19,14 +20,30 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const rules = await trainSchedulingService.getGlobalRules();
|
||||
setForm(rules);
|
||||
// `numeric` columns come back from the API as strings (e.g. "250.00").
|
||||
// Coerce every field to a real number so Mantine's controlled
|
||||
// NumberInput edits cleanly (a string value fights the caret) and the
|
||||
// default can be cleared and replaced.
|
||||
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
|
||||
for (const [key, value] of Object.entries(rules)) {
|
||||
if (key === "id") continue;
|
||||
const num = value === "" || value == null ? "" : Number(value);
|
||||
numeric[key as keyof TrainSchedulingGlobalRules] =
|
||||
typeof num === "number" && Number.isNaN(num) ? "" : num;
|
||||
}
|
||||
setForm(numeric);
|
||||
} catch {
|
||||
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [toast]);
|
||||
// Run once on mount only. `toast` from useToast is a fresh function every
|
||||
// render — listing it here re-fired the effect on every render, refetching
|
||||
// the rules and overwriting whatever the user was typing (values snapped
|
||||
// back to the saved defaults).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Every field must hold a real number — an empty box (cleared but not
|
||||
@@ -88,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -98,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -107,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -120,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -133,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -145,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
title="Booking windows"
|
||||
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||
/>
|
||||
<NumberInput
|
||||
label="Import window lead (days)"
|
||||
description="The single booking day opens this many days before departure"
|
||||
<DurationField
|
||||
label="Import window lead"
|
||||
description="The single booking day opens this long before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
nativeUnit="days"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
<DurationField
|
||||
label="Export booking lead"
|
||||
description="Export bookings are accepted first-come-first-serve starting this long before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||
}
|
||||
@@ -172,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window duration (hours)"
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long the import booking window stays open"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Document review (minutes)"
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Payment window (minutes)"
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Reopen delay (minutes)"
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
<DurationField
|
||||
label="Reopen delay"
|
||||
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user