mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
add items per wagon map to cargo types and sync bulk rate units
- Implemented in the to manage the physical item capacity for each wagon type. - Added a new migration to create the column in the table. - Introduced method in to update rate units when cargo type unit of measure changes. - Updated booking calculations to consider items per wagon for break-bulk cargo. - Refactored various components to utilize the new items fit logic and ensure consistent date formatting across the application. - Added tests for the new display timezone functionality to ensure consistent date/time representation across different user settings.
This commit is contained in:
@@ -29,7 +29,6 @@ import {
|
||||
Upload,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
@@ -299,7 +298,14 @@ function DocumentRow({
|
||||
<Text size="xs" c="dimmed" mt={4} truncate>
|
||||
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
|
||||
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
|
||||
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
|
||||
{new Date(doc.uploadedAt).toLocaleString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import "@edr/ui-common/display-timezone";
|
||||
|
||||
// The pin must hold on ANY machine timezone — these assertions are the bug:
|
||||
// before the patch they only passed on a PC already set to UTC+3.
|
||||
describe("display-timezone pin (EAT, UTC+3)", () => {
|
||||
const utcMidnight = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
it("formats Date.toLocale* in EAT regardless of machine timezone", () => {
|
||||
expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe(
|
||||
"03:00:00",
|
||||
);
|
||||
// 22:00 UTC is already the NEXT day in EAT.
|
||||
expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe(
|
||||
"2026-01-02",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => {
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
expect(fmt.format(utcMidnight)).toBe("03:00");
|
||||
expect(fmt).toBeInstanceOf(Intl.DateTimeFormat);
|
||||
expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain(
|
||||
"en-GB",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects an explicit timeZone option", () => {
|
||||
expect(
|
||||
utcMidnight.toLocaleTimeString("en-GB", {
|
||||
hour12: false,
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
).toBe("00:00:00");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// Must stay the first import: pins all date/time display to EAT before any
|
||||
// module can create a formatter in the PC's local timezone.
|
||||
import "@edr/ui-common/display-timezone";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => {
|
||||
<TableCell>{log.message}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{log.timestamp && !isNaN(new Date(log.timestamp).getTime())
|
||||
? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")
|
||||
? new Date(log.timestamp).toLocaleString("sv-SE")
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord {
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
|
||||
wagonTypes?: { id: string; code?: string; name?: string }[];
|
||||
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
|
||||
const ITEMS_FIT_PREFIX = "itemsFit__";
|
||||
|
||||
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||
|
||||
@@ -131,15 +136,33 @@ const CargoTypesPage = () => {
|
||||
|
||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
);
|
||||
const formFields = useMemo<FormFieldDef[]>(() => {
|
||||
const base = FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeIds"
|
||||
? { ...field, options: wagonTypeOptions ?? [] }
|
||||
: field,
|
||||
);
|
||||
// PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how
|
||||
// many whole items physically fit that wagon (floor space binds before
|
||||
// tonnage). Shown only while the wagon type is picked; the API requires a
|
||||
// fit for every selected type on PER_ITEM cargo.
|
||||
const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({
|
||||
name: `${ITEMS_FIT_PREFIX}${opt.value}`,
|
||||
label: `Items per ${opt.label} wagon`,
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "e.g. 4",
|
||||
showIf: (values) =>
|
||||
values.unitOfMeasure === "PER_ITEM" &&
|
||||
Array.isArray(values.wagonTypeIds) &&
|
||||
(values.wagonTypeIds as string[]).includes(opt.value),
|
||||
getInitialValue: (record) =>
|
||||
(record as CargoNode).itemsPerWagonMap?.[opt.value],
|
||||
}));
|
||||
const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
|
||||
base.splice(wagonTypesAt + 1, 0, ...fitFields);
|
||||
return base;
|
||||
}, [wagonTypeOptions]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...values };
|
||||
// Fold the per-wagon-type fit inputs into the API's map shape. Null when
|
||||
// none are visible (not PER_ITEM) so an update clears stale fits.
|
||||
const payload: Record<string, unknown> = {};
|
||||
const itemsPerWagonMap: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (key.startsWith(ITEMS_FIT_PREFIX)) {
|
||||
itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value);
|
||||
} else {
|
||||
payload[key] = value;
|
||||
}
|
||||
}
|
||||
payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null;
|
||||
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||
if (formMode?.kind === "create" && current) {
|
||||
payload.parentGroupId = current.id;
|
||||
|
||||
@@ -107,7 +107,14 @@ const ReminderList = () => {
|
||||
Remind {dayjs(reminder.remindAt).fromNow()}
|
||||
</p>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
({dayjs(reminder.remindAt).format("MMM D, h:mm A")})
|
||||
(
|
||||
{new Date(reminder.remindAt).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Clock,
|
||||
User,
|
||||
@@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
|
||||
const iconBg = "bg-gray-100 dark:bg-gray-800";
|
||||
const whiteBg = "bg-white dark:bg-gray-900";
|
||||
|
||||
const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy");
|
||||
const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss");
|
||||
const formattedDate = new Date(activity.timestamp).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "short", day: "numeric", year: "numeric" },
|
||||
);
|
||||
const formattedTime = new Date(activity.timestamp).toLocaleTimeString(
|
||||
"en-GB",
|
||||
{ hour12: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -213,7 +213,6 @@ export default function AuditLogPageShared({
|
||||
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
|
||||
const primaryBtn =
|
||||
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
|
||||
const primaryIcon = "text-gray-900 dark:text-gray-100";
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
|
||||
textSubtle,
|
||||
)}>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "MMM d, yyyy")}
|
||||
{new Date(log.timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-2 min-w-0">
|
||||
<span className={cn(textSubtle)}>•</span>
|
||||
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className={cn("text-sm", textStrong)}>
|
||||
{format(
|
||||
new Date(log.timestamp),
|
||||
"MMM d, yyyy",
|
||||
{new Date(log.timestamp).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
<span className={cn("text-xs", textSubtle)}>
|
||||
{format(new Date(log.timestamp), "HH:mm:ss")}
|
||||
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
Reference in New Issue
Block a user