Add migration to widen window_duration_hours precision and update related components for duration handling

This commit is contained in:
Marshal
2026-07-03 16:37:09 +00:00
parent 907ff2138b
commit a5c46505e3
8 changed files with 274 additions and 30 deletions

View File

@@ -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}
/>

View File

@@ -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 }))
}