mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Center, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { FaydaCallbackMessage } from "@/services/verifayda.service";
|
||||
|
||||
/**
|
||||
* Landing page for the eSignet redirect_uri (FAYDA_WEB_REDIRECT_URI →
|
||||
* http://localhost:5183/callback). Runs inside the verification popup:
|
||||
* relays ?code&state (or ?error) to the window that opened it via
|
||||
* postMessage, then closes itself. The opener performs the /complete call
|
||||
* so the single-use session is only consumed once, in one place.
|
||||
*/
|
||||
const FaydaCallbackPage = () => {
|
||||
const [standalone, setStandalone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const message: FaydaCallbackMessage = {
|
||||
type: "fayda-callback",
|
||||
code: params.get("code") ?? undefined,
|
||||
state: params.get("state") ?? undefined,
|
||||
error: params.get("error") ?? undefined,
|
||||
errorDescription: params.get("error_description") ?? undefined,
|
||||
};
|
||||
|
||||
if (window.opener && window.opener !== window) {
|
||||
(window.opener as Window).postMessage(message, window.location.origin);
|
||||
window.close();
|
||||
} else {
|
||||
// Opened as a full-page redirect instead of a popup — nothing to relay to.
|
||||
setStandalone(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Center h="100vh">
|
||||
<Stack align="center" gap="sm">
|
||||
{standalone ? (
|
||||
<>
|
||||
<Text fw={600}>Verification window lost its parent page</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Close this tab and restart the verification from the form.
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Completing Fayda verification…</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaydaCallbackPage;
|
||||
@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
|
||||
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
|
||||
}}
|
||||
onRemove={setRemoveTarget}
|
||||
onAssignDriver={setAssigningDriver}
|
||||
onHistory={setHistoryTarget}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -510,6 +513,7 @@ const FleetResourcePage = () => {
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
selectOptionsLoading={selectOptionsLoading}
|
||||
onSubmit={handleFormSubmit}
|
||||
verifyWithFayda={Boolean(config.faydaVerification)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -580,6 +584,13 @@ const FleetResourcePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ const DRIVER_STATUS_OPTIONS = [
|
||||
{ label: "On leave", value: "ON_LEAVE" },
|
||||
];
|
||||
|
||||
const DRIVER_GENDER_OPTIONS = [
|
||||
{ label: "Male", value: "MALE" },
|
||||
{ label: "Female", value: "FEMALE" },
|
||||
{ label: "Other", value: "OTHER" },
|
||||
];
|
||||
|
||||
export const driversConfig: FleetResourceConfig = {
|
||||
slug: "drivers",
|
||||
label: "Drivers",
|
||||
@@ -29,24 +35,28 @@ export const driversConfig: FleetResourceConfig = {
|
||||
options: DRIVER_STATUS_OPTIONS,
|
||||
},
|
||||
],
|
||||
faydaVerification: true,
|
||||
searchKeys: ["firstName", "lastName", "email", "phoneNumber", "licenseNumber", "status"],
|
||||
columns: [
|
||||
{ id: "licenseNumber", header: "License Number", accessorKey: "licenseNumber", format: "code", size: 140 },
|
||||
{ id: "licenseNumber", header: "Driver's License Number", accessorKey: "licenseNumber", format: "code", size: 180 },
|
||||
{ id: "firstName", header: "First Name", accessorKey: "firstName", format: "code", size: 120 },
|
||||
{ id: "lastName", header: "Last Name", accessorKey: "lastName", format: "code", size: 120 },
|
||||
{ id: "email", header: "Email", accessorKey: "email", format: "code", size: 180 },
|
||||
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber", format: "code", size: 120 },
|
||||
{ id: "gender", header: "Gender", accessorKey: "gender", format: "code", size: 90 },
|
||||
{ id: "licenseExpiryDate", header: "License Expiry", accessorKey: "licenseExpiryDate", format: "code", size: 130 },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
{ id: "faydaVerified", header: "Fayda", accessorKey: "faydaVerified", format: "verifiedBadge", size: 90 },
|
||||
],
|
||||
formFields: [
|
||||
{ name: "licenseNumber", label: "License Number", type: "text", required: true },
|
||||
{ name: "firstName", label: "First Name", type: "text", required: true },
|
||||
{ name: "lastName", label: "Last Name", type: "text", required: true },
|
||||
{ name: "email", label: "Email", type: "email", required: true },
|
||||
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true },
|
||||
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true },
|
||||
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true },
|
||||
{ name: "licenseNumber", label: "Driver's License Number", type: "text", required: true },
|
||||
{ name: "firstName", label: "First Name", type: "text", required: true, faydaLocked: true },
|
||||
{ name: "lastName", label: "Last Name", type: "text", required: true, faydaLocked: true },
|
||||
{ name: "email", label: "Email", type: "email", required: true, faydaLocked: true },
|
||||
{ name: "phoneNumber", label: "Phone Number", type: "text", required: true, faydaLocked: true },
|
||||
{ name: "dateOfBirth", label: "Date of Birth", type: "date", required: true, faydaLocked: true },
|
||||
{ name: "gender", label: "Gender", type: "select", options: DRIVER_GENDER_OPTIONS, faydaLocked: true },
|
||||
{ name: "licenseExpiryDate", label: "License Expiry Date", type: "date", required: true, dateBound: "future" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: DRIVER_STATUS_OPTIONS },
|
||||
{ name: "vehicleTypesAuthorized", label: "Authorized Vehicle Types", type: "multiselect", options: VEHICLE_TYPE_OPTIONS },
|
||||
{ name: "address", label: "Address", type: "textarea" },
|
||||
@@ -60,6 +70,7 @@ export const driversConfig: FleetResourceConfig = {
|
||||
email: "",
|
||||
phoneNumber: "",
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
licenseExpiryDate: "",
|
||||
status: "ACTIVE",
|
||||
vehicleTypesAuthorized: [],
|
||||
|
||||
@@ -33,13 +33,23 @@ export interface FleetResourceColumn {
|
||||
id: string;
|
||||
header: string;
|
||||
accessorKey: string;
|
||||
format?: ColumnFormat | "statusBadge";
|
||||
format?: ColumnFormat | "statusBadge" | "verifiedBadge";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FleetFormFieldDef extends FormFieldDef {
|
||||
dynamicOptions?: FleetDynamicOptions;
|
||||
noneOption?: boolean;
|
||||
/**
|
||||
* Field is owned by the Fayda identity — populated only by verification and
|
||||
* never hand-edited. Rendered disabled in the form.
|
||||
*/
|
||||
faydaLocked?: boolean;
|
||||
/**
|
||||
* Direction a `date` field is constrained to. "future" = must be after today
|
||||
* (e.g. a license expiry); "past" (default) = cannot be in the future.
|
||||
*/
|
||||
dateBound?: "past" | "future";
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
@@ -73,6 +83,8 @@ export interface FleetResourceConfig {
|
||||
cardCodeKey?: string;
|
||||
cardSubtitleKey?: string;
|
||||
searchKeys: string[];
|
||||
/** Offer Fayda identity verification in the add/edit form (drivers). */
|
||||
faydaVerification?: boolean;
|
||||
}
|
||||
|
||||
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
Printer,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Trash,
|
||||
@@ -879,10 +880,14 @@ const FirstMilePage = () => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<ArrowRight size={15} />}
|
||||
disabled={!nextStatus}
|
||||
disabled={!nextStatus || (nextStatus === "IN_TRANSIT" && !assigned)}
|
||||
onClick={() => handleAdvanceStatus(row.original)}
|
||||
>
|
||||
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
|
||||
{nextStatus === "IN_TRANSIT" && !assigned
|
||||
? "Assign a vehicle first"
|
||||
: nextStatus
|
||||
? `Mark ${STATUS_META[nextStatus].label}`
|
||||
: STATUS_META[row.original.status].label}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
@@ -919,6 +924,13 @@ const FirstMilePage = () => {
|
||||
>
|
||||
Add distance
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={15} />}
|
||||
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
|
||||
onClick={() => openInvoice(row.original)}
|
||||
>
|
||||
Generate Invoice
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
@@ -954,7 +966,7 @@ const FirstMilePage = () => {
|
||||
}, [vehicleOptions]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="md" p="md">
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Printer,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Trash,
|
||||
@@ -49,6 +50,7 @@ import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -92,6 +94,58 @@ const vehicleLabel = (record: LastMileRecord) => {
|
||||
|
||||
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
||||
|
||||
const fmtStamp = (iso?: string | null) => {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the 6-step last-mile workflow state for a record. Step completion is
|
||||
* read from the record + its pickup-ready (warehouse release) row:
|
||||
* assign→vehicleId, arrived→release order issued, leave→releaseDate,
|
||||
* in-transit/delivered→status, distance→exactKm.
|
||||
*/
|
||||
const computeLastMileSteps = (
|
||||
record: LastMileRecord,
|
||||
releaseRow?: ImportUnloadedItem,
|
||||
): LastMileStepState[] => {
|
||||
const exactKm = (record as { exactKm?: number | null }).exactKm;
|
||||
// Truck arrival/leave live in the transient warehouse pickup-ready queue and
|
||||
// vanish once the item is released. So once the leg is IN_TRANSIT/DELIVERED,
|
||||
// treat both as done (the truck must have arrived + left to get there).
|
||||
const past = record.status === "IN_TRANSIT" || record.status === "DELIVERED";
|
||||
const flags = [
|
||||
record.status !== "PAYMENT_PENDING",
|
||||
Boolean(record.vehicleId),
|
||||
past || Boolean(releaseRow?.releaseOrderReference),
|
||||
past || Boolean(releaseRow?.releaseDate),
|
||||
past,
|
||||
exactKm != null,
|
||||
exactKm != null, // Generate Invoice — auto-generated when distance is saved
|
||||
record.status === "DELIVERED",
|
||||
];
|
||||
// Current step = earliest incomplete one.
|
||||
const activeIdx = flags.findIndex((f) => !f);
|
||||
const labels = ["Ready to Transit", "Assign vehicle", "Truck arrived", "Truck leave", "In transit", "Add distance", "Generate Invoice", "Delivered"];
|
||||
const details: (string | null)[] = [
|
||||
null,
|
||||
record.vehicle?.plateNumber ?? null,
|
||||
releaseRow?.releaseOrderReference ?? null,
|
||||
fmtStamp(releaseRow?.releaseDate),
|
||||
null,
|
||||
exactKm != null ? `${exactKm} KM` : null,
|
||||
exactKm != null ? "Invoice ready" : null,
|
||||
fmtStamp(releaseRow?.deliveredAt),
|
||||
];
|
||||
return labels.map((label, i) => ({
|
||||
label,
|
||||
done: flags[i],
|
||||
active: i === activeIdx,
|
||||
detail: details[i],
|
||||
}));
|
||||
};
|
||||
|
||||
const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
@@ -955,7 +1009,24 @@ const LastMilePage = () => {
|
||||
const delivered = row.original.status === "DELIVERED";
|
||||
const releaseRow =
|
||||
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
|
||||
const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival";
|
||||
// Gate on PERSISTENT state (status/vehicle/distance), not the truck
|
||||
// arrival/leave signals — those live in the warehouse queue and vanish
|
||||
// once the item is released, so they can't gate the status advance.
|
||||
const status = row.original.status;
|
||||
const hasDistance = row.original.exactKm != null;
|
||||
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
|
||||
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
|
||||
const canAdvance =
|
||||
status === "PAYMENT_PENDING" ||
|
||||
(status === "READY_TO_TRANSIT" && assigned) ||
|
||||
(status === "IN_TRANSIT" && hasDistance);
|
||||
const canAssignStep = !assigned && status !== "DELIVERED";
|
||||
const canDistance = status === "IN_TRANSIT";
|
||||
// Truck arrival/leaving are independent — each driven only by its own
|
||||
// warehouse state: arrive once assigned & not arrived, leave once
|
||||
// arrived & not departed.
|
||||
const canArrive = assigned && !releaseRow?.releaseOrderReference;
|
||||
const canLeave = Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate;
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -967,15 +1038,17 @@ const LastMilePage = () => {
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<ArrowRight size={15} />}
|
||||
disabled={!nextStatus}
|
||||
disabled={!nextStatus || !canAdvance}
|
||||
onClick={() => handleAdvanceStatus(row.original)}
|
||||
>
|
||||
{nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label}
|
||||
{nextStatus
|
||||
? `Mark ${STATUS_META[nextStatus].label}`
|
||||
: STATUS_META[row.original.status].label}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={assigned || delivered}
|
||||
disabled={!canAssignStep}
|
||||
onClick={() => openAssign(row.original.id)}
|
||||
>
|
||||
Assign
|
||||
@@ -989,10 +1062,17 @@ const LastMilePage = () => {
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!assigned}
|
||||
disabled={!canArrive}
|
||||
onClick={() => openTruckArrival(row.original)}
|
||||
>
|
||||
{truckArrivalLabel}
|
||||
Truck Arrival
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!canLeave}
|
||||
onClick={() => openTruckArrival(row.original)}
|
||||
>
|
||||
Truck Leaving
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
@@ -1003,11 +1083,18 @@ const LastMilePage = () => {
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
disabled={delivered}
|
||||
disabled={!canDistance}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
>
|
||||
Add distance
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Receipt size={15} />}
|
||||
disabled={!(row.original.exactKm != null && row.original.exactKm > 0)}
|
||||
onClick={() => openInvoice(row.original)}
|
||||
>
|
||||
Generate Invoice
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
@@ -1044,7 +1131,7 @@ const LastMilePage = () => {
|
||||
}, [vehicleOptions, pickupReadyByBooking]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="md" p="md">
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
@@ -1320,6 +1407,18 @@ const LastMilePage = () => {
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeRecord && <BookingInfo record={activeRecord} />}
|
||||
{activeRecord && (
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Text fw={600} size="sm" mb="sm">Delivery steps</Text>
|
||||
<LastMileStepper
|
||||
steps={computeLastMileSteps(
|
||||
activeRecord,
|
||||
pickupReadyByBooking.get(activeRecord.bookingId) ??
|
||||
pickupReadyByBooking.get(bookingRef(activeRecord)),
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => { setDetailOpen(false); setActiveId(null); }}>Close</Button>
|
||||
</Group>
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@/components/trainScheduling/containerPlacement.util";
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
@@ -145,6 +146,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importLoadingQuery = useQuery(
|
||||
api.trainScheduling.importLoadingBookings.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
|
||||
}),
|
||||
);
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
schedule
|
||||
@@ -955,6 +963,23 @@ export default function TrainScheduleV2DetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{schedule?.direction === "IMPORT" ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Import loading confirmation</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
|
||||
is tracking only — it does not block dispatch.
|
||||
</Text>
|
||||
<ImportLoadingConfirmationPanel
|
||||
scheduleId={scheduleId as string}
|
||||
items={importLoadingQuery.data?.items ?? []}
|
||||
isLoading={importLoadingQuery.isLoading}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
|
||||
@@ -14,7 +14,17 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
PackageOpen,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
@@ -36,6 +46,7 @@ import {
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { trainSchedulingService } from '@/services/trainScheduling.service';
|
||||
import type {
|
||||
AutoUnloadExportDjiboutiResult,
|
||||
ExportTrain,
|
||||
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||
const generateInterchange = useGenerateInterchangeDocument();
|
||||
const qc = useQueryClient();
|
||||
const secureGatePass = useMutation({
|
||||
mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({
|
||||
queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'],
|
||||
}),
|
||||
});
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
|
||||
@@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
.map((doc) => [doc.scheduleId as string, doc]),
|
||||
);
|
||||
|
||||
const secureGate = async (train: ExportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
await secureGatePass.mutateAsync(train.scheduleId);
|
||||
toast({
|
||||
title: 'Gate pass secured',
|
||||
description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Could not secure gate pass',
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unloadTrain = async (train: ExportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
|
||||
onClick={() => secureGate(train)}
|
||||
>
|
||||
Secure Gate Pass
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="green"
|
||||
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
Auto Unload Export Items
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
|
||||
const [filter, setFilter] = useState<InventoryFilter>(
|
||||
initialStatus ? { status: initialStatus } : {},
|
||||
);
|
||||
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const queryFilter = useMemo<InventoryFilter>(
|
||||
() => ({ ...filter, search: debouncedSearch || undefined }),
|
||||
[filter, debouncedSearch],
|
||||
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
|
||||
[filter, direction, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useWarehouses();
|
||||
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Warehouse Inventory"
|
||||
title={
|
||||
direction === 'IMPORT'
|
||||
? 'Import Terminal Inventory'
|
||||
: direction === 'EXPORT'
|
||||
? 'Export Terminal Inventory'
|
||||
: 'Warehouse Inventory'
|
||||
}
|
||||
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
||||
action={
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -253,7 +253,7 @@ function AllocationRules() {
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
const value = lettersOnly(e.currentTarget.value);
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
@@ -569,7 +569,7 @@ function FeeRules() {
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
const value = lettersOnly(e.currentTarget.value);
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user