mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +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)
|
@Max(23)
|
||||||
windowOpenHour?: number;
|
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 })
|
@ApiPropertyOptional({ example: 3 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0.25)
|
@Min(0.0166)
|
||||||
@Max(12)
|
@Max(12)
|
||||||
windowDurationHours?: number;
|
windowDurationHours?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -54,11 +54,13 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
|
|||||||
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
|
||||||
windowOpenHour!: number;
|
windowOpenHour!: number;
|
||||||
|
|
||||||
|
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
|
||||||
|
// are exact. See WidenWindowDurationHoursPrecision migration.
|
||||||
@Column({
|
@Column({
|
||||||
name: 'window_duration_hours',
|
name: 'window_duration_hours',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
precision: 4,
|
precision: 6,
|
||||||
scale: 2,
|
scale: 4,
|
||||||
default: 3,
|
default: 3,
|
||||||
})
|
})
|
||||||
windowDurationHours!: number;
|
windowDurationHours!: number;
|
||||||
|
|||||||
@@ -208,6 +208,10 @@ function codeLabel(code?: string | null): string | null {
|
|||||||
|
|
||||||
export interface ContractDocumentsCardProps {
|
export interface ContractDocumentsCardProps {
|
||||||
files: ContractFile[];
|
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. */
|
/** Open the file inline in a viewer modal. */
|
||||||
onView?: (file: ContractFile) => void;
|
onView?: (file: ContractFile) => void;
|
||||||
/** Download the file to disk. */
|
/** Download the file to disk. */
|
||||||
@@ -217,13 +221,15 @@ export interface ContractDocumentsCardProps {
|
|||||||
/** Rich list of the contract's attached documents: type, size, view + download. */
|
/** Rich list of the contract's attached documents: type, size, view + download. */
|
||||||
export function ContractDocumentsCard({
|
export function ContractDocumentsCard({
|
||||||
files,
|
files,
|
||||||
|
title = "Documents",
|
||||||
|
emptyText = "No documents attached to this contract.",
|
||||||
onView,
|
onView,
|
||||||
onDownload,
|
onDownload,
|
||||||
}: ContractDocumentsCardProps) {
|
}: ContractDocumentsCardProps) {
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title="Documents"
|
title={title}
|
||||||
accent="indigo"
|
accent="indigo"
|
||||||
extra={
|
extra={
|
||||||
<Badge color="gray" variant="light" radius="sm">
|
<Badge color="gray" variant="light" radius="sm">
|
||||||
@@ -233,7 +239,7 @@ export function ContractDocumentsCard({
|
|||||||
>
|
>
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No documents attached to this contract.
|
{emptyText}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<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';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface ToastOptions {
|
interface ToastOptions {
|
||||||
@@ -8,7 +9,9 @@ interface ToastOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useToast() {
|
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 { title, description, variant = 'default', duration = 3000 } = options;
|
||||||
|
|
||||||
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
|
const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
|
||||||
@@ -18,7 +21,7 @@ export function useToast() {
|
|||||||
} else {
|
} else {
|
||||||
toast.success(message, { duration });
|
toast.success(message, { duration });
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return { toast: showToast };
|
return { toast: showToast };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,9 +61,11 @@ import {
|
|||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
|
import type { CustomerDocument } from "@/types/customer";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||||
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
|
|||||||
enabled: Boolean(id) && showClearanceTabQuery,
|
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 () => {
|
const downloadContractPdf = async () => {
|
||||||
if (!contract?.id) return;
|
if (!contract?.id) return;
|
||||||
try {
|
try {
|
||||||
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
|
|||||||
const selfClear = !contract.customsClearingEnabled;
|
const selfClear = !contract.customsClearingEnabled;
|
||||||
const files = contract.files ?? [];
|
const files = contract.files ?? [];
|
||||||
const contractPdf = files.find((f) => f.code === "contract");
|
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(
|
const hasContractDocument = Boolean(
|
||||||
contractPdf || contract.contractGeneratedAt,
|
contractPdf || contract.contractGeneratedAt,
|
||||||
);
|
);
|
||||||
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
|
|||||||
value="documents"
|
value="documents"
|
||||||
leftSection={<Files size={16} />}
|
leftSection={<Files size={16} />}
|
||||||
rightSection={
|
rightSection={
|
||||||
files.length > 0 ? (
|
contractDocuments.length + profileDocuments.length > 0 ? (
|
||||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
{files.length}
|
{contractDocuments.length + profileDocuments.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
|
|||||||
) : currentTab === "documents" ? (
|
) : currentTab === "documents" ? (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<ContractDocumentsCard
|
<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}
|
onView={handleViewFile}
|
||||||
onDownload={handleDownloadFile}
|
onDownload={handleDownloadFile}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
|
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import DurationField from "@/components/trainScheduling/DurationField";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
||||||
@@ -19,14 +20,30 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const rules = await trainSchedulingService.getGlobalRules();
|
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 {
|
} catch {
|
||||||
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 () => {
|
const handleSave = async () => {
|
||||||
// Every field must hold a real number — an empty box (cleared but not
|
// Every field must hold a real number — an empty box (cleared but not
|
||||||
@@ -88,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -98,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -107,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -120,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
max20ftContainerWeightTons: value,
|
max20ftContainerWeightTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={0.001}
|
min={0.001}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -133,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
max20ftPairWeightDiffTons: value,
|
max20ftPairWeightDiffTons: value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -145,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
title="Booking windows"
|
title="Booking windows"
|
||||||
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Import window lead (days)"
|
label="Import window lead"
|
||||||
description="The single booking day opens this many days before departure"
|
description="The single booking day opens this long before departure"
|
||||||
value={form.importWindowLeadDays ?? ""}
|
value={form.importWindowLeadDays ?? ""}
|
||||||
|
nativeUnit="days"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Export booking lead (hours)"
|
label="Export booking lead"
|
||||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
description="Export bookings are accepted first-come-first-serve starting this long before departure"
|
||||||
value={form.exportBookingLeadHours ?? ""}
|
value={form.exportBookingLeadHours ?? ""}
|
||||||
|
nativeUnit="hours"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||||
}
|
}
|
||||||
@@ -172,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||||
}
|
}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowDecimal
|
||||||
min={0}
|
min={0}
|
||||||
max={23}
|
max={23}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Window duration (hours)"
|
label="Window duration"
|
||||||
|
description="How long the import booking window stays open"
|
||||||
value={form.windowDurationHours ?? ""}
|
value={form.windowDurationHours ?? ""}
|
||||||
|
nativeUnit="hours"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||||
}
|
}
|
||||||
min={0.25}
|
min={1}
|
||||||
max={12}
|
|
||||||
step={0.25}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Document review (minutes)"
|
label="Document review"
|
||||||
description="Max staff time to accept booking documents after the window closes"
|
description="Max staff time to accept booking documents after the window closes"
|
||||||
value={form.docReviewMinutes ?? ""}
|
value={form.docReviewMinutes ?? ""}
|
||||||
|
nativeUnit="minutes"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||||
}
|
}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Payment window (minutes)"
|
label="Payment window"
|
||||||
description="Time a selected customer has to pay before the slot expires"
|
description="Time a selected customer has to pay before the slot expires"
|
||||||
value={form.paymentWindowMinutes ?? ""}
|
value={form.paymentWindowMinutes ?? ""}
|
||||||
|
nativeUnit="minutes"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||||
}
|
}
|
||||||
min={1}
|
min={1}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<DurationField
|
||||||
label="Reopen delay (minutes)"
|
label="Reopen delay"
|
||||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
|
||||||
value={form.reopenDelayMinutes ?? ""}
|
value={form.reopenDelayMinutes ?? ""}
|
||||||
|
nativeUnit="minutes"
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user