mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
merge
This commit is contained in:
@@ -1,77 +1,16 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowRight, Building2, Package } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, Calendar, Package, User } from "lucide-react";
|
||||
import { Group } from "@mantine/core";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue";
|
||||
|
||||
function BookingQueueRow({
|
||||
booking,
|
||||
selected,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
booking: BookingListRow;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Package size={14} />
|
||||
<Text fw={600} size="sm">{booking.reference}</Text>
|
||||
{booking.isGovernment ? (
|
||||
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
|
||||
{booking.schedulingStatus ? (
|
||||
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
|
||||
<Group gap={6}>
|
||||
<Text size="xs">{booking.originLabel}</Text>
|
||||
<ArrowRight size={12} />
|
||||
<Text size="xs">{booking.destinationLabel}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
{booking.serviceTypeLabel ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.serviceTypeLabel}
|
||||
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge, DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
export function OperationsBookingQueue({
|
||||
bookings,
|
||||
@@ -82,144 +21,144 @@ export function OperationsBookingQueue({
|
||||
isLoading?: boolean;
|
||||
onAllocate: (bookingIds: string[]) => void;
|
||||
}) {
|
||||
const { government, commercial } = useMemo(
|
||||
() => groupBookingsForOperationsQueue(bookings),
|
||||
[bookings],
|
||||
const navigate = useNavigate();
|
||||
const suppressRowClickRef = useRef(false);
|
||||
|
||||
const suppressRowClick = useCallback(() => {
|
||||
suppressRowClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressRowClickRef.current = false;
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: BookingListRow) => {
|
||||
if (suppressRowClickRef.current) return;
|
||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
const [govSelected, setGovSelected] = useState<string[]>([]);
|
||||
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
|
||||
|
||||
const allocatable = (row: BookingListRow) =>
|
||||
row.status === "PAID" &&
|
||||
canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus });
|
||||
|
||||
const govSelection = govSelected.length
|
||||
? govSelected
|
||||
: government.filter(allocatable).map((b) => b.id);
|
||||
|
||||
const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => {
|
||||
const existing = selectedByBucket[bucketKey];
|
||||
if (existing) return existing;
|
||||
return bucketBookings.filter(allocatable).map((b) => b.id);
|
||||
};
|
||||
|
||||
const toggleGov = (bookingId: string) => {
|
||||
setGovSelected((prev) => {
|
||||
const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id);
|
||||
return base.includes(bookingId)
|
||||
? base.filter((id) => id !== bookingId)
|
||||
: [...base, bookingId];
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBucket = (bucketKey: string, bookingId: string) => {
|
||||
setSelectedByBucket((prev) => {
|
||||
const current = prev[bucketKey] ?? [];
|
||||
const next = current.includes(bookingId)
|
||||
? current.filter((id) => id !== bookingId)
|
||||
: [...current, bookingId];
|
||||
return { ...prev, [bucketKey]: next };
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Text size="sm" c="dimmed">Loading operations queue…</Text>;
|
||||
}
|
||||
|
||||
if (!government.length && !commercial.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No PAID bookings ready to allocate.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const columns: ColumnDef<BookingListRow>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<Package className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<p className="truncate font-medium text-foreground">{booking.reference}</p>
|
||||
{booking.isGovernment ? (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
Government
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{booking.customerLabel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{booking.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">{booking.destinationLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase">
|
||||
{booking.tradeDirection}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
{booking.freightType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="space-y-1 py-1">
|
||||
<BookingStatusBadge status={row.original.status} />
|
||||
{row.original.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={row.original.schedulingStatus} />
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "scheduled",
|
||||
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
{row.original.scheduledDate}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "priority",
|
||||
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
||||
cell: ({ row }) => <BookingPriorityBadge score={row.original.priorityScore} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: () => <span className={bookingTable.headerCell}>Amount</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
|
||||
{row.original.paymentCurrency}{" "}
|
||||
{row.original.totalAmount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className={bookingTable.headerCell}>Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<BookingActionsMenu
|
||||
row={row.original}
|
||||
variant="table"
|
||||
onSuppressRowClick={suppressRowClick}
|
||||
onAllocateBooking={() => onAllocate([row.original.id])}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{government.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Title order={5}>Government priority</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
Served first — not grouped by 3-hour window
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{govSelection.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="violet"
|
||||
disabled={!govSelection.length}
|
||||
onClick={() => onAllocate(govSelection)}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
{government.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={govSelection.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleGov(booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{commercial.length > 0 ? (
|
||||
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
|
||||
{commercial.map((bucket) => {
|
||||
const selected = bucketSelection(bucket.key, bucket.bookings);
|
||||
return (
|
||||
<Accordion.Item key={bucket.key} value={bucket.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">{bucket.label}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bucket.bookings.length} commercial booking
|
||||
{bucket.bookings.length === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{selected.length} selected</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
disabled={!selected.length}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocate(selected);
|
||||
}}
|
||||
>
|
||||
Allocate
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{bucket.bookings.map((booking) => (
|
||||
<BookingQueueRow
|
||||
key={booking.id}
|
||||
booking={booking}
|
||||
selected={selected.includes(booking.id)}
|
||||
disabled={!allocatable(booking)}
|
||||
onToggle={() => toggleBucket(bucket.key, booking.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
) : null}
|
||||
</Stack>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={bookings}
|
||||
status={isLoading ? "loading" : "success"}
|
||||
emptyMessage="No PAID bookings ready to load."
|
||||
onRowClick={handleRowClick}
|
||||
containerClassName={cn(
|
||||
"border-0 shadow-none",
|
||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage route definitions built from freight yards",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/warehouses/list",
|
||||
meta: {
|
||||
title: "Warehouses",
|
||||
subtitle: "Manage warehouses, yards, and zones",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/warehouse-inventory",
|
||||
meta: {
|
||||
title: "Warehouse inventory",
|
||||
subtitle: "Track received items through inspection and loading",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/inventory-inquiry",
|
||||
meta: {
|
||||
title: "Inventory inquiry",
|
||||
subtitle: "Locate cargo, containers, and goods inside the warehouse network",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/warehouses",
|
||||
meta: {
|
||||
title: "Warehouse dashboard",
|
||||
subtitle: "Live overview of warehouse capacity and inventory lifecycle",
|
||||
},
|
||||
},
|
||||
...getFleetRouteMeta(),
|
||||
{
|
||||
prefix: "/dashboard/trains/",
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -32,6 +34,9 @@ function fmtDate(iso: string | null) {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
|
||||
|
||||
function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
const meta = LABELS[fee.ruleType] ?? { label: fee.ruleType, color: 'gray' };
|
||||
const configured = Boolean(fee.ruleId);
|
||||
@@ -48,7 +53,7 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={800} size="lg" c={`${meta.color}.7`}>
|
||||
{fee.amount.toLocaleString()} {fee.currency}
|
||||
{money(fee.amount, fee.currency)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -60,10 +65,12 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Stack gap={4}>
|
||||
<Row label="Rule" value={fee.ruleName ?? '—'} />
|
||||
<Row label="Free days" value={String(fee.freeDays)} />
|
||||
<Row label="Rate / day" value={`${fee.ratePerDay.toLocaleString()} ${fee.currency}`} />
|
||||
<Row label="Rate / day" value={money(fee.ratePerDay, fee.currency)} />
|
||||
<Row label="Period" value={`${fmtDate(fee.startDate)} → ${fmtDate(fee.endDate)}${fee.endIsOpen ? ' (today)' : ''}`} />
|
||||
<Row label="Elapsed days" value={String(fee.elapsedDays)} />
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
@@ -106,7 +113,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
const inv = await generate.mutateAsync({ inventoryId, confirmZero });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} — ${inv.totalAmount} ${inv.currency}` });
|
||||
toast({ title: 'Invoice generated', description: `${inv.invoiceNumber} - ${money(inv.totalAmount, inv.currency)}` });
|
||||
} catch (error) {
|
||||
const msg = extractErrorMessage(error);
|
||||
if (/no payable warehouse fee/i.test(msg)) {
|
||||
@@ -121,11 +128,21 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
|
||||
const handleGateClearance = async () => {
|
||||
if (!inventoryId) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await gateClear.mutateAsync(inventoryId);
|
||||
toast({ title: 'Gate clearance recorded', description: 'Item released from terminal.' });
|
||||
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
|
||||
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The release PDF opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release blocked', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
@@ -165,7 +182,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{Number(activeInvoice.balanceAmount).toLocaleString()} {activeInvoice.currency} due
|
||||
{money(Number(activeInvoice.balanceAmount), activeInvoice.currency)} due
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
@@ -12,10 +12,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateInspectionReport, useInspectionReports, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
|
||||
import {
|
||||
INSPECTION_REPORT_TYPES,
|
||||
INSPECTION_STATUSES,
|
||||
@@ -47,12 +45,9 @@ const STATUS_LABELS: Record<InspectionResultStatus, string> = {
|
||||
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
|
||||
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
|
||||
const { toast } = useToast();
|
||||
const createReport = useMutation(
|
||||
api.warehouses.createInspectionReport.mutationOptions(),
|
||||
);
|
||||
const uploadAttachments = useMutation(
|
||||
api.warehouses.uploadInspectionAttachments.mutationOptions(),
|
||||
);
|
||||
const createReport = useCreateInspectionReport();
|
||||
const uploadAttachments = useUploadInspectionAttachments();
|
||||
const reportsQuery = useInspectionReports(opened ? inventoryId ?? undefined : undefined);
|
||||
|
||||
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
|
||||
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
|
||||
@@ -82,6 +77,27 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const report = reportsQuery.data?.[0];
|
||||
if (!report) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setReportType(report.reportType);
|
||||
setInspectionStatus(report.inspectionStatus);
|
||||
setHasDamage(report.hasDamage ?? false);
|
||||
setDamageDescription(report.damageDescription ?? '');
|
||||
setHasWeightLoss(report.hasWeightLoss ?? false);
|
||||
setExpectedWeight(report.expectedWeight == null ? '' : Number(report.expectedWeight));
|
||||
setActualWeight(report.actualWeight == null ? '' : Number(report.actualWeight));
|
||||
setHasMissingItems(report.hasMissingItems ?? false);
|
||||
setMissingItemsDescription(report.missingItemsDescription ?? '');
|
||||
setRemarks(report.remarks ?? '');
|
||||
setFiles([]);
|
||||
}, [opened, reportsQuery.data]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inventoryId) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface InventoryDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
|
||||
{!item ? (
|
||||
<Text c="dimmed">No inventory item selected.</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="lg" fw={800}>
|
||||
{item.booking?.reference ?? item.bookingId ?? item.id}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Inventory ID: {item.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</Group>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Warehouse" value={item.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : '-'} />
|
||||
<DetailRow label="Yard" value={item.yard ? `${item.yard.name} (${item.yard.code})` : '-'} />
|
||||
<DetailRow label="Zone" value={item.zone ? `${item.zone.name} (${item.zone.code})` : '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Booking & item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
|
||||
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
|
||||
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
|
||||
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
|
||||
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
|
||||
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Lifecycle" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Inspection" value={<Badge variant="light">{item.inspectionStatus ?? 'Not inspected'}</Badge>} />
|
||||
<DetailRow label="Arrived" value={formatDate(item.arrivedAt)} />
|
||||
<DetailRow label="Stored" value={formatDate(item.storedAt)} />
|
||||
<DetailRow label="Reserved" value={formatDate(item.reservedAt)} />
|
||||
<DetailRow label="Inspected" value={formatDate(item.inspectedAt)} />
|
||||
<DetailRow label="Ready for loading" value={formatDate(item.readyForLoadingAt)} />
|
||||
<DetailRow label="Loaded" value={formatDate(item.loadedAt)} />
|
||||
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
|
||||
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
|
||||
<DetailRow label="Released" value={formatDate(item.releaseDate)} />
|
||||
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
{item.notes && (
|
||||
<>
|
||||
<Divider label="Notes" labelPosition="left" />
|
||||
<Text size="sm">{item.notes}</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Badge, Divider, Group, Modal, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
|
||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface InventoryInquiryDetailModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
result: InventoryInquiryResult | null;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{value || '-'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function itemLabel(result: InventoryInquiryResult) {
|
||||
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||
if (result.cargoType) return result.cargoType;
|
||||
if (result.cargoDescription) return result.cargoDescription;
|
||||
if (result.goodsId) return `Goods ${result.goodsId}`;
|
||||
return '-';
|
||||
}
|
||||
|
||||
export function InventoryInquiryDetailModal({ opened, onClose, result }: InventoryInquiryDetailModalProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Inventory inquiry detail" centered size="xl">
|
||||
{!result ? (
|
||||
<Text c="dimmed">No inquiry result selected.</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={2}>
|
||||
<Text size="lg" fw={800}>
|
||||
{result.bookingReference ?? result.bookingNumber ?? result.bookingId ?? result.id}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Inventory ID: {result.inventoryId ?? 'Not yet in warehouse inventory'}
|
||||
</Text>
|
||||
</Stack>
|
||||
{result.status ? (
|
||||
<InventoryStatusBadge status={result.status} />
|
||||
) : (
|
||||
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'}>
|
||||
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider label="Booking" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Booking reference" value={result.bookingReference ?? result.bookingNumber ?? '-'} />
|
||||
<DetailRow label="Booking status" value={result.bookingStatus ?? '-'} />
|
||||
<DetailRow label="Customer" value={result.customerName ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Item" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Item" value={itemLabel(result)} />
|
||||
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Warehouse" value={result.warehouse ? `${result.warehouse.name} (${result.warehouse.code})` : '-'} />
|
||||
<DetailRow label="Yard" value={result.yard ? `${result.yard.name} (${result.yard.code})` : '-'} />
|
||||
<DetailRow label="Zone" value={result.zone ? `${result.zone.name} (${result.zone.code})` : '-'} />
|
||||
<DetailRow label="Current location" value={result.locationSummary ?? '-'} />
|
||||
<DetailRow label="Train" value={result.trainNumber ?? '-'} />
|
||||
<DetailRow label="Route" value={result.route ?? '-'} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Dates" labelPosition="left" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Arrived" value={formatDate(result.arrivedAt)} />
|
||||
<DetailRow label="Ready for loading" value={formatDate(result.readyForLoadingAt)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
@@ -17,6 +19,7 @@ import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -33,6 +36,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -91,10 +95,46 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
}
|
||||
};
|
||||
|
||||
const downloadReleaseDocument = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
const filename = `release-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({ title: opened ? 'Release exit paper opened' : 'Release exit paper downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Release paper preview failed',
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const stored = await storeMutation.mutateAsync(item.id);
|
||||
toast({
|
||||
title: 'Inventory stored',
|
||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||
});
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const advance = (item: WarehouseInventoryItem, action: InventoryAction) => {
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return runDirect(item, () => storeMutation.mutateAsync(item.id), 'Inventory stored');
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
@@ -151,8 +191,10 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onView={setViewItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onReleaseDocument={downloadReleaseDocument}
|
||||
onLastMile={onLastMile}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
@@ -174,6 +216,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
onClose={() => setHistoryItem(null)}
|
||||
item={historyItem}
|
||||
/>
|
||||
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectItem)}
|
||||
onClose={() => setInspectItem(null)}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
@@ -19,6 +21,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const [reference, setReference] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
@@ -26,36 +29,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
||||
toast({ title: 'Release order issued' });
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { reference: reference.trim() || undefined },
|
||||
});
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
const blob = response.data;
|
||||
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Release exit paper issued',
|
||||
description: opened
|
||||
? 'The PDF opened in a browser tab for printing or saving.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
||||
picked up and delivered.
|
||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||
printed paper authorizes the goods to leave the warehouse gate.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release order reference"
|
||||
placeholder="e.g. DO-2026-001"
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
||||
Issue release order
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Issue & view exit paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon, Card, Group, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Pencil } from 'lucide-react';
|
||||
import { ActionIcon, Box, Card, Divider, Group, Progress, SimpleGrid, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Building2, Eye, MapPin, Package, Pencil, Weight } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -35,56 +35,101 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{warehouses.map((warehouse) => (
|
||||
<Card key={warehouse.id} withBorder radius="md" padding="lg">
|
||||
<Stack gap="sm">
|
||||
<Card
|
||||
key={warehouse.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
borderColor: 'var(--mantine-color-gray-2)',
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
<Box h={3} bg={warehouse.status === 'ACTIVE' ? 'green.5' : 'gray.4'} />
|
||||
<Stack gap="md" p="lg">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div>
|
||||
<Text fw={700}>{warehouse.name}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{warehouse.code}
|
||||
</Text>
|
||||
</div>
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Group>
|
||||
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<Building2 size={14} />
|
||||
<Text size="sm">{stationNameById.get(warehouse.stationId)}</Text>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
color: 'var(--mantine-color-green-7)',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Building2 size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="md" truncate>
|
||||
{warehouse.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={600} truncate>
|
||||
{warehouse.code}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={6} c="dimmed">
|
||||
<MapPin size={14} />
|
||||
<Text size="sm">{warehouse.locationName}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentWeight, warehouse.capacityWeight)}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
Containers
|
||||
</Text>
|
||||
<Text size="sm">{formatCapacity(warehouse.currentContainers, warehouse.capacityContainers)}</Text>
|
||||
<Stack gap={6} align="flex-end">
|
||||
<WarehouseStatusBadge status={warehouse.status} />
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(warehouse)} title="View">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onEdit(warehouse)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Stack gap={8}>
|
||||
{warehouse.stationId && stationNameById.get(warehouse.stationId) && (
|
||||
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||
<Building2 size={15} />
|
||||
<Text size="sm" truncate>
|
||||
{stationNameById.get(warehouse.stationId)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{warehouse.locationName && (
|
||||
<Group gap={8} c="dimmed" wrap="nowrap">
|
||||
<MapPin size={15} />
|
||||
<Text size="sm" truncate>
|
||||
{warehouse.locationName}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="md">
|
||||
<CapacityRow
|
||||
icon={Weight}
|
||||
label="Weight"
|
||||
current={warehouse.currentWeight}
|
||||
capacity={warehouse.capacityWeight}
|
||||
/>
|
||||
<CapacityRow
|
||||
icon={Package}
|
||||
label="Containers"
|
||||
current={warehouse.currentContainers}
|
||||
capacity={warehouse.capacityContainers}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" gap="xs" mt="auto">
|
||||
<Tooltip label="View warehouse" withArrow>
|
||||
<ActionIcon variant="light" color="gray" onClick={() => onView(warehouse)} aria-label="View warehouse">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit warehouse" withArrow>
|
||||
<ActionIcon variant="light" color="orange" onClick={() => onEdit(warehouse)} aria-label="Edit warehouse">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -92,3 +137,40 @@ export function WarehouseCardView({ warehouses, onView, onEdit }: WarehouseCardV
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
const capacityPercent = (current?: number | null, capacity?: number | null) => {
|
||||
if (!capacity || capacity <= 0) return 0;
|
||||
return Math.min(100, Math.max(0, ((current ?? 0) / capacity) * 100));
|
||||
};
|
||||
|
||||
function CapacityRow({
|
||||
icon: Icon,
|
||||
label,
|
||||
current,
|
||||
capacity,
|
||||
}: {
|
||||
icon: typeof Weight;
|
||||
label: string;
|
||||
current?: number | null;
|
||||
capacity?: number | null;
|
||||
}) {
|
||||
const percent = capacityPercent(current, capacity);
|
||||
const color = percent >= 90 ? 'red' : percent >= 70 ? 'orange' : 'green';
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={7} c="dimmed" wrap="nowrap">
|
||||
<Icon size={15} />
|
||||
<Text size="xs" fw={700} tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatCapacity(Number(current) || 0, capacity)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={percent} color={color} size="xs" radius="xl" bg="var(--mantine-color-gray-1)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Stack, Text } from '@mantine/core';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { ActionIcon, Badge, Stack, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { InventoryInquiryResult } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
@@ -7,71 +7,111 @@ import { formatDate, formatNumber } from './options';
|
||||
|
||||
interface WarehouseInquiryTableProps {
|
||||
results: InventoryInquiryResult[];
|
||||
onView?: (result: InventoryInquiryResult) => void;
|
||||
}
|
||||
|
||||
const dash = '-';
|
||||
|
||||
const itemDescriptor = (result: InventoryInquiryResult) => {
|
||||
if (result.containerNumber) return `Container ${result.containerNumber}`;
|
||||
if (result.cargoType) return `Cargo · ${result.cargoType}`;
|
||||
if (result.cargoDescription) return `Cargo · ${result.cargoDescription}`;
|
||||
if (result.cargoType) return `Cargo - ${result.cargoType}`;
|
||||
if (result.cargoDescription) return `Cargo - ${result.cargoDescription}`;
|
||||
if (result.goodsId) return 'Goods';
|
||||
return '—';
|
||||
return dash;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<InventoryInquiryResult>[] = [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.bookingNumber ?? row.original.bookingId.slice(0, 8)}
|
||||
export function WarehouseInquiryTable({ results, onView }: WarehouseInquiryTableProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No matching items. Adjust your search to locate cargo, containers or goods.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customerName ?? '—' },
|
||||
{ id: 'item', header: 'Item', cell: ({ row }) => itemDescriptor(row.original) },
|
||||
{
|
||||
id: 'warehouse',
|
||||
header: 'Warehouse',
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{row.original.warehouse?.name ?? '—'}</Text>
|
||||
{row.original.warehouse?.code && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.warehouse.code}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.name ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.name ?? '—' },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <InventoryStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{ id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) },
|
||||
{ id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'arrived',
|
||||
header: 'Arrived',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivedAt)}</Text>,
|
||||
},
|
||||
{
|
||||
id: 'ready',
|
||||
header: 'Ready',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.readyForLoadingAt)}</Text>,
|
||||
},
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInquiryTable({ results }: WarehouseInquiryTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results}
|
||||
status="success"
|
||||
emptyMessage="No matching items. Adjust your search to locate cargo, containers or goods."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Location</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th>Ready</Table.Th>
|
||||
{onView ? <Table.Th ta="right">Actions</Table.Th> : null}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{results.map((result) => (
|
||||
<Table.Tr key={result.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{result.bookingReference ?? result.bookingNumber ?? result.bookingId?.slice(0, 8) ?? dash}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{result.customerName ?? dash}</Table.Td>
|
||||
<Table.Td>{itemDescriptor(result)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{result.warehouse?.name ?? dash}</Text>
|
||||
{result.warehouse?.code ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{result.warehouse.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{result.yard?.name ?? dash}</Table.Td>
|
||||
<Table.Td>{result.zone?.name ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{result.locationSummary ?? dash}</Text>
|
||||
{result.trainNumber ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{result.trainNumber}
|
||||
{result.route ? ` - ${result.route}` : ''}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{result.status ? (
|
||||
<InventoryStatusBadge status={result.status} />
|
||||
) : (
|
||||
<Badge variant="light" color={result.trainStatus === 'ARRIVED' ? 'orange' : 'blue'} size="sm">
|
||||
{result.trainStatus ?? result.bookingStatus ?? 'Not in warehouse'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(result.quantity)}</Table.Td>
|
||||
<Table.Td>{formatNumber(result.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(result.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(result.readyForLoadingAt)}</Text>
|
||||
</Table.Td>
|
||||
{onView ? (
|
||||
<Table.Td>
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(result)} ml="auto">
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
) : null}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import {
|
||||
INVENTORY_NEXT_ACTION,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from "@/types/warehouse";
|
||||
import { InventoryStatusBadge } from "./badges";
|
||||
import { formatDate, formatNumber, humanizeEnum } from "./options";
|
||||
getNextInventoryAction,
|
||||
type InventoryAction,
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -17,11 +15,11 @@ interface WarehouseInventoryTableProps {
|
||||
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
|
||||
onMove: (item: WarehouseInventoryItem) => void;
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onView?: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
|
||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional row selection (used for bulk Mark-as-Inspected).
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onToggleSelectAll?: () => void;
|
||||
@@ -30,21 +28,21 @@ interface WarehouseInventoryTableProps {
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
if (item.containerId) return { label: "Container", color: "blue" };
|
||||
if (item.cargoId) return { label: "Cargo", color: "grape" };
|
||||
if (item.goodsId) return { label: "Goods", color: "orange" };
|
||||
return { label: "—", color: "gray" };
|
||||
if (item.containerId) return { label: 'Container', color: 'blue' };
|
||||
if (item.cargoId) return { label: 'Cargo', color: 'grape' };
|
||||
if (item.goodsId) return { label: 'Goods', color: 'orange' };
|
||||
return { label: '-', color: 'gray' };
|
||||
};
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: "blue",
|
||||
reserve: "grape",
|
||||
"ready-for-loading": "cyan",
|
||||
load: "teal",
|
||||
dispatch: "edr-green",
|
||||
"ready-for-pickup": "orange",
|
||||
release: "yellow",
|
||||
deliver: "green",
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -53,165 +51,195 @@ export function WarehouseInventoryTable({
|
||||
onAdvance,
|
||||
onMove,
|
||||
onHistory,
|
||||
onView,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onReleaseDocument,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) =>
|
||||
row.original.bookingId ? (
|
||||
<Tooltip label={row.original.bookingId} withArrow>
|
||||
<Text size="sm" fw={600}>
|
||||
{row.original.bookingId.slice(0, 8)}…
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—",
|
||||
},
|
||||
{
|
||||
id: "warehouse",
|
||||
header: "Warehouse",
|
||||
cell: ({ row }) => row.original.warehouse?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "zone",
|
||||
header: "Zone",
|
||||
cell: ({ row }) => row.original.zone?.code ?? "—",
|
||||
},
|
||||
{
|
||||
id: "item",
|
||||
header: "Item",
|
||||
cell: ({ row }) => {
|
||||
const kind = itemKind(row.original);
|
||||
return (
|
||||
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||
{kind.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qty",
|
||||
header: "Qty",
|
||||
cell: ({ row }) => formatNumber(row.original.quantity),
|
||||
},
|
||||
{
|
||||
id: "weight",
|
||||
header: "Weight",
|
||||
cell: ({ row }) => formatNumber(row.original.weight),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<InventoryStatusBadge status={row.original.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "arrived",
|
||||
header: "Arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs">{formatDate(row.original.arrivedAt)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = INVENTORY_NEXT_ACTION[item.status];
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={actionColor[nextAction]}
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, "_"))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== "DISPATCHED" && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onMove(item)}
|
||||
>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
onClick={() => onFeePreview(item)}
|
||||
>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => onHistory(item)}
|
||||
>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[busyId, onAdvance, onMove, onHistory, onInspect, onFeePreview],
|
||||
);
|
||||
const selectable = Boolean(onToggleSelect);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No inventory items found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status="success"
|
||||
emptyMessage="No inventory items found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table highlightOnHover verticalSpacing="sm" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{selectable && (
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={onToggleSelectAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
)}
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Item</Table.Th>
|
||||
<Table.Th>Qty</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item) => {
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.id}>
|
||||
{selectable && (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${item.bookingId ?? item.id}`}
|
||||
checked={selectedIds?.has(item.id) ?? false}
|
||||
onChange={() => onToggleSelect?.(item.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
{item.bookingId ? (
|
||||
<Tooltip label={item.bookingId} withArrow>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingId.slice(0, 8)}...
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.yard?.code ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.zone?.code ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={kind.color} variant="light" size="sm" radius="md">
|
||||
{kind.label}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(item.arrivedAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{onView && (
|
||||
<Tooltip label="View details" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onView(item)}>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{nextAction && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={actionColor[nextAction]}
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'dispatch')}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>
|
||||
<ArrowRightLeft size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onInspect && (
|
||||
<Tooltip label="Inspection / Report" withArrow>
|
||||
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
|
||||
<ClipboardList size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFeePreview && (
|
||||
<Tooltip label="Storage / Demurrage preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => onFeePreview(item)}>
|
||||
<Coins size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onReleaseDocument && item.releaseDate && (
|
||||
<Tooltip label="View release exit paper" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => onReleaseDocument(item)}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||
<Tooltip label="Last mile delivery" withArrow>
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||
<MapPin size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ActionIcon, Group, Text } from '@mantine/core';
|
||||
import { Eye, Pencil } from 'lucide-react';
|
||||
import {
|
||||
Building2,
|
||||
Eye,
|
||||
MapPin,
|
||||
Package,
|
||||
Pencil,
|
||||
Scale,
|
||||
Warehouse as WarehouseIcon,
|
||||
} from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { bookingTable } from '@/components/bookings/booking-ui.styles';
|
||||
import { api } from '@/services/api';
|
||||
import type { Warehouse } from '@/types/warehouse';
|
||||
import { WarehouseStatusBadge, WarehouseTypeBadge } from './badges';
|
||||
@@ -16,6 +26,43 @@ interface WarehouseTableProps {
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
const HEADER = bookingTable.headerCell;
|
||||
|
||||
function CapacityCell({
|
||||
current,
|
||||
capacity,
|
||||
icon,
|
||||
}: {
|
||||
current?: number | null;
|
||||
capacity?: number | null;
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
const numericCurrent = Number(current) || 0;
|
||||
const numericCapacity = Number(capacity) || 0;
|
||||
const hasCapacity = numericCapacity > 0;
|
||||
const ratio = hasCapacity ? Math.min(100, Math.max(0, (numericCurrent / numericCapacity) * 100)) : 0;
|
||||
const isOverCapacity = hasCapacity && numericCurrent > numericCapacity;
|
||||
|
||||
return (
|
||||
<div className="min-w-[8rem] space-y-2 py-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-background/70 text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{formatCapacity(numericCurrent, capacity)}</span>
|
||||
</div>
|
||||
{hasCapacity ? (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||
<div
|
||||
className={isOverCapacity ? 'h-full rounded-full bg-red-500' : 'h-full rounded-full bg-edr-green'}
|
||||
style={{ width: `${ratio}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTableProps) {
|
||||
const { data: stations } = useQuery(
|
||||
api.stations.list.queryOptions({ staleTime: 5 * 60 * 1000 }),
|
||||
@@ -28,63 +75,98 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
const columns: ColumnDef<Warehouse>[] = [
|
||||
{
|
||||
id: 'code',
|
||||
header: 'Code',
|
||||
header: () => <span className={HEADER}>Warehouse</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
c="edr-green.7"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onView(row.original)}
|
||||
>
|
||||
{row.original.code}
|
||||
</Text>
|
||||
<div className="flex min-w-[11rem] items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<WarehouseIcon className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="block max-w-full truncate text-left text-sm font-semibold text-edr-green transition-colors hover:text-edr-green/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView(row.original);
|
||||
}}
|
||||
>
|
||||
{row.original.code}
|
||||
</button>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{row.original.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{
|
||||
id: 'facility',
|
||||
header: 'Facility',
|
||||
header: () => <span className={HEADER}>Facility</span>,
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.stationId
|
||||
? stationNameById.get(row.original.stationId)
|
||||
: undefined;
|
||||
return name ? (
|
||||
<Text size="sm" fw={500}>
|
||||
{name}
|
||||
</Text>
|
||||
<div className="flex min-w-[9rem] items-center gap-2 py-1 text-sm font-medium text-foreground">
|
||||
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
-
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => <WarehouseTypeBadge type={row.original.type} />,
|
||||
header: () => <span className={HEADER}>Type</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<WarehouseTypeBadge type={row.original.type} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => row.original.locationName ?? '—',
|
||||
header: () => <span className={HEADER}>Location</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-[10rem] items-center gap-2 py-1 text-sm text-foreground">
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{row.original.locationName ?? '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
header: 'Weight (cur / cap)',
|
||||
cell: ({ row }) => formatCapacity(row.original.currentWeight, row.original.capacityWeight),
|
||||
header: () => <span className={HEADER}>Weight</span>,
|
||||
cell: ({ row }) => (
|
||||
<CapacityCell
|
||||
current={row.original.currentWeight}
|
||||
capacity={row.original.capacityWeight}
|
||||
icon={<Scale className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'containers',
|
||||
header: 'Containers (cur / cap)',
|
||||
cell: ({ row }) =>
|
||||
formatCapacity(row.original.currentContainers, row.original.capacityContainers),
|
||||
header: () => <span className={HEADER}>Containers</span>,
|
||||
cell: ({ row }) => (
|
||||
<CapacityCell
|
||||
current={row.original.currentContainers}
|
||||
capacity={row.original.capacityContainers}
|
||||
icon={<Package className="size-3.5" />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <WarehouseStatusBadge status={row.original.status} />,
|
||||
header: () => <span className={HEADER}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="py-1">
|
||||
<WarehouseStatusBadge status={row.original.status} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -109,7 +191,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
|
||||
status="success"
|
||||
onRowClick={(warehouse) => onView(warehouse)}
|
||||
emptyMessage="No warehouses found."
|
||||
containerClassName="border-0 shadow-none"
|
||||
containerClassName="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
RECEIVED: "yellow",
|
||||
STORED: "blue",
|
||||
RESERVED: "grape",
|
||||
ARRIVED_AT_WAREHOUSE: "orange",
|
||||
UNDER_INSPECTION: "yellow",
|
||||
READY_FOR_LOADING: "cyan",
|
||||
LOADED: "teal",
|
||||
READY_FOR_PICKUP: "teal",
|
||||
|
||||
@@ -16,6 +16,8 @@ export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
export { InventoryMovementHistoryTable } from './InventoryMovementHistoryTable';
|
||||
export { ActivityTimeline } from './ActivityTimeline';
|
||||
export { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
export { InventoryDetailModal } from './InventoryDetailModal';
|
||||
export { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
export { InventoryWorkbench } from './InventoryWorkbench';
|
||||
export { BookingSelect } from './BookingSelect';
|
||||
export { WagonSelect } from './WagonSelect';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function openPdfBlob(blob: Blob, filename: string, targetWindow?: Window | null) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
if (targetWindow && !targetWindow.closed) {
|
||||
targetWindow.location.href = url;
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const opened = window.open(url, '_blank');
|
||||
if (opened) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return true;
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return false;
|
||||
}
|
||||
@@ -276,38 +276,43 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
WAREHOUSE_YARDS: {
|
||||
BASE: '/warehouse-yards',
|
||||
BY_ID: (id: string) => `/warehouse-yards/${id}`,
|
||||
ZONES: (yardId: string) => `/warehouse-yards/${yardId}/zones`,
|
||||
},
|
||||
|
||||
WAREHOUSE_ZONES: {
|
||||
BASE: '/warehouse-zones',
|
||||
BY_ID: (id: string) => `/warehouse-zones/${id}`,
|
||||
},
|
||||
|
||||
WAREHOUSE_INVENTORY: {
|
||||
BASE: '/warehouse-inventory',
|
||||
RECEIVE: '/warehouse-inventory/receive',
|
||||
DASHBOARD_SUMMARY: '/warehouse-inventory/dashboard/summary',
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
INSPECT: (id: string) => `/warehouse-inventory/${id}/inspect`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
RESERVE: '/warehouse-inventory/reserve',
|
||||
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
|
||||
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
|
||||
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
|
||||
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
|
||||
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
|
||||
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
|
||||
INQUIRY: '/warehouse-inventory/inquiry',
|
||||
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
|
||||
BOOKING_SCHEDULE: (bookingId: string) => `/warehouse-inventory/booking/${bookingId}/schedule`,
|
||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||
MOVEMENTS: (id: string) => `/warehouse-inventory/${id}/movements`,
|
||||
ACTIVITY: (id: string) => `/warehouse-inventory/${id}/activity`,
|
||||
LOADINGS: (id: string) => `/warehouse-inventory/${id}/loadings`,
|
||||
STORE: (id: string) => `/warehouse-inventory/${id}/store`,
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
// Import branch
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const HealthCheck = () => {
|
||||
const url1 = import.meta.env.VITE_API_URL?? "undefined";
|
||||
const url2 = import.meta.env.VITE_BASE_API_URL?? "undefined";
|
||||
const url3 = import.meta.env.VITE_USER_MANAGEMENT_BASE?? "undefined";
|
||||
|
||||
return <div>
|
||||
<h2>-----------{url1}</h2>
|
||||
<h2>-----------{url2}</h2>
|
||||
<h2>-----------{url3}</h2>
|
||||
</div>
|
||||
}
|
||||
538
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
538
apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
InspectionReportPayload,
|
||||
SaveAllocationRulePayload,
|
||||
SaveFeeRulePayload,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
BulkReceivePayload,
|
||||
BulkInspectPayload,
|
||||
ReserveInventoryPayload,
|
||||
SaveWarehousePayload,
|
||||
SaveYardPayload,
|
||||
SaveZonePayload,
|
||||
WarehouseFilter,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export const warehouseKeys = {
|
||||
all: ['warehouses'] as const,
|
||||
list: (filter?: WarehouseFilter) => ['warehouses', 'list', filter ?? {}] as const,
|
||||
facilities: () => ['warehouses', 'facilities'] as const,
|
||||
detail: (id: string) => ['warehouses', 'detail', id] as const,
|
||||
yards: (warehouseId: string) => ['warehouses', warehouseId, 'yards'] as const,
|
||||
allYards: () => ['warehouse-yards', 'all'] as const,
|
||||
zones: (yardId: string) => ['warehouse-yards', yardId, 'zones'] as const,
|
||||
allZones: () => ['warehouse-zones', 'all'] as const,
|
||||
inventory: (filter?: InventoryFilter) => ['warehouse-inventory', 'list', filter ?? {}] as const,
|
||||
dashboardSummary: (filter?: InventoryFilter) => ['warehouse-dashboard', 'summary', filter ?? {}] as const,
|
||||
inquiry: (filter: InventoryInquiryFilter) => ['warehouse-inventory', 'inquiry', filter] as const,
|
||||
};
|
||||
|
||||
// ── Warehouses ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouses(filter?: WarehouseFilter) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.list(filter),
|
||||
queryFn: () => warehouseService.list(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouse(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.detail(id ?? ''),
|
||||
queryFn: () => warehouseService.getById(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseFacilities() {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.facilities(),
|
||||
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateWarehouse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: SaveWarehousePayload) => warehouseService.create(payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateWarehouse() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveWarehousePayload> }) =>
|
||||
warehouseService.update(id, payload),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseYards(warehouseId?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.yards(warehouseId ?? ''),
|
||||
queryFn: () => warehouseService.listYards(warehouseId as string).then((r) => r.data),
|
||||
enabled: Boolean(warehouseId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllWarehouseYards() {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.allYards(),
|
||||
queryFn: () => warehouseService.listAllYards().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateYard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ warehouseId, payload }: { warehouseId: string; payload: SaveYardPayload }) =>
|
||||
warehouseService.createYard(warehouseId, payload),
|
||||
onSuccess: (_, { warehouseId }) => {
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.yards(warehouseId) });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.detail(warehouseId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateYard() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveYardPayload> }) =>
|
||||
warehouseService.updateYard(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: warehouseKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseZones(yardId?: string) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.zones(yardId ?? ''),
|
||||
queryFn: () => warehouseService.listZones(yardId as string).then((r) => r.data),
|
||||
enabled: Boolean(yardId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllWarehouseZones() {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.allZones(),
|
||||
queryFn: () => warehouseService.listAllZones().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ yardId, payload }: { yardId: string; payload: SaveZonePayload }) =>
|
||||
warehouseService.createZone(yardId, payload),
|
||||
onSuccess: (_, { yardId }) => qc.invalidateQueries({ queryKey: warehouseKeys.zones(yardId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateZone() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: Partial<SaveZonePayload> }) =>
|
||||
warehouseService.updateZone(id, payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['warehouse-yards'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Inventory ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWarehouseInventory(filter?: InventoryFilter) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.inventory(filter),
|
||||
queryFn: () => warehouseService.listInventory(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseDashboardSummary(filter?: InventoryFilter) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.dashboardSummary(filter),
|
||||
queryFn: () => warehouseService.getDashboardSummary(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReceiveInventory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: ReceiveInventoryPayload) => warehouseService.receiveInventory(payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-dashboard'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useInventoryMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const useStoreInventory = () => useInventoryMutation((id: string) => warehouseService.store(id));
|
||||
export const useReserveInventory = () =>
|
||||
useInventoryMutation((payload: ReserveInventoryPayload) => warehouseService.reserve(payload));
|
||||
export const useMarkReadyForLoading = () =>
|
||||
useInventoryMutation((id: string) => warehouseService.markReadyForLoading(id));
|
||||
export const useLoadInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: LoadInventoryPayload }) =>
|
||||
warehouseService.load(args.id, args.payload),
|
||||
);
|
||||
export const useDispatchInventory = () => useInventoryMutation((id: string) => warehouseService.dispatch(id));
|
||||
export const useMoveInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: MoveInventoryPayload }) =>
|
||||
warehouseService.move(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
|
||||
export const useMarkReadyForPickup = () =>
|
||||
useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id));
|
||||
export const useReleaseInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) =>
|
||||
warehouseService.release(args.id, args.payload),
|
||||
);
|
||||
export const useDeliverInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) =>
|
||||
warehouseService.deliver(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
|
||||
/**
|
||||
* All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call.
|
||||
* Both Receive tabs share this single query (same key) — only one HTTP request fires —
|
||||
* then filter client-side by direction.
|
||||
*/
|
||||
export function useEligibleBookings(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'eligible-bookings'],
|
||||
queryFn: () => warehouseService.eligibleBookings().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
export const useBulkReceive = () =>
|
||||
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
||||
export const useLoadPassedExport = () =>
|
||||
useInventoryMutation(() => warehouseService.loadPassedExport());
|
||||
export const useBulkMarkInspected = () =>
|
||||
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
||||
|
||||
export function useReadyToLoadExport(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ready-to-load-export'],
|
||||
queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLoadedExport(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loaded-export'],
|
||||
queryFn: () => warehouseService.loadedExport().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export const useBulkDispatchExport = () =>
|
||||
useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds));
|
||||
|
||||
/** Arrived IMPORT trains (route-derived). Read-only. */
|
||||
export function useImportArriveQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-arrive-queue'],
|
||||
queryFn: () => warehouseService.importArriveQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train. Read-only. */
|
||||
export function useImportTrainItems(scheduleId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-train-items', scheduleId],
|
||||
queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||
export const useAutoUnloadArrivedBookings = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||
|
||||
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
|
||||
export function useImportUnloadedQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
|
||||
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
|
||||
export function useImportPickupReadyQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoadableWagons(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'loadable-wagons'],
|
||||
queryFn: () => warehouseService.loadableWagons().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseLoadings(params?: { bookingId?: string; wagonId?: string }) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-loadings', params ?? {}],
|
||||
queryFn: () => warehouseService.loadings(params).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingSchedule(bookingId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse', 'booking-schedule', bookingId ?? ''],
|
||||
queryFn: () => warehouseService.bookingSchedule(bookingId as string).then((r) => r.data),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryMovements(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'movements'],
|
||||
queryFn: () => warehouseService.movements(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryActivity(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', id, 'activity'],
|
||||
queryFn: () => warehouseService.activity(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseDashboard() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouses', 'dashboard'],
|
||||
queryFn: () => warehouseService.dashboard().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: warehouseKeys.inquiry(filter),
|
||||
queryFn: () => warehouseService.inquiry(filter).then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
|
||||
|
||||
export function useArrivalQueue() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'arrival-queue'],
|
||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useArrivalInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
qc.invalidateQueries({ queryKey: warehouseKeys.all });
|
||||
};
|
||||
}
|
||||
|
||||
export function useAutoUnloadArrived() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({ mutationFn: () => warehouseService.autoUnloadArrived(), onSuccess });
|
||||
}
|
||||
|
||||
export function useAutoLoadReady() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({ mutationFn: () => warehouseService.autoLoadReady(), onSuccess });
|
||||
}
|
||||
|
||||
export function useUnloadBooking() {
|
||||
const onSuccess = useArrivalInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: (args: { bookingId: string; payload?: Record<string, unknown> }) =>
|
||||
warehouseService.unloadBooking(args.bookingId, args.payload),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInspectionReports(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
|
||||
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInspectionReport() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
|
||||
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
|
||||
onSuccess: (_, { inventoryId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadInspectionAttachments() {
|
||||
return useMutation({
|
||||
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
|
||||
warehouseService.uploadInspectionAttachments(reportId, files),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 5: Allocation + Fee rules / preview ───────────────────────────────
|
||||
|
||||
export function useAllocationRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-allocation-rules'],
|
||||
queryFn: () => warehouseService.listAllocationRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFeeRules() {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-rules'],
|
||||
queryFn: () => warehouseService.listFeeRules().then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
function useRuleMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>, keys: string[]) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: () => keys.forEach((k) => qc.invalidateQueries({ queryKey: [k] })),
|
||||
});
|
||||
}
|
||||
|
||||
export const useCreateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(payload: SaveAllocationRulePayload) => warehouseService.createAllocationRule(payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useUpdateAllocationRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveAllocationRulePayload> }) =>
|
||||
warehouseService.updateAllocationRule(args.id, args.payload),
|
||||
['warehouse-allocation-rules'],
|
||||
);
|
||||
export const useDeleteAllocationRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteAllocationRule(id), ['warehouse-allocation-rules']);
|
||||
|
||||
export const useCreateFeeRule = () =>
|
||||
useRuleMutation((payload: SaveFeeRulePayload) => warehouseService.createFeeRule(payload), ['warehouse-fee-rules']);
|
||||
export const useUpdateFeeRule = () =>
|
||||
useRuleMutation(
|
||||
(args: { id: string; payload: Partial<SaveFeeRulePayload> }) =>
|
||||
warehouseService.updateFeeRule(args.id, args.payload),
|
||||
['warehouse-fee-rules'],
|
||||
);
|
||||
export const useDeleteFeeRule = () =>
|
||||
useRuleMutation((id: string) => warehouseService.deleteFeeRule(id), ['warehouse-fee-rules']);
|
||||
|
||||
export function useFeePreview(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-preview'],
|
||||
queryFn: () => warehouseService.feePreview(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch 6: Warehouse fee invoices ─────────────────────────────────────────
|
||||
|
||||
export function useWarehouseInvoices(filter?: WarehouseInvoiceFilter) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-invoices', filter ?? {}],
|
||||
queryFn: () => warehouseService.listInvoices(filter).then((r) => r.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWarehouseInvoice(id?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-fee-invoices', 'detail', id],
|
||||
queryFn: () => warehouseService.getInvoice(id as string).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoicesForInventory(inventoryId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', inventoryId, 'fee-invoices'],
|
||||
queryFn: () => warehouseService.invoicesForInventory(inventoryId as string).then((r) => r.data),
|
||||
enabled: Boolean(inventoryId),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvoiceInvalidation() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-fee-invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
};
|
||||
}
|
||||
|
||||
export function useGenerateInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: ({ inventoryId, confirmZero }: { inventoryId: string; confirmZero?: boolean }) =>
|
||||
warehouseService.generateInvoice(inventoryId, confirmZero).then((r) => r.data),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({ mutationFn: (id: string) => warehouseService.cancelInvoice(id), onSuccess });
|
||||
}
|
||||
|
||||
export function usePayInvoice() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: PayInvoicePayload }) =>
|
||||
warehouseService.payInvoice(id, payload),
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGateClearance() {
|
||||
const onSuccess = useInvoiceInvalidation();
|
||||
return useMutation({ mutationFn: (inventoryId: string) => warehouseService.gateClearance(inventoryId), onSuccess });
|
||||
}
|
||||
@@ -103,9 +103,8 @@ export default function BookingRequestsPage() {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
|
||||
assignedToSchedule: "false",
|
||||
sortBy: "isGovernment",
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -32,7 +35,7 @@ import {
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company } from "@/types/customer";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -45,14 +48,17 @@ export default function CustomersPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
|
||||
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
@@ -107,7 +113,25 @@ export default function CustomersPage() {
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <CompanyStatusBadge status={row.original.status} />,
|
||||
cell: ({ row }) => {
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<CompanyStatusBadge status={row.original.status} />
|
||||
{pending > 0 ? (
|
||||
<Tooltip
|
||||
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
||||
>
|
||||
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
||||
{pending} pending
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
@@ -216,6 +240,20 @@ export default function CustomersPage() {
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -360,7 +360,7 @@ const FleetResourcePage = () => {
|
||||
{config.subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => {
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
@@ -391,7 +391,7 @@ const FleetResourcePage = () => {
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={filter.value === option.value ? "filled" : "outline"}
|
||||
color="green"
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
@@ -417,7 +417,7 @@ const FleetResourcePage = () => {
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={statusFilter === option.value ? "filled" : "outline"}
|
||||
color="green"
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setStatusFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -47,7 +47,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
],
|
||||
searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 },
|
||||
{ id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 },
|
||||
{ id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 },
|
||||
{ id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 },
|
||||
{ id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 },
|
||||
{ id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 },
|
||||
{ id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 },
|
||||
@@ -59,7 +62,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
],
|
||||
formFields: [
|
||||
{ name: "plateNumber", label: "Plate Number", type: "text", required: true },
|
||||
{ name: "code", label: "Code", type: "text" },
|
||||
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
|
||||
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
|
||||
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
|
||||
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
|
||||
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
|
||||
{ name: "model", label: "Model", type: "text", required: true },
|
||||
@@ -70,7 +76,10 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
],
|
||||
emptyValues: {
|
||||
code: "",
|
||||
plateNumber: "",
|
||||
powerPlateNo: "",
|
||||
trailerPlateNo: "",
|
||||
vehicleType: "TRUCK",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Printer,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -73,7 +75,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
const vehicleLabel = (record: FirstMileRecord) => {
|
||||
if (!record.vehicle) return null;
|
||||
const v = record.vehicle;
|
||||
return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
|
||||
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||
if (v.code) parts.unshift(v.code);
|
||||
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||
if (plates) parts.push(plates);
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
|
||||
@@ -89,8 +95,9 @@ const cargoDesc = (r: FirstMileRecord) => {
|
||||
};
|
||||
const priceAmount = (r: FirstMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.destinationYard?.name ?? "—";
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
const contactPersonName = (r: FirstMileRecord) =>
|
||||
r.booking?.company?.contactPersonName ?? "—";
|
||||
const contactPhone = (r: FirstMileRecord) =>
|
||||
@@ -100,7 +107,7 @@ const requestedDate = (r: FirstMileRecord) => {
|
||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||
};
|
||||
const serviceTypeName = (r: FirstMileRecord) =>
|
||||
r.booking?.serviceType?.name ?? "—";
|
||||
r.booking?.serviceType?.label ?? "—";
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
@@ -133,7 +140,7 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||
<InfoRow label="Destination yard" value={destinationYardName(record)} />
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
@@ -343,10 +350,13 @@ const FirstMilePage = () => {
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
||||
})),
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||
if (v.code) parts.unshift(v.code);
|
||||
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||
if (plates) parts.push(plates);
|
||||
return { value: v.id, label: parts.join(" · ") };
|
||||
}),
|
||||
[vehiclesData],
|
||||
);
|
||||
|
||||
@@ -585,6 +595,12 @@ const FirstMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => pickupLocation(row.original),
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
header: "Destination",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => destinationYardName(row.original),
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
header: "Cargo",
|
||||
@@ -701,11 +717,11 @@ const FirstMilePage = () => {
|
||||
/>
|
||||
<Group gap="sm">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign vehicle ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button leftSection={<Truck size={16} />} onClick={openAccept}>
|
||||
<Button leftSection={<Truck size={16} />} onClick={openAccept} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -718,6 +734,7 @@ const FirstMilePage = () => {
|
||||
key={option.value}
|
||||
size="xs"
|
||||
variant={active ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setStatusFilter(option.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
@@ -865,33 +882,54 @@ const FirstMilePage = () => {
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
|
||||
) : (
|
||||
filteredPaidBookings.map((b) => (
|
||||
<Card
|
||||
<UnstyledButton
|
||||
key={b.id}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
w="100%"
|
||||
onClick={() => {
|
||||
setSelectedBooking(b);
|
||||
setAcceptStep(2);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
padding: "10px 12px",
|
||||
backgroundColor: "var(--mantine-color-white)",
|
||||
transition: "background-color 120ms ease, border-color 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
||||
"var(--mantine-color-blue-0)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"var(--mantine-color-blue-4)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
|
||||
"var(--mantine-color-white)";
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor =
|
||||
"var(--mantine-color-gray-3)";
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">{b.reference}</Text>
|
||||
<Text size="xs" c="dimmed">{b.company?.name ?? b.company?.companyName ?? "—"}</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.originYard?.name ?? "—"} → {b.destinationYard?.name ?? "—"}
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Stack gap={3} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="dark">{b.reference}</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{b.company?.name ?? b.company?.companyName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={3} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.originYard?.label ?? "—"} → {b.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="blue">
|
||||
{formatPrice(b.totalAmount)}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>{formatPrice(b.totalAmount)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.scheduledDate ? b.scheduledDate.slice(0, 10) : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ChevronRight size={16} color="var(--mantine-color-gray-5)" />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
@@ -912,8 +950,8 @@ const FirstMilePage = () => {
|
||||
<InfoRow label="Customer" value={selectedBooking.company?.name ?? selectedBooking.company?.companyName ?? "—"} />
|
||||
<InfoRow label="Service type" value={selectedBooking.serviceType?.name ?? "—"} />
|
||||
<InfoRow label="Pickup address" value={selectedBooking.firstMilePickupAddress ?? "—"} />
|
||||
<InfoRow label="Origin yard" value={selectedBooking.originYard?.name ?? "—"} />
|
||||
<InfoRow label="Destination yard" value={selectedBooking.destinationYard?.name ?? "—"} />
|
||||
<InfoRow label="Destination (origin yard)" value={selectedBooking.originYard?.label ?? "—"} />
|
||||
<InfoRow label="Train destination yard" value={selectedBooking.destinationYard?.label ?? "—"} />
|
||||
<InfoRow label="Cargo type" value={selectedBooking.cargoType?.name ?? "—"} />
|
||||
<InfoRow label="Weight (VGM)" value={`${selectedBooking.cargoTotalWeightVgm} t`} />
|
||||
<InfoRow label="Total amount" value={formatPrice(selectedBooking.totalAmount)} />
|
||||
|
||||
@@ -71,7 +71,11 @@ const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
const vehicleLabel = (record: LastMileRecord) => {
|
||||
if (!record.vehicle) return null;
|
||||
const v = record.vehicle;
|
||||
return `${v.manufacturer} ${v.model} (${v.plateNumber})`;
|
||||
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||
if (v.code) parts.unshift(v.code);
|
||||
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||
if (plates) parts.push(plates);
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId);
|
||||
@@ -313,10 +317,13 @@ const LastMilePage = () => {
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.manufacturer} ${v.model} (${v.plateNumber})`,
|
||||
})),
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
|
||||
if (v.code) parts.unshift(v.code);
|
||||
const plates = [v.powerPlateNo, v.trailerPlateNo].filter(Boolean).join(" / ");
|
||||
if (plates) parts.push(plates);
|
||||
return { value: v.id, label: parts.join(" · ") };
|
||||
}),
|
||||
[vehiclesData],
|
||||
);
|
||||
|
||||
@@ -622,11 +629,11 @@ const LastMilePage = () => {
|
||||
/>
|
||||
<Group gap="sm">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign}>
|
||||
<Button variant="light" leftSection={<Truck size={16} />} onClick={openBulkAssign} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign vehicle ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)}>
|
||||
<Button leftSection={<Truck size={16} />} onClick={() => openAssign(null)} styles={{ label: { fontWeight: 500 } }}>
|
||||
Assign Mile
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -639,6 +646,7 @@ const LastMilePage = () => {
|
||||
key={option.value}
|
||||
size="xs"
|
||||
variant={active ? "filled" : "default"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setStatusFilter(option.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
|
||||
@@ -1,197 +1,271 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Fragment, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ClipboardList, Eye, PackageOpen, Truck } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { PageHeader } from '@/components/page';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import {
|
||||
InspectionReportModal,
|
||||
VisualEmptyState,
|
||||
WarehouseHero,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
} from '@/components/warehouses';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { ArrivalQueueItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
|
||||
function inspectionBadge(status: string | null) {
|
||||
if (!status) return <Badge variant="light" color="gray" size="sm">Not inspected</Badge>;
|
||||
const color = status === 'PASSED' ? 'edr-green' : status === 'FAILED' ? 'red' : 'orange';
|
||||
return <Badge variant="light" color={color} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
}
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: unknown } } }).response;
|
||||
const message = response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
/** Batch 4.5 — arrived bookings awaiting unload / inspection. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(api.warehouses.arrivalQueue.queryOptions());
|
||||
const autoUnload = useMutation(api.warehouses.autoUnloadArrived.mutationOptions());
|
||||
const unloadOne = useMutation(api.warehouses.unloadBooking.mutationOptions());
|
||||
const [inspectInventoryId, setInspectInventoryId] = useState<string | null>(null);
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
|
||||
const items = data ?? [];
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAutoUnload = async () => {
|
||||
try {
|
||||
const r = await autoUnload.mutateAsync();
|
||||
toast({
|
||||
title: 'Auto-unload complete',
|
||||
description: `Processed ${r.processedCount}, skipped ${r.skippedCount}, failed ${r.failedCount}.`,
|
||||
});
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Auto-unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadOne = async (item: ArrivalQueueItem) => {
|
||||
try {
|
||||
await unloadOne.mutateAsync({ bookingId: item.bookingId });
|
||||
toast({ title: 'Booking unloaded', description: `${item.bookingReference} stored as RECEIVED.` });
|
||||
} catch {
|
||||
toast({ variant: 'destructive', title: 'Unload failed' });
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ArrivalQueueItem>[] = [
|
||||
{
|
||||
id: 'booking',
|
||||
header: 'Booking',
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.bookingReference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ id: 'customer', header: 'Customer', cell: ({ row }) => row.original.customer ?? '—' },
|
||||
{
|
||||
id: 'cargo',
|
||||
header: 'Cargo / Container',
|
||||
cell: ({ row }) => row.original.container ?? row.original.cargo ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'arrival',
|
||||
header: 'Arrival',
|
||||
cell: ({ row }) => <Text size="xs">{formatDate(row.original.arrivalDate)}</Text>,
|
||||
},
|
||||
{ id: 'facility', header: 'Facility', cell: ({ row }) => row.original.facility ?? '—' },
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse ?? '—' },
|
||||
{ id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone ?? '—' },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) =>
|
||||
row.original.unloaded ? (
|
||||
<Badge variant="light" color="edr-green" size="sm">
|
||||
{row.original.currentStatus ?? 'RECEIVED'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light" color="orange" size="sm">
|
||||
Not unloaded
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'inspection',
|
||||
header: 'Inspection',
|
||||
cell: ({ row }) => inspectionBadge(row.original.inspectionStatus),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
{!item.unloaded && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={unloadOne.isPending}
|
||||
onClick={() => handleUnloadOne(item)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<ClipboardList size={14} />}
|
||||
onClick={() => setInspectInventoryId(item.inventoryId)}
|
||||
>
|
||||
Inspect
|
||||
</Button>
|
||||
)}
|
||||
{item.inventoryId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Eye size={14} />}
|
||||
onClick={() => navigate('/dashboard/warehouse-inventory')}
|
||||
>
|
||||
Inventory
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No assigned bookings found for this train.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived bookings ready to unload, store and inspect."
|
||||
action={
|
||||
<Button
|
||||
leftSection={<PackageOpen size={16} />}
|
||||
loading={autoUnload.isPending}
|
||||
onClick={handleAutoUnload}
|
||||
>
|
||||
Auto Unload Arrived Bookings
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Text fw={600} mb="md">
|
||||
{items.length} arrived booking(s)
|
||||
</Text>
|
||||
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived bookings"
|
||||
description="Bookings in transit that arrive appear here for unloading and inspection."
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<InspectionReportModal
|
||||
opened={Boolean(inspectInventoryId)}
|
||||
onClose={() => setInspectInventoryId(null)}
|
||||
inventoryId={inspectInventoryId}
|
||||
/>
|
||||
</PageContainer>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingReference ?? item.bookingId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customerName ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
|
||||
size="sm"
|
||||
>
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
/** Arrived import trains awaiting unload into warehouse inventory. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Auto unload failed',
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="xxl" py="lg">
|
||||
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
|
||||
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
/>
|
||||
|
||||
<WarehouseHero
|
||||
variant="container"
|
||||
secondaryVariant="warehouse"
|
||||
title="Arrival / Unloading Queue"
|
||||
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : trains.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived import trains"
|
||||
description="Import trains appear here once their train schedule status is ARRIVED."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th ta="center">Bookings</Table.Th>
|
||||
<Table.Th ta="center">Containers</Table.Th>
|
||||
<Table.Th ta="center">Cargoes</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ImportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{train.trainNumber ?? '-'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{train.scheduleId.slice(0, 8)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{train.route ?? '-'}</Table.Td>
|
||||
<Table.Td>{train.origin ?? '-'}</Table.Td>
|
||||
<Table.Td>{train.destination ?? '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">{train.totalBookings}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={
|
||||
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||
}
|
||||
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
leftSection={
|
||||
busyScheduleId === train.scheduleId ? (
|
||||
<PackageOpen size={14} />
|
||||
) : (
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
Auto Unload
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,35 +3,30 @@ import { Button, Card, Center, Group, Loader, Select, Stack, TextInput } from '@
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { VisualEmptyState, WarehouseInquiryTable, inventoryStatusOptions } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import type { InventoryInquiryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
import {
|
||||
InventoryInquiryDetailModal,
|
||||
VisualEmptyState,
|
||||
WarehouseInquiryTable,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useInventoryInquiry,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function InventoryInquiryPage() {
|
||||
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId: draft.warehouseId ?? '' },
|
||||
enabled: Boolean(draft.warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId: draft.yardId ?? '' },
|
||||
enabled: Boolean(draft.yardId),
|
||||
}),
|
||||
);
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useAllWarehouseYards();
|
||||
const zonesQuery = useAllWarehouseZones();
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
api.warehouses.inquiry.queryOptions({ input: { filter: applied } }),
|
||||
);
|
||||
const { data, isFetching } = useInventoryInquiry(applied);
|
||||
const results = data ?? [];
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
@@ -39,15 +34,39 @@ export default function InventoryInquiryPage() {
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
() =>
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[draft.warehouseId, yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
() => {
|
||||
const visibleYardIds = new Set(
|
||||
(yardsQuery.data ?? [])
|
||||
.filter((y) => !draft.warehouseId || y.warehouseId === draft.warehouseId)
|
||||
.map((y) => y.id),
|
||||
);
|
||||
return (zonesQuery.data ?? [])
|
||||
.filter((z) => {
|
||||
if (draft.yardId) return z.yardId === draft.yardId;
|
||||
if (draft.warehouseId) return visibleYardIds.has(z.yardId);
|
||||
return true;
|
||||
})
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
},
|
||||
[draft.warehouseId, draft.yardId, yardsQuery.data, zonesQuery.data],
|
||||
);
|
||||
|
||||
const runSearch = () => setApplied(draft);
|
||||
const normalizeDraft = (filter: InventoryInquiryFilter): InventoryInquiryFilter => ({
|
||||
...filter,
|
||||
bookingReference: filter.bookingReference?.trim() || undefined,
|
||||
containerNumber: filter.containerNumber?.trim() || undefined,
|
||||
cargoType: filter.cargoType?.trim() || undefined,
|
||||
goodsName: filter.goodsName?.trim() || undefined,
|
||||
});
|
||||
|
||||
const runSearch = () => setApplied(normalizeDraft(draft));
|
||||
const reset = () => {
|
||||
setDraft({});
|
||||
setApplied({});
|
||||
@@ -60,101 +79,109 @@ export default function InventoryInquiryPage() {
|
||||
subtitle="Locate any cargo, container or goods inside the warehouse network."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking number"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.warehouseId}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
disabled={!draft.yardId}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
label="Booking reference"
|
||||
placeholder="e.g. BKG-00123"
|
||||
value={draft.bookingReference ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, bookingReference: v || undefined })); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') runSearch();
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container number"
|
||||
placeholder="e.g. MSKU1234567"
|
||||
value={draft.containerNumber ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, containerNumber: v || undefined })); }}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Goods name"
|
||||
placeholder="e.g. Coffee"
|
||||
value={draft.goodsName ?? ''}
|
||||
onChange={(e) => { const v = e.currentTarget.value; setDraft((f) => ({ ...f, goodsName: v || undefined })); }}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setDraft((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
searchable
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, zoneId: value ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={draft.status ?? null}
|
||||
onChange={(value) => setDraft((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
w={180}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group>
|
||||
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||
Search
|
||||
</Button>
|
||||
<Button variant="default" onClick={reset}>
|
||||
Reset
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} />
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
{isFetching ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : results.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No items found"
|
||||
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
|
||||
/>
|
||||
) : (
|
||||
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
<InventoryInquiryDetailModal
|
||||
opened={Boolean(viewResult)}
|
||||
onClose={() => setViewResult(null)}
|
||||
result={viewResult}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { WarehouseDashboardCharts } from '@/components/warehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { WarehouseDashboardCharts, WarehouseHero } from '@/components/warehouses';
|
||||
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseDashboard } from '@/types/warehouse';
|
||||
|
||||
interface Metric {
|
||||
@@ -31,8 +29,8 @@ interface Metric {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#22c55e';
|
||||
const ORANGE = 'rgb(241, 147, 23)';
|
||||
const GREEN = '#084b21';
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
@@ -51,7 +49,7 @@ const METRICS: Metric[] = [
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useQuery(api.warehouses.dashboard.queryOptions());
|
||||
const { data, isError, isLoading } = useWarehouseDashboard();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -60,45 +58,58 @@ export default function WarehouseDashboardPage() {
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Stack gap="lg" mt="sm">
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="warehouse"
|
||||
title="Warehouse Dashboard"
|
||||
subtitle="Live overview of warehouse capacity and inventory lifecycle."
|
||||
/>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center py="xl">
|
||||
<Text c="red">Failed to load warehouse dashboard.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="md">
|
||||
{METRICS.map((metric) => (
|
||||
<Card
|
||||
key={metric.key}
|
||||
padding="lg"
|
||||
onClick={() => navigate(metric.to)}
|
||||
className="cursor-pointer transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
|
||||
{data ? data[metric.key] : 0}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
size={46}
|
||||
radius="md"
|
||||
style={{ backgroundColor: `${metric.theme}1a`, color: metric.theme }}
|
||||
>
|
||||
{metric.icon}
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<WarehouseDashboardCharts data={data} />
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,15 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -27,8 +26,6 @@ import {
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
@@ -51,7 +48,6 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [editingYard, setEditingYard] = useState<WarehouseYard | null>(null);
|
||||
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
@@ -169,14 +165,18 @@ export default function WarehouseDetailPage() {
|
||||
|
||||
if (!warehouse) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<PageContainer>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<Text fw={700}>Warehouse not found</Text>
|
||||
<Button variant="default" leftSection={<ArrowLeft size={16} />} onClick={() => navigate('/dashboard/warehouses')}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate('/dashboard/warehouses')}
|
||||
>
|
||||
Back to warehouses
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,9 +189,7 @@ export default function WarehouseDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/warehouses"
|
||||
title={warehouse.name}
|
||||
subtitle={`${warehouse.code}${
|
||||
warehouse.locationName ? ` · ${warehouse.locationName}` : ''
|
||||
}`}
|
||||
subtitle={`${warehouse.code}${warehouse.locationName ? ` - ${warehouse.locationName}` : ''}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<WarehouseTypeBadge type={warehouse.type} />
|
||||
@@ -216,7 +214,6 @@ export default function WarehouseDetailPage() {
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<KpiStrip
|
||||
items={[
|
||||
@@ -237,113 +234,105 @@ export default function WarehouseDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* YARDS */}
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
<Tabs.Panel value="yards" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Yards</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => {
|
||||
setEditingYard(null);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Yard
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<DataTable
|
||||
columns={yardColumns}
|
||||
data={yards}
|
||||
status={
|
||||
yardsQuery.isLoading ? 'loading' : yardsQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
emptyMessage="No yards yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
yardsQuery.isError
|
||||
? {
|
||||
message: 'Failed to load yards.',
|
||||
onRetry: () => void yardsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={yardColumns}
|
||||
data={yards}
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
status={
|
||||
yardsQuery.isLoading
|
||||
? 'loading'
|
||||
: yardsQuery.isError
|
||||
? 'error'
|
||||
: 'success'
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
emptyMessage="No yards yet."
|
||||
emptyMessage="No zones in this yard yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
yardsQuery.isError
|
||||
zonesQuery.isError
|
||||
? {
|
||||
message: 'Failed to load yards.',
|
||||
onRetry: () => void yardsQuery.refetch(),
|
||||
message: 'Failed to load zones.',
|
||||
onRetry: () => void zonesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* ZONES */}
|
||||
<Tabs.Panel value="zones" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Select a yard"
|
||||
data={yardOptions}
|
||||
value={selectedYardId}
|
||||
onChange={setSelectedYardId}
|
||||
w={280}
|
||||
searchable
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selectedYardId}
|
||||
onClick={() => {
|
||||
setEditingZone(null);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Zone
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
Select a yard to view its zones.
|
||||
</Text>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
status={
|
||||
zonesQuery.isLoading
|
||||
? 'loading'
|
||||
: zonesQuery.isError
|
||||
? 'error'
|
||||
: 'success'
|
||||
}
|
||||
emptyMessage="No zones in this yard yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
error={
|
||||
zonesQuery.isError
|
||||
? {
|
||||
message: 'Failed to load zones.',
|
||||
onRetry: () => void zonesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVENTORY */}
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="inventory" pt="lg">
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<InventoryWorkbench
|
||||
items={inventoryQuery.data ?? []}
|
||||
isLoading={inventoryQuery.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{id && (
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
ReceiveInventoryModal,
|
||||
inventoryStatusOptions,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
useWarehouseInventory,
|
||||
useWarehouseYards,
|
||||
useWarehouseZones,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseInventoryPage() {
|
||||
@@ -30,24 +33,10 @@ export default function WarehouseInventoryPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: {} }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId: filter.warehouseId ?? '' },
|
||||
enabled: Boolean(filter.warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId: filter.yardId ?? '' },
|
||||
enabled: Boolean(filter.yardId),
|
||||
}),
|
||||
);
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: queryFilter } }),
|
||||
);
|
||||
const warehousesQuery = useWarehouses();
|
||||
const yardsQuery = useWarehouseYards(filter.warehouseId);
|
||||
const zonesQuery = useWarehouseZones(filter.yardId);
|
||||
const inventoryQuery = useWarehouseInventory(queryFilter);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
@@ -91,7 +80,12 @@ export default function WarehouseInventoryPage() {
|
||||
data={warehouseOptions}
|
||||
value={filter.warehouseId ?? null}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, warehouseId: value ?? undefined, yardId: undefined, zoneId: undefined }))
|
||||
setFilter((f) => ({
|
||||
...f,
|
||||
warehouseId: value ?? undefined,
|
||||
yardId: undefined,
|
||||
zoneId: undefined,
|
||||
}))
|
||||
}
|
||||
w={220}
|
||||
/>
|
||||
@@ -102,7 +96,9 @@ export default function WarehouseInventoryPage() {
|
||||
disabled={!filter.warehouseId}
|
||||
data={yardOptions}
|
||||
value={filter.yardId ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, yardId: value ?? undefined, zoneId: undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
@@ -120,7 +116,9 @@ export default function WarehouseInventoryPage() {
|
||||
clearable
|
||||
data={inventoryStatusOptions}
|
||||
value={filter.status ?? null}
|
||||
onChange={(value) => setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))}
|
||||
onChange={(value) =>
|
||||
setFilter((f) => ({ ...f, status: (value as InventoryStatus) ?? undefined }))
|
||||
}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -37,7 +37,7 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
CANCELLED: 'gray',
|
||||
};
|
||||
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c}`;
|
||||
const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`;
|
||||
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
|
||||
|
||||
export default function WarehouseInvoicesPage() {
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
WarehouseTable,
|
||||
type WarehouseView,
|
||||
} from '@/components/warehouses';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
@@ -30,9 +28,7 @@ export default function WarehouseListPage() {
|
||||
[filter, debouncedSearch],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: queryFilter } }),
|
||||
);
|
||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||
const warehouses = data ?? [];
|
||||
|
||||
const openCreate = () => {
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useAllWarehouseYards,
|
||||
useAllocationRules,
|
||||
useCreateAllocationRule,
|
||||
useCreateFeeRule,
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
|
||||
const FREIGHT = [
|
||||
@@ -32,15 +40,26 @@ const TRADE = [
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
{ value: 'USD', label: 'USD - Dollar' },
|
||||
{ value: 'ETB', label: 'ETB - Birr' },
|
||||
];
|
||||
|
||||
const clean = (s: string) => s.trim() || undefined;
|
||||
const selectValue = (value: string | null, fallback = '') => value ?? fallback;
|
||||
const numberValue = (value: string | number, fallback = 0) => {
|
||||
const next = Number(value);
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
const dash = '-';
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Allocation & Fee Rules"
|
||||
subtitle="Configure deterministic yard allocation and storage / demurrage free time and rates."
|
||||
subtitle="Configure yard allocation and storage or demurrage free time and rates."
|
||||
/>
|
||||
<Card>
|
||||
<Tabs defaultValue="allocation">
|
||||
@@ -62,11 +81,10 @@ export default function WarehouseRulesPage() {
|
||||
|
||||
function AllocationRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(
|
||||
api.warehouses.allocationRules.queryOptions(),
|
||||
);
|
||||
const create = useMutation(api.warehouses.createAllocationRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteAllocationRule.mutationOptions());
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -78,13 +96,33 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
|
||||
const rules = data ?? [];
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
value: yard.code,
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerStatus: '',
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Name and target yard code are required' });
|
||||
toast({ variant: 'destructive', title: 'Name and target yard are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
@@ -98,77 +136,179 @@ function AllocationRules() {
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
setForm({ name: '', priority: 100, freightType: '', tradeDirection: '', cargoTypeCode: '', containerStatus: '', targetYardCode: '', storageType: '' });
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<(typeof rules)[number]>[] = [
|
||||
{ id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority },
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
|
||||
{ id: 'cargo', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? '—' },
|
||||
{
|
||||
id: 'targetYard',
|
||||
header: 'Target yard',
|
||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
|
||||
{row.original.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — matched by ascending priority</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New allocation rule</Button>
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No allocation rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light" mb="md">
|
||||
<Text size="sm">
|
||||
Allocation rules tell the system where to place a booking when it enters the warehouse.
|
||||
Lower priority numbers are checked first.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo code</Table.Th>
|
||||
<Table.Th>Target yard</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>{rule.priority}</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light">{rule.targetYardCode}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" c="dimmed" fw={700} tt="uppercase">
|
||||
Rule preview
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<b>When</b> {anyLabel(form.tradeDirection, 'trade direction').toLowerCase()} /{' '}
|
||||
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
|
||||
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
|
||||
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
|
||||
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<NumberInput label="Priority" value={form.priority} onChange={(v) => setForm((f) => ({ ...f, priority: Number(v) || 100 }))} />
|
||||
<TextInput
|
||||
label="Rule name"
|
||||
placeholder="e.g. Import containers to open yard"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Priority"
|
||||
value={form.priority}
|
||||
onChange={(v) => setForm((f) => ({ ...f, priority: numberValue(v, 100) || 100 }))}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<Select
|
||||
label="Freight type"
|
||||
placeholder="Any freight"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, freightType: selectValue(v) }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
placeholder="Any direction"
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tradeDirection: selectValue(v) }))}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Container status" placeholder="e.g. MAINTENANCE" value={form.containerStatus} onChange={(e) => setForm((f) => ({ ...f, containerStatus: e.currentTarget.value }))} />
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
placeholder="e.g. COFFEE"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container status"
|
||||
placeholder="e.g. MAINTENANCE"
|
||||
value={form.containerStatus}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, containerStatus: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Target yard code" required value={form.targetYardCode} onChange={(e) => setForm((f) => ({ ...f, targetYardCode: e.currentTarget.value }))} />
|
||||
<TextInput label="Storage type" value={form.storageType} onChange={(e) => setForm((f) => ({ ...f, storageType: e.currentTarget.value }))} />
|
||||
<Select
|
||||
label="Target yard"
|
||||
required
|
||||
searchable
|
||||
clearable
|
||||
data={yardOptions}
|
||||
value={form.targetYardCode || null}
|
||||
placeholder={yardsLoading ? 'Loading yards...' : 'Select target yard'}
|
||||
nothingFoundMessage="No yards found"
|
||||
disabled={yardsLoading}
|
||||
onChange={(value) => setForm((f) => ({ ...f, targetYardCode: selectValue(value) }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Storage type"
|
||||
placeholder="e.g. OPEN_STACK"
|
||||
value={form.storageType}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, storageType: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -178,9 +318,9 @@ function AllocationRules() {
|
||||
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useQuery(api.warehouses.feeRules.queryOptions());
|
||||
const create = useMutation(api.warehouses.createFeeRule.mutationOptions());
|
||||
const remove = useMutation(api.warehouses.deleteFeeRule.mutationOptions());
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
@@ -199,6 +339,7 @@ function FeeRules() {
|
||||
toast({ variant: 'destructive', title: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
@@ -208,86 +349,158 @@ function FeeRules() {
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<(typeof rules)[number]>[] = [
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||
{row.original.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
{ id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? '—' },
|
||||
{ id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? '—' },
|
||||
{ id: 'freeDays', header: 'Free days', cell: ({ row }) => row.original.freeDays },
|
||||
{
|
||||
id: 'rate',
|
||||
header: 'Rate / day',
|
||||
cell: ({ row }) => `${Number(row.original.ratePerDay).toLocaleString()} ${row.original.currency}`,
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Active',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.isActive ? 'edr-green' : 'gray'} variant="light">
|
||||
{row.original.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => remove.mutate(row.original.id)} title="Delete">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text c="dimmed" size="sm">{rules.length} rule(s) — most specific match applies</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>New fee rule</Button>
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rules}
|
||||
status={isLoading ? 'loading' : 'success'}
|
||||
emptyMessage="No fee rules yet."
|
||||
containerClassName="border-0 shadow-none"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rules.map((rule) => (
|
||||
<Table.Tr key={rule.id}>
|
||||
<Table.Td>
|
||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={rule.isActive ? 'green' : 'gray'} variant="light">
|
||||
{rule.isActive ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput label="Name" required value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.currentTarget.value }))} />
|
||||
<Select label="Rule type" data={FEE_RULE_TYPES.map((t) => ({ value: t, label: t === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage' }))} value={form.ruleType} onChange={(v) => setForm((f) => ({ ...f, ruleType: (v as FeeRuleType) ?? 'DEMURRAGE_FEE' }))} allowDeselect={false} />
|
||||
<TextInput
|
||||
label="Name"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, name: value }));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Rule type"
|
||||
data={FEE_RULE_TYPES.map((type) => ({
|
||||
value: type,
|
||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
||||
}))}
|
||||
value={form.ruleType}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
ruleType: selectValue(value, 'DEMURRAGE_FEE') as FeeRuleType,
|
||||
}))
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Select label="Freight type" data={FREIGHT} value={form.freightType || null} onChange={(v) => setForm((f) => ({ ...f, freightType: v ?? '' }))} clearable />
|
||||
<Select label="Trade direction" data={TRADE} value={form.tradeDirection || null} onChange={(v) => setForm((f) => ({ ...f, tradeDirection: v ?? '' }))} clearable />
|
||||
<TextInput label="Cargo type code" value={form.cargoTypeCode} onChange={(e) => setForm((f) => ({ ...f, cargoTypeCode: e.currentTarget.value }))} />
|
||||
<Select
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Free days" min={0} value={form.freeDays} onChange={(v) => setForm((f) => ({ ...f, freeDays: Number(v) || 0 }))} />
|
||||
<NumberInput label="Rate / day" min={0} value={form.ratePerDay} onChange={(v) => setForm((f) => ({ ...f, ratePerDay: Number(v) || 0 }))} />
|
||||
<TextInput label="Currency" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.currentTarget.value }))} />
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
min={0}
|
||||
value={form.freeDays}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Rate / day"
|
||||
min={0}
|
||||
value={form.ratePerDay}
|
||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||
/>
|
||||
<Select
|
||||
label="Currency"
|
||||
data={CURRENCIES}
|
||||
value={form.currency}
|
||||
onChange={(value) => setForm((f) => ({ ...f, currency: selectValue(value, 'USD') }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>Create</Button>
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -18,10 +18,10 @@ export interface FirstMileBooking {
|
||||
totalAmount: number;
|
||||
scheduledDate?: string | null;
|
||||
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
|
||||
serviceType?: { id: string; name?: string } | null;
|
||||
originYard?: { id: string; name?: string } | null;
|
||||
destinationYard?: { id: string; name?: string } | null;
|
||||
cargoType?: { id: string; name?: string } | null;
|
||||
serviceType?: { id: string; label?: string } | null;
|
||||
originYard?: { id: string; label?: string } | null;
|
||||
destinationYard?: { id: string; label?: string } | null;
|
||||
cargoType?: { id: string; label?: string } | null;
|
||||
}
|
||||
|
||||
export interface FirstMileVehicle {
|
||||
@@ -29,6 +29,9 @@ export interface FirstMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
}
|
||||
|
||||
export interface FirstMileRecord {
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface LastMileVehicle {
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
}
|
||||
|
||||
export interface LastMileRecord {
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Vehicle {
|
||||
capacity: number;
|
||||
status: VehicleStatus;
|
||||
description?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
Warehouse,
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFacility,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseLoading,
|
||||
@@ -67,15 +68,19 @@ export const warehouseService = {
|
||||
params: cleanParams(filter ?? {}),
|
||||
}),
|
||||
dashboard: () => apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
||||
getDashboardSummary: (_filter?: InventoryFilter) =>
|
||||
apiClient.get<WarehouseDashboard>(URL_CONSTANTS.WAREHOUSES.DASHBOARD),
|
||||
getById: (id: string) => apiClient.get<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id)),
|
||||
create: (payload: SaveWarehousePayload) =>
|
||||
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
|
||||
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
|
||||
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
|
||||
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
|
||||
|
||||
// ── Yards ────────────────────────────────────────────────────────────────
|
||||
listYards: (warehouseId: string) =>
|
||||
apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId)),
|
||||
listAllYards: () => apiClient.get<WarehouseYard[]>(URL_CONSTANTS.WAREHOUSE_YARDS.BASE),
|
||||
createYard: (warehouseId: string, payload: SaveYardPayload) =>
|
||||
apiClient.post<WarehouseYard>(URL_CONSTANTS.WAREHOUSES.YARDS(warehouseId), payload),
|
||||
getYard: (id: string) => apiClient.get<WarehouseYard>(URL_CONSTANTS.WAREHOUSE_YARDS.BY_ID(id)),
|
||||
@@ -85,6 +90,7 @@ export const warehouseService = {
|
||||
// ── Zones ──────────────────────────────────────────────────────────────
|
||||
listZones: (yardId: string) =>
|
||||
apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId)),
|
||||
listAllZones: () => apiClient.get<WarehouseZone[]>(URL_CONSTANTS.WAREHOUSE_ZONES.BASE),
|
||||
createZone: (yardId: string, payload: SaveZonePayload) =>
|
||||
apiClient.post<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_YARDS.ZONES(yardId), payload),
|
||||
getZone: (id: string) => apiClient.get<WarehouseZone>(URL_CONSTANTS.WAREHOUSE_ZONES.BY_ID(id)),
|
||||
@@ -124,6 +130,10 @@ export const warehouseService = {
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
|
||||
release: (id: string, payload: ReleaseOrderPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
|
||||
downloadReleaseDocument: (id: string) =>
|
||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export const INVENTORY_STATUSES = [
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'ARRIVED_AT_WAREHOUSE',
|
||||
'UNDER_INSPECTION',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
@@ -57,6 +59,8 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
RESERVED: 'ready-for-loading',
|
||||
ARRIVED_AT_WAREHOUSE: null,
|
||||
UNDER_INSPECTION: null,
|
||||
READY_FOR_LOADING: 'load',
|
||||
LOADED: 'dispatch',
|
||||
DISPATCHED: null,
|
||||
@@ -122,6 +126,7 @@ export interface WarehouseYard {
|
||||
currentVolume: number;
|
||||
status: WarehouseStatus;
|
||||
isActive: boolean;
|
||||
warehouse?: Pick<Warehouse, 'id' | 'name' | 'code'> | null;
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
|
||||
@@ -146,6 +151,8 @@ export interface Facility {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export type WarehouseFacility = Facility;
|
||||
|
||||
export interface Warehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -459,8 +466,11 @@ export interface ImportTrainItem {
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
inventoryId: string | null;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
bookingNumber: string | null;
|
||||
bookingStatus: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
@@ -469,7 +479,11 @@ export interface InventoryInquiryResult {
|
||||
warehouse: { id: string; name: string; code: string } | null;
|
||||
yard: { id: string; name: string; code: string } | null;
|
||||
zone: { id: string; name: string; code: string } | null;
|
||||
status: InventoryStatus;
|
||||
status: InventoryStatus | null;
|
||||
trainNumber: string | null;
|
||||
trainStatus: string | null;
|
||||
route: string | null;
|
||||
locationSummary: string | null;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
arrivedAt: string | null;
|
||||
@@ -606,6 +620,8 @@ export interface FeePreview {
|
||||
endIsOpen: boolean;
|
||||
elapsedDays: number;
|
||||
chargeableDays: number;
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -757,6 +773,28 @@ export interface ReceiveInventoryPayload {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface MoveInventoryPayload {
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface ReserveInventoryPayload {
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
export interface WarehouseDashboardSummary {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
export interface WarehouseFilter {
|
||||
search?: string;
|
||||
type?: WarehouseType;
|
||||
@@ -765,6 +803,7 @@ export interface WarehouseFilter {
|
||||
}
|
||||
|
||||
export interface InventoryFilter {
|
||||
facilityId?: string;
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
@@ -774,9 +813,12 @@ export interface InventoryFilter {
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryFilter {
|
||||
bookingReference?: string;
|
||||
bookingNumber?: string;
|
||||
containerNumber?: string;
|
||||
cargoType?: string;
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* fhc.theme.ts — Federal Housing Corporation (FHC) look & feel preset.
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ HOST-OWNED config. Lives in app-config/, NOT inside the user-management │
|
||||
* │ module. At submodule-split time this whole folder moves to the host repo. │
|
||||
* │ It is fully self-contained — no imports from the module. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* WHAT IT GIVES YOU
|
||||
* - The FHC Mantine color palettes: fhcBlue, fhcBrick, fhcGold, fhcGray
|
||||
* - The FHC layout design tokens (brick-gradient sidebar, glassy header,
|
||||
* page background, brand colors, sizes) under `theme.other.fhcLayout`
|
||||
* (light) and `theme.other.fhcLayoutDark` (dark) — the classic shell reads
|
||||
* these via useFhcLayout()
|
||||
* - FHC typography (Plus Jakarta Sans), radii, shadows and component defaults
|
||||
*
|
||||
* HOW TO USE — in app-config/project.theme.ts:
|
||||
*
|
||||
* import { fhcMantineTheme } from "./fhc.theme";
|
||||
*
|
||||
* export const projectTheme: DesignConfig = {
|
||||
* typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
||||
* mantineTheme: fhcMantineTheme, // escape hatch — merges the FHC theme in
|
||||
* };
|
||||
*
|
||||
* Load the font once in index.html:
|
||||
* <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
*/
|
||||
|
||||
import type { MantineColorsTuple, MantineThemeOverride } from "@mantine/core";
|
||||
|
||||
/** Mantine 10-shade color scales used across the FHC UI. */
|
||||
export const FHC_COLORS = {
|
||||
fhcBlue: [
|
||||
"#EEF4FC",
|
||||
"#D9E8FA",
|
||||
"#BCD5F5",
|
||||
"#96BDEB",
|
||||
"#6FA4E0",
|
||||
"#4A90E2",
|
||||
"#357ABD",
|
||||
"#2C669D",
|
||||
"#224F7A",
|
||||
"#173654",
|
||||
],
|
||||
fhcBrick: [
|
||||
"#F6ECE8",
|
||||
"#EACFC4",
|
||||
"#DBAD99",
|
||||
"#C9876B",
|
||||
"#B86B49",
|
||||
"#A85735",
|
||||
"#8C462B",
|
||||
"#703622",
|
||||
"#55281A",
|
||||
"#3D1E14",
|
||||
],
|
||||
fhcGold: [
|
||||
"#FFFBE6",
|
||||
"#FFF3BF",
|
||||
"#FEE98A",
|
||||
"#FCDD57",
|
||||
"#F9CF2F",
|
||||
"#FFD700",
|
||||
"#D9B700",
|
||||
"#B39400",
|
||||
"#8C7300",
|
||||
"#665300",
|
||||
],
|
||||
fhcGray: [
|
||||
"#F8FAFC",
|
||||
"#F1F5F9",
|
||||
"#E2E8F0",
|
||||
"#CBD5E1",
|
||||
"#94A3B8",
|
||||
"#64748B",
|
||||
"#475569",
|
||||
"#334155",
|
||||
"#1E293B",
|
||||
"#0F172A",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Layout design tokens — the brick-gradient sidebar, glassy header, page
|
||||
* surfaces, brand colors and sizes. Mirrored under `theme.other.fhcLayout`.
|
||||
*/
|
||||
export const FHC_LAYOUT = {
|
||||
sidebar: {
|
||||
bg: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)",
|
||||
headerBg: "rgba(93, 46, 31, 0.82)",
|
||||
footerBg: "rgba(61, 30, 20, 0.62)",
|
||||
border: "rgba(255,255,255,0.10)",
|
||||
text: "rgba(255,255,255,0.76)",
|
||||
mutedText: "rgba(255,255,255,0.42)",
|
||||
childText: "rgba(255,255,255,0.68)",
|
||||
activeText: "#FFFFFF",
|
||||
iconBg: "rgba(255,255,255,0.06)",
|
||||
iconActiveBg: "rgba(255,255,255,0.12)",
|
||||
hoverBg: "rgba(255,255,255,0.08)",
|
||||
activeBg: "rgba(255,255,255,0.15)",
|
||||
activeBorder: "rgba(255,255,255,0.14)",
|
||||
sectionLine: "rgba(255,255,255,0.10)",
|
||||
rail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)",
|
||||
},
|
||||
header: {
|
||||
bg: "rgba(255,255,255,0.92)",
|
||||
border: "rgba(15, 23, 42, 0.08)",
|
||||
searchBg: "#F9FAFB",
|
||||
searchBorder: "#E5E7EB",
|
||||
title: "#1F2937",
|
||||
subtitle: "#6B7280",
|
||||
},
|
||||
page: {
|
||||
bg: "#F8FAFC",
|
||||
cardBg: "rgba(255,255,255,0.92)",
|
||||
},
|
||||
brand: {
|
||||
brick: "#5D2E1F",
|
||||
brickDark: "#3D1E14",
|
||||
blue: "#4A90E2",
|
||||
blueDark: "#357ABD",
|
||||
gold: "#FFD700",
|
||||
text: "#1F2937",
|
||||
},
|
||||
sizes: {
|
||||
sidebarExpanded: 288,
|
||||
sidebarCollapsed: 80,
|
||||
headerHeight: 64,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Dark-mode counterpart of FHC_LAYOUT. The brick-gradient sidebar, accent rail
|
||||
* and sizes are intentionally kept (they already read well on dark), while the
|
||||
* glassy white header, page background, card surfaces and dark text are flipped
|
||||
* to dark equivalents.
|
||||
*/
|
||||
export const FHC_LAYOUT_DARK = {
|
||||
...FHC_LAYOUT,
|
||||
header: {
|
||||
bg: "rgba(26, 27, 30, 0.92)",
|
||||
border: "rgba(255,255,255,0.08)",
|
||||
searchBg: "#25262B",
|
||||
searchBorder: "#2C2E33",
|
||||
title: "#F1F5F9",
|
||||
subtitle: "#9CA3AF",
|
||||
},
|
||||
page: {
|
||||
bg: "#141517",
|
||||
cardBg: "rgba(26, 27, 30, 0.92)",
|
||||
},
|
||||
brand: {
|
||||
...FHC_LAYOUT.brand,
|
||||
text: "#F1F5F9",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Full Mantine theme override carrying the FHC palettes, layout tokens,
|
||||
* typography, radii, shadows and component defaults. Pass this as the
|
||||
* `mantineTheme` escape hatch in project.theme.ts.
|
||||
*
|
||||
* Note: BOTH `fhcLayout` (light) and `fhcLayoutDark` (dark) are published under
|
||||
* `other` — the module's useFhcLayout() reads the matching one per color scheme.
|
||||
*/
|
||||
export const fhcMantineTheme: MantineThemeOverride = {
|
||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
||||
headings: {
|
||||
fontFamily: "Plus Jakarta Sans, sans-serif",
|
||||
},
|
||||
defaultRadius: "md",
|
||||
radius: {
|
||||
xs: "6px",
|
||||
sm: "8px",
|
||||
md: "10px",
|
||||
lg: "14px",
|
||||
xl: "18px",
|
||||
},
|
||||
shadows: {
|
||||
xs: "0 1px 2px rgba(15, 23, 42, 0.04)",
|
||||
sm: "0 2px 8px rgba(15, 23, 42, 0.06)",
|
||||
md: "0 4px 20px rgba(15, 23, 42, 0.08)",
|
||||
lg: "0 8px 30px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
colors: {
|
||||
fhcBlue: FHC_COLORS.fhcBlue as unknown as MantineColorsTuple,
|
||||
fhcBrick: FHC_COLORS.fhcBrick as unknown as MantineColorsTuple,
|
||||
fhcGold: FHC_COLORS.fhcGold as unknown as MantineColorsTuple,
|
||||
fhcGray: FHC_COLORS.fhcGray as unknown as MantineColorsTuple,
|
||||
},
|
||||
other: {
|
||||
fhcLayout: FHC_LAYOUT,
|
||||
fhcLayoutDark: FHC_LAYOUT_DARK,
|
||||
},
|
||||
components: {
|
||||
Paper: {
|
||||
defaultProps: {
|
||||
radius: "lg",
|
||||
shadow: "sm",
|
||||
},
|
||||
},
|
||||
NavLink: {
|
||||
defaultProps: {
|
||||
radius: "md",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional convenience: the bits of a DesignConfig that carry the FHC look.
|
||||
* Spread this into your projectTheme if you also want FHC as the primary brand
|
||||
* (this re-tints buttons/links to fhcBlue). Leave it out to keep your own brand
|
||||
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
|
||||
*/
|
||||
export const fhcDesignPreset = {
|
||||
colors: { primary: "#357ABD" },
|
||||
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
|
||||
shape: { radius: "10px" },
|
||||
mantineTheme: fhcMantineTheme,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no"
|
||||
/>
|
||||
<title>User Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// Consume the reusable module via its public barrel (@/ → ../user-management/src).
|
||||
import { UserManagementApp } from "@/index";
|
||||
// Your project's config lives HERE in the host folder (resolved via @app-config).
|
||||
// The module never imports it; the host passes it in.
|
||||
import { projectTheme } from "@app-config/project.theme";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<UserManagementApp config={projectTheme} />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "user-management-host",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "user-management-host",
|
||||
"version": "0.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "user-management-host",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"description": "Host wrapper for the user-management module. Owns branding/theme (project.theme.ts / fhc.theme.ts), the Vite build (vite.config.ts), the HTML shell (index.html) and the entry (main.tsx). Consumes the module from ../user-management/src.",
|
||||
"scripts": {
|
||||
"dev": "vite --port 4202 --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 4202 --host"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Tailwind is handled by the @tailwindcss/vite plugin (see vite.config.ts), so
|
||||
// PostCSS needs no plugins here. This local config exists to stop Vite from
|
||||
// walking up to the monorepo root postcss.config.js (Tailwind v3), which would
|
||||
// conflict with this package's Tailwind v4 setup.
|
||||
export default {
|
||||
plugins: {},
|
||||
};
|
||||
@@ -1,216 +0,0 @@
|
||||
/**
|
||||
* project.theme.ts — HOST-OWNED config for THIS project / organisation (FHC).
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
* │ Lives in app-config/, OUTSIDE the user-management module. The module │
|
||||
* │ never imports this file — the host passes it in via │
|
||||
* │ <UserManagementApp config={projectTheme} /> (see ../src/main.tsx). │
|
||||
* │ At submodule-split time, this whole folder moves to the host repo. │
|
||||
* └─────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* Every field is optional — remove lines you don't need to override.
|
||||
*
|
||||
* Flow:
|
||||
* project.theme.ts → design.config.ts (module engine) → CSS vars + Mantine theme
|
||||
* TenantConfig.ts → overrides --primary at runtime per hostname
|
||||
*
|
||||
* The TenantConfig layer runs AFTER this, so per-hostname primary-color overrides
|
||||
* still work on top of whatever you set here.
|
||||
*/
|
||||
|
||||
import type { DesignConfig } from "@/config/design.config";
|
||||
import { fhcMantineTheme } from "./fhc.theme";
|
||||
|
||||
export const projectTheme: DesignConfig = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// BRANDING
|
||||
// Replace with your organisation's assets.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
brand: {
|
||||
appName: "Federal Housing Corporation", // FHC — shown in the browser tab
|
||||
// Drop the FHC logo at this path in /public to show it in the sidebar brand
|
||||
// and as the favicon. Until then the sidebar falls back to a building icon.
|
||||
// logoUrl: "/assets/logo/fhc.png",
|
||||
// faviconUrl: "/favicon.ico", // optional — defaults to logoUrl
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// COLORS
|
||||
// Change `primary` to your brand hex and everything cascades automatically.
|
||||
// Shades primary-50 → primary-950 are computed via CSS color-mix in index.css.
|
||||
// TenantConfig overrides this per-hostname, so localhost vs edrsc.com can
|
||||
// still have different colors.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
colors: {
|
||||
primary: "#357ABD", // FHC blue (fhcBlue-6) — buttons, links, active states
|
||||
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
|
||||
// // "#2563eb" blue | "#7c3aed" purple
|
||||
// // "#16a34a" green | "#dc2626" red
|
||||
// // "#f59e0b" amber | "#0284c7" sky
|
||||
|
||||
// primaryForeground: "#ffffff", // text on primary-colored bg — rarely needs changing
|
||||
|
||||
// secondary: "#f1f5f9", // TODO: subtle secondary UI color
|
||||
// background: "#ffffff", // TODO: page background
|
||||
// foreground: "#0f172a", // TODO: main text color
|
||||
// border: "#e2e8f0", // TODO: input / card borders
|
||||
// muted: "#f8fafc", // TODO: disabled input / tag backgrounds
|
||||
// mutedForeground: "#94a3b8", // TODO: placeholder / helper text
|
||||
// card: "#ffffff", // TODO: card background (if different from page)
|
||||
// sidebar: "#f8fafc", // TODO: sidebar background
|
||||
// danger: "#dc2626", // TODO: error / destructive color
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TYPOGRAPHY
|
||||
// Load the font FIRST in index.html (Google Fonts link or @font-face) then
|
||||
// set fontFamily here. The fallback chain is used if the custom font fails.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
typography: {
|
||||
fontFamily: "Plus Jakarta Sans, Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
// // TODO: "Poppins, Inter, sans-serif"
|
||||
// // TODO: "Cairo, Inter, sans-serif" (Arabic)
|
||||
// // TODO: "Noto Serif Ethiopic, serif" (Amharic)
|
||||
|
||||
// headingFontFamily: undefined, // TODO: separate heading font if desired
|
||||
// baseFontSize: "16px", // TODO: "14px" for compact dashboards
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SHAPE
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
shape: {
|
||||
radius: "0.625rem", // TODO: "0" sharp | "0.5rem" subtle | "1rem" very rounded
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SHADOWS
|
||||
// Leave commented to use Mantine/Tailwind defaults.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// shadows: {
|
||||
// card: "0 1px 3px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)",
|
||||
// dropdown: "0 8px 30px rgba(0,0,0,0.12)",
|
||||
// modal: "0 20px 60px rgba(0,0,0,0.16)",
|
||||
// },
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// MANTINE COMPONENT DEFAULTS
|
||||
// These become the <MantineProvider theme> defaults for every component.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
components: {
|
||||
buttonDefaultVariant: "filled", // TODO: "light" | "outline" | "subtle"
|
||||
inputDefaultSize: "sm", // TODO: "xs" | "md" | "lg"
|
||||
inputRadius: "md", // TODO: "xs" | "lg" | "xl"
|
||||
modalRadius: "lg", // TODO: "md" | "xl"
|
||||
tableHighlightOnHover: true,
|
||||
tableStriped: false, // TODO: "odd" | "even" | true
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// USER-MANAGEMENT LAYOUT / NAVIGATION
|
||||
// Pick the navigation chrome and style the side menu — all from here.
|
||||
// "classic" → app-wide SIDE MENU, no top tabs
|
||||
// "legacy" → top TAB bar, no side menu
|
||||
// Each value is also exposed as a --um-* CSS var, so tweaks apply instantly.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
layout: {
|
||||
userManagementView: "classic", // TODO: "legacy" for the top-tab UI
|
||||
// showTopBar: false, // TODO: overrides VITE_SHOW_TOP_BAR
|
||||
|
||||
// ── Dimensions ──────────────────────────────────────────────────────────
|
||||
sidebarWidth: "288px", // TODO: expanded side-menu width
|
||||
sidebarCollapsedWidth: "80px", // TODO: icon-only width
|
||||
headerHeight: "64px", // TODO: top bar height
|
||||
// contentMaxWidth: "1440px", // TODO: cap the content column
|
||||
|
||||
// ── Side-menu skin (defaults follow the FHC brick theme) ───────────────
|
||||
sidebarBackground: "linear-gradient(180deg, #5D2E1F 0%, #3D1E14 100%)",
|
||||
sidebarColor: "rgba(255,255,255,0.76)",
|
||||
sidebarMutedColor: "rgba(255,255,255,0.42)",
|
||||
sidebarActiveBackground: "rgba(255,255,255,0.15)",
|
||||
sidebarActiveColor: "#FFFFFF",
|
||||
sidebarHoverBackground: "rgba(255,255,255,0.08)",
|
||||
sidebarBorder: "rgba(255,255,255,0.10)",
|
||||
sidebarRail: "linear-gradient(180deg, #FFD700 0%, #4A90E2 100%)",
|
||||
sidebarBrandLabel: "User Management",
|
||||
sidebarBrandSublabel: "Federal Housing",
|
||||
|
||||
// ── THE MENU (data, shared by the side menu AND the top tabs) ──────────
|
||||
// Edit/add/remove freely. `icon` is a name from the registry in
|
||||
// navConfig.tsx (users, dashboard, content, position, settings, excel,
|
||||
// archive, units, activity, organizations, …). `label` is an i18n key
|
||||
// under "organization.<label>" (raw string shown if no translation).
|
||||
// Remove this array entirely to fall back to the built-in defaults.
|
||||
//
|
||||
// SHOW / HIDE A MENU: set `enabled: false` on any item to stop it
|
||||
// rendering in BOTH the side menu and the top tabs — without deleting it.
|
||||
// Omitting `enabled` (or `true`) keeps it visible. Toggle these per project.
|
||||
navItems: [
|
||||
// The menu is ROLE-FILTERED (see navConfig.tsx): each role sees only its own
|
||||
// block. Every href below has a matching route in the embedded router
|
||||
// (src/App.tsx), which is now a superset of org-admin + super-admin routes.
|
||||
|
||||
// ── Org-admin / unit-admin surface ──
|
||||
{ label: "dashboard", href: "/user-management/user_management-dashboard", icon: "dashboard", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "userManagement", href: "/user-management/user_management", icon: "users", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "contentManagement", displayLabel: "contentManagement", href: "/user-management/content-management", icon: "content", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Position", displayLabel: "positionTypes", href: "/user-management/position-management", icon: "position", roles: ["admin", "unit_admin", "super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "settings", displayLabel: "settings", href: "/user-management/organization-settings", icon: "settings", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Bulk", displayLabel: "bulkUpload", href: "/user-management/bulk-upload", icon: "excel", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archives", icon: "archive", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archived Units & Positions", displayLabel: "Archived Units & Positions", href: "/user-management/archived", icon: "units", roles: ["admin", "unit_admin"], isPrimary: true, enabled: true },
|
||||
|
||||
// ── Super-admin surface (routes now wired in App.tsx) ──
|
||||
{ label: "dashboard", href: "/user-management/dashboard", icon: "dashboard", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "organizations", href: "/user-management/organizations", icon: "organizations", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "organizationAdmins", href: "/user-management/organization_admins", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "externalUsers", href: "/user-management/external_users", icon: "admins", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Migrated Records", displayLabel: "migratedRecords", href: "/user-management/migrated-records-management", icon: "file", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archive Users", displayLabel: "Archive Users", href: "/user-management/archive-users", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "Archived Organizations", displayLabel: "Archived Organizations", href: "/user-management/archived-organizations", icon: "archive", roles: ["super_admin"], isPrimary: true, enabled: true },
|
||||
{ label: "activityLog", href: "/user-management/activity_log", icon: "activity", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
{ label: "setting", href: "/user-management/settings", icon: "settings", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
{ label: "Letter Template", href: "/user-management/templates", icon: "file", roles: ["super_admin"], isPrimary: false, enabled: true },
|
||||
],
|
||||
|
||||
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
|
||||
menuBackground: "#ffffff", // TODO: tab bar background
|
||||
menuColor: "#334155", // inactive tab text
|
||||
menuActiveColor: "#357ABD", // active tab text (FHC blue)
|
||||
menuActiveBorderColor: "#357ABD", // active tab underline
|
||||
menuHoverColor: "#357ABD", // tab hover text
|
||||
|
||||
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
|
||||
modalAccentColor: "#5D2E1F", // brick top strip
|
||||
modalHeaderBackground: "#F6ECE8", // header bg (view)
|
||||
modalHeaderEditBackground: "#EACFC4", // header bg (edit)
|
||||
modalIconBackground: "#EACFC4", // header icon chip bg
|
||||
modalIconColor: "#5D2E1F", // header icon chip color
|
||||
modalTitleColor: "#1F2937", // modal title text
|
||||
modalFocusColor: "#357ABD", // input focus ring inside modals
|
||||
modalSurface: "#ffffff", // modal body surface
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// EXTRA CSS VARS
|
||||
// Inject any CSS custom property that isn't covered above.
|
||||
// Keys are variable names WITHOUT the leading "--".
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// cssVars: {
|
||||
// "sidebar-width": "260px",
|
||||
// "header-height": "64px",
|
||||
// "content-max-width": "1440px",
|
||||
// "custom-gradient": "linear-gradient(135deg, #18aa9d 0%, #0f7a70 100%)",
|
||||
// },
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// MANTINE THEME ESCAPE HATCH
|
||||
// Any Mantine theme key — merged on top of everything above.
|
||||
// Full list: https://mantine.dev/theming/theme-object/
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// The FHC look & feel preset — provides the fhcBlue/fhcBrick/fhcGold/fhcGray
|
||||
// palettes and the `other.fhcLayout` / `other.fhcLayoutDark` tokens that the
|
||||
// "classic" user-management view renders with. Lives alongside this file in
|
||||
// app-config/ so the whole host config moves together at submodule-split time.
|
||||
mantineTheme: fhcMantineTheme,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["../user-management/src/*"],
|
||||
"@app-config/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["main.tsx", "project.theme.ts", "fhc.theme.ts", "../user-management/src"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
// Same-origin sub-path the host server serves the module from. `base` makes
|
||||
// built asset URLs resolve under it AND is read back inside the module
|
||||
// (import.meta.env.BASE_URL) to set the router basename. Override with UM_BASE.
|
||||
const base = env.UM_BASE || "/_um/";
|
||||
|
||||
// Build straight into the host app's public dir so its ONE server serves the
|
||||
// module at <origin>/_um/ — no second server, same origin as the host.
|
||||
const outDir = path.resolve(__dirname, "../public/_um");
|
||||
|
||||
return {
|
||||
base,
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react(),
|
||||
{
|
||||
// TinyMCE is self-hosted; the module references it at the ABSOLUTE path
|
||||
// /tinymce/..., which resolves at the host origin. Mirror the assets into
|
||||
// BOTH the module public dir AND the host public root. Regenerated on
|
||||
// build, so neither copy is a hand-managed artifact.
|
||||
name: "copy-tinymce-assets",
|
||||
buildStart() {
|
||||
const src = path.resolve(__dirname, "node_modules/tinymce");
|
||||
const dests = [
|
||||
path.resolve(__dirname, "../user-management/public/tinymce"),
|
||||
path.resolve(__dirname, "../public/tinymce"),
|
||||
];
|
||||
const runtimeEntries = [
|
||||
"tinymce.min.js",
|
||||
"icons",
|
||||
"models",
|
||||
"plugins",
|
||||
"skins",
|
||||
"themes",
|
||||
];
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
throw new Error(
|
||||
"[copy-tinymce-assets] node_modules/tinymce not found in this app. Run `npm install` here first."
|
||||
);
|
||||
}
|
||||
|
||||
for (const dest of dests) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of runtimeEntries) {
|
||||
const entrySrc = path.resolve(src, entry);
|
||||
const entryDest = path.resolve(dest, entry);
|
||||
if (!fs.existsSync(entrySrc)) continue;
|
||||
fs.cpSync(entrySrc, entryDest, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
assetsInclude: ["**/*.TTF"],
|
||||
// Static assets (incl. tinymce/) live in the module's public dir.
|
||||
publicDir: path.resolve(__dirname, "../user-management/public"),
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"file-type",
|
||||
"readable-web-to-node-stream",
|
||||
"strtok3",
|
||||
"token-types",
|
||||
],
|
||||
},
|
||||
outDir,
|
||||
assetsDir: "assets",
|
||||
sourcemap: false,
|
||||
emptyOutDir: true,
|
||||
minify: "esbuild",
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
// @app-config = this host folder itself (project.theme.ts / fhc.theme.ts).
|
||||
{ find: /^@app-config\//, replacement: path.resolve(__dirname) + "/" },
|
||||
// @/ = the reusable module's source in the SIBLING module folder.
|
||||
{ find: /^@\//, replacement: path.resolve(__dirname, "../user-management/src") + "/" },
|
||||
],
|
||||
// node_modules is linked to the module's, but pin the singletons so the host
|
||||
// entry and the module code share ONE React 18 (no "invalid hook call").
|
||||
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: env.DEV_PORT ? Number(env.DEV_PORT) : 5173,
|
||||
strictPort: true,
|
||||
fs: { allow: [path.resolve(__dirname, "..")] },
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["file-type", "readable-web-to-node-stream", "strtok3", "token-types"],
|
||||
},
|
||||
esbuild: {
|
||||
drop: mode === "production" ? ["console", "debugger"] : [],
|
||||
},
|
||||
define: {
|
||||
global: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,16 +1,14 @@
|
||||
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
CalendarCheck,
|
||||
Clock,
|
||||
Home,
|
||||
Layers,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Receipt,
|
||||
Settings,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
@@ -21,8 +19,11 @@ import {
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
import useAuth from "./hooks/useAuth";
|
||||
import OnboardingResumeBanner, {
|
||||
AccountReviewBanner,
|
||||
} from "./components/onboarding/OnboardingResumeBanner";
|
||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||
import useAuth from "./hooks/useAuth";
|
||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||
import MyPortalPage from "./pages/MyPortalPage";
|
||||
import MySignaturePage from "./pages/MySignaturePage";
|
||||
@@ -37,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import MyBookings from "./pages/bookings/MyBookings";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
import ContractsList from "./pages/contracts/ContractsList";
|
||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
|
||||
function FullScreenSpinner() {
|
||||
@@ -110,13 +111,11 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* as users who haven't completed onboarding.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted, companyStatus } = useAuth();
|
||||
const { company, onboardingCompleted } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const needsOnboarding = !company || !onboardingCompleted;
|
||||
const allowedHere = isOnboardingAllowedPath(location.pathname);
|
||||
// Onboarding done but not yet approved by an admin → awaiting-approval state.
|
||||
const awaitingApproval = !needsOnboarding && companyStatus === "pending";
|
||||
|
||||
// Open by default while onboarding is pending (covers the login case).
|
||||
const [wizardOpen, { open: openWizard, close: closeWizard }] =
|
||||
@@ -146,10 +145,8 @@ function OnboardingGate() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{needsOnboarding && !wizardOpen && (
|
||||
<OnboardingResumeBanner onResume={openWizard} />
|
||||
)}
|
||||
{awaitingApproval && <PendingApprovalBanner />}
|
||||
{needsOnboarding && <OnboardingResumeBanner onResume={openWizard} />}
|
||||
{!needsOnboarding && <AccountReviewBanner />}
|
||||
<Outlet />
|
||||
<OnboardingWizardDialog
|
||||
opened={needsOnboarding && wizardOpen}
|
||||
@@ -159,41 +156,6 @@ function OnboardingGate() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
|
||||
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-[#0A6F4D]" />
|
||||
<span className="text-sm font-medium text-[#0A6F4D]">
|
||||
Finish setting up your company to unlock bookings, tracking and
|
||||
billing.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
Continue onboarding
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown after onboarding while the company awaits backoffice approval. */
|
||||
function PendingApprovalBanner() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-amber-300/50 bg-amber-50 px-6 py-3">
|
||||
<Clock size={16} className="text-amber-700" />
|
||||
<span className="text-sm font-medium text-amber-800">
|
||||
Your company is awaiting EDR approval. You can browse, but creating
|
||||
bookings is disabled until your company is approved.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps authenticated users off the login/signup pages. */
|
||||
function RedirectIfAuthed() {
|
||||
const { isPending, isAuthenticated } = useAuth();
|
||||
@@ -308,7 +270,10 @@ const App = () => {
|
||||
<Route path="/tracking" element={<TrackingPage />} />
|
||||
<Route path="/billing" element={<BillingPage />} />
|
||||
{/* Profile was merged into Settings — keep old links working. */}
|
||||
<Route path="/profile" element={<Navigate to="/settings" replace />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<Navigate to="/settings" replace />}
|
||||
/>
|
||||
<Route path="/signature" element={<MySignaturePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Box, Button, Tooltip } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Lock, Plus } from "lucide-react";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
interface NewBookingButtonProps {
|
||||
label?: string;
|
||||
size?: string;
|
||||
mt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* New-booking entry point that respects approval status: a customer can only
|
||||
* create bookings under a profile once the backoffice has approved it. While the
|
||||
* active profile is pending the button is disabled with an explanation, so the
|
||||
* gate is communicated rather than silently failing at submit time.
|
||||
*/
|
||||
export function NewBookingButton({
|
||||
label = "New booking",
|
||||
size,
|
||||
mt,
|
||||
}: NewBookingButtonProps) {
|
||||
const { canBook, activeProfileStatus } = useAuth();
|
||||
|
||||
if (!canBook) {
|
||||
const message =
|
||||
activeProfileStatus === "pending"
|
||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||
: "Bookings aren't available for this profile yet.";
|
||||
return (
|
||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||
<Box mt={mt}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size={size}
|
||||
disabled
|
||||
leftSection={<Lock size={16} />}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/bookings/new"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size={size}
|
||||
mt={mt}
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Clock } from "lucide-react";
|
||||
import { api } from "@/services/api";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import type { OnboardingRequirements } from "@/services/companies.service";
|
||||
|
||||
interface OnboardingResumeBannerProps {
|
||||
/** Re-opens the onboarding wizard. */
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
interface BannerCopy {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wording is driven entirely by the backend's outstanding-items list — the
|
||||
* client never decides what's required, it just narrates what's left.
|
||||
*/
|
||||
function getCopy(
|
||||
requirements: OnboardingRequirements | undefined,
|
||||
pct: number,
|
||||
): BannerCopy {
|
||||
// No data yet (or nothing started) — treat it as a fresh start.
|
||||
if (!requirements || requirements.progress.completed === 0) {
|
||||
return {
|
||||
title: "Set up your company profile",
|
||||
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
|
||||
cta: "Start onboarding",
|
||||
};
|
||||
}
|
||||
|
||||
// Everything's filled in but not yet submitted for review.
|
||||
if (requirements.isComplete) {
|
||||
return {
|
||||
title: "Everything's ready to go",
|
||||
subtitle: "Submit your profile to send it for approval.",
|
||||
cta: "Submit for review",
|
||||
};
|
||||
}
|
||||
|
||||
const remaining = requirements.outstanding.length;
|
||||
if (remaining <= 2) {
|
||||
return {
|
||||
title: `Almost done — you're ${pct}% set up`,
|
||||
subtitle: `Just ${remaining} more ${
|
||||
remaining === 1 ? "item" : "items"
|
||||
} to finish: ${requirements.outstanding.join(", ")}.`,
|
||||
cta: "Finish onboarding",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `You're ${pct}% set up`,
|
||||
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
|
||||
cta: "Continue onboarding",
|
||||
};
|
||||
}
|
||||
|
||||
/** Circular percentage meter that reads at a glance against the dark banner. */
|
||||
function ProgressRing({ pct }: { pct: number }) {
|
||||
const size = 56;
|
||||
const stroke = 5;
|
||||
const r = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const offset = circumference * (1 - pct / 100);
|
||||
|
||||
return (
|
||||
<span className="relative flex shrink-0 items-center justify-center">
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#6ee7b7"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: "stroke-dashoffset 600ms ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-sm font-bold text-white">{pct}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent banner shown on onboarding-allowed pages after the wizard is
|
||||
* dismissed. Progress and copy are read straight from the backend's onboarding
|
||||
* requirements, so the banner always agrees with the wizard about what's left.
|
||||
*/
|
||||
export default function OnboardingResumeBanner({
|
||||
onResume,
|
||||
}: OnboardingResumeBannerProps) {
|
||||
const requirementsQuery = useQuery(
|
||||
api.companies.onboardingRequirements.queryOptions({ retry: false }),
|
||||
);
|
||||
|
||||
const requirements = requirementsQuery.data;
|
||||
const { completed, total } = requirements?.progress ?? {
|
||||
completed: 0,
|
||||
total: 0,
|
||||
};
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
const { title, subtitle, cta } = getCopy(requirements, pct);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ProgressRing pct={pct} />
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6ee7b7] opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#6ee7b7]" />
|
||||
</span>
|
||||
<span className="text-base font-bold tracking-tight text-white">
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-white/80">{subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-[#0A6F4D] shadow-sm transition-transform hover:scale-[1.02] hover:bg-white/95"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown once onboarding is submitted but the company's operational profiles are
|
||||
* still being reviewed. Communicates that approval is per-profile and that
|
||||
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
|
||||
*/
|
||||
export function AccountReviewBanner() {
|
||||
const { company } = useAuth();
|
||||
const profiles = company?.company?.companyProfiles ?? [];
|
||||
const pending = profiles.filter((p) => p.status === "pending");
|
||||
const approved = profiles.filter((p) => p.status === "active");
|
||||
|
||||
if (profiles.length === 0 || pending.length === 0) return null;
|
||||
|
||||
const pendingLabel = pending
|
||||
.map((p) => p.type.replace(/_/g, " "))
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Clock size={18} />
|
||||
</span>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-semibold text-amber-900">
|
||||
Your account is under review
|
||||
</span>
|
||||
<span className="text-xs text-amber-800">
|
||||
We're reviewing your {pendingLabel}{" "}
|
||||
{pending.length === 1 ? "profile" : "profiles"}. You can create
|
||||
bookings under a profile as soon as it's approved.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-amber-800">
|
||||
{approved.length} of {profiles.length} approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
Globe2,
|
||||
PartyPopper,
|
||||
ShieldCheck,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
@@ -43,6 +46,7 @@ type FormStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "contact"
|
||||
| "verify"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
@@ -50,6 +54,7 @@ const FORM_STEPS: FormStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"contact",
|
||||
"verify",
|
||||
"poa",
|
||||
"documents",
|
||||
"additional",
|
||||
@@ -90,6 +95,11 @@ const STEP_META: Record<
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
verify: {
|
||||
icon: <ShieldCheck size={20} />,
|
||||
title: "Verify Contact Person",
|
||||
description: "Confirm the contact phone with a one-time SMS code.",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
@@ -142,10 +152,14 @@ export default function OnboardingWizardDialog({
|
||||
onClose,
|
||||
}: OnboardingWizardDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, company, onboardingStep } = useAuth();
|
||||
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
|
||||
|
||||
const existingProfiles = company?.company?.companyProfiles ?? [];
|
||||
const companyAlreadyStarted = Boolean(company?.company?.id);
|
||||
// A draft can exist with zero operational profiles (e.g. an interrupted start).
|
||||
// Such a draft must re-run role selection so the profiles actually get created
|
||||
// — otherwise the user is stuck with nothing to upload a license against.
|
||||
const hasOperationalProfiles = existingProfiles.length > 0;
|
||||
const savedNationality =
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
|
||||
@@ -157,7 +171,11 @@ export default function OnboardingWizardDialog({
|
||||
// Phases: nationality → role → form. If a draft already exists, resume
|
||||
// straight into the form with nationality + roles pre-selected.
|
||||
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
|
||||
companyAlreadyStarted ? "form" : "nationality",
|
||||
companyAlreadyStarted
|
||||
? hasOperationalProfiles
|
||||
? "form"
|
||||
: "role"
|
||||
: "nationality",
|
||||
);
|
||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
@@ -174,6 +192,10 @@ export default function OnboardingWizardDialog({
|
||||
// Mirror of CompanyProfileForm's active step so the global header + progress
|
||||
// pill can reflect it (the form no longer renders its own stepper).
|
||||
const [formStep, setFormStep] = useState<FormStep>(resumeFormStep);
|
||||
// Once submission succeeds we swap the whole wizard body for a congratulations
|
||||
// panel, and keep the modal open (the gate would otherwise tear it down the
|
||||
// moment onboardingCompleted flips true).
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// Saved profile data, for rehydrating the form fields after a refresh.
|
||||
const profileQuery = useQuery(
|
||||
@@ -184,6 +206,19 @@ export default function OnboardingWizardDialog({
|
||||
}),
|
||||
);
|
||||
|
||||
// Server-driven onboarding requirements: the backend decides which document
|
||||
// set applies (by nationality) and what's still outstanding, so the client
|
||||
// never makes that choice itself. This is the heavier "second request" — it's
|
||||
// only issued while onboarding is still incomplete; once the getInfo flag says
|
||||
// we're done, it never fires.
|
||||
const requirementsQuery = useQuery(
|
||||
api.companies.onboardingRequirements.queryOptions({
|
||||
enabled: companyAlreadyStarted && !onboardingCompleted,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshInfo = useCallback(
|
||||
() =>
|
||||
queryClient.invalidateQueries({
|
||||
@@ -225,7 +260,10 @@ export default function OnboardingWizardDialog({
|
||||
}
|
||||
return api.companies.completeOnboarding.call();
|
||||
},
|
||||
onSuccess: refreshInfo,
|
||||
onSuccess: async () => {
|
||||
await refreshInfo();
|
||||
setCompleted(true);
|
||||
},
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
});
|
||||
|
||||
@@ -260,7 +298,9 @@ export default function OnboardingWizardDialog({
|
||||
resumedRef.current = true;
|
||||
setRoles(existingProfiles.map((p) => p.type));
|
||||
setNationality(savedNationality);
|
||||
setPhase("form");
|
||||
// Resume into the form only when profiles exist; otherwise send the user to
|
||||
// role selection so the missing operational profiles get created.
|
||||
setPhase(hasOperationalProfiles ? "form" : "role");
|
||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -329,8 +369,22 @@ export default function OnboardingWizardDialog({
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
|
||||
// Closing from the congratulations panel also clears the completed flag so a
|
||||
// future reopen (shouldn't happen once onboarded) starts clean.
|
||||
const handleClose = useCallback(() => {
|
||||
if (completed) setCompleted(false);
|
||||
onClose();
|
||||
}, [completed, onClose]);
|
||||
|
||||
// Prefer the backend-resolved document code; fall back to the local mapping
|
||||
// only until the requirements query lands (the documents step is reached well
|
||||
// after the draft — and thus the requirements — exist).
|
||||
const resolvedDocumentSettingCode =
|
||||
requirementsQuery.data?.documentSettingCode ??
|
||||
documentSettingCode(effectiveNationality);
|
||||
|
||||
const formProps = {
|
||||
documentSettingCode: documentSettingCode(effectiveNationality),
|
||||
documentSettingCode: resolvedDocumentSettingCode,
|
||||
documentFiles,
|
||||
onDocumentFilesChange: setDocumentFiles,
|
||||
user,
|
||||
@@ -346,16 +400,20 @@ export default function OnboardingWizardDialog({
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange: setLicenseFiles,
|
||||
// Surface a failed final submit (license/document upload or complete) inside
|
||||
// the form — otherwise the server message (e.g. a 500) would be invisible on
|
||||
// the submit step.
|
||||
submitError: phase === "form" ? startError : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton
|
||||
opened={opened || completed}
|
||||
onClose={handleClose}
|
||||
withCloseButton={!completed}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape
|
||||
size={1040}
|
||||
closeOnEscape={!completed}
|
||||
size={720}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
@@ -371,20 +429,25 @@ export default function OnboardingWizardDialog({
|
||||
}
|
||||
}}
|
||||
title={
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
completed ? null : (
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Group gap="sm" mb={4}>
|
||||
{stepMeta.icon}
|
||||
<Title order={3}>{stepMeta.title}</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm">
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
|
||||
{phase === "nationality" ? (
|
||||
@@ -438,10 +501,65 @@ export default function OnboardingWizardDialog({
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the wizard body once onboarding is submitted: congratulates the user
|
||||
* and sets the expectation that their company is now under review, and that
|
||||
* bookings unlock per profile as the team approves each one.
|
||||
*/
|
||||
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Stack gap="lg" align="center" py="md" ta="center">
|
||||
<Box
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{ background: "var(--mantine-color-edr-green-1)" }}
|
||||
>
|
||||
<PartyPopper size={32} className="text-[var(--mantine-color-edr-green-7)]" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Title order={3}>You're all set!</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4} maw={460}>
|
||||
Thanks for completing your company profile. Your application has been
|
||||
submitted and is now with our team for review.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
gap="sm"
|
||||
w="100%"
|
||||
maw={460}
|
||||
p="md"
|
||||
className="rounded-lg"
|
||||
style={{ background: "var(--mantine-color-edr-green-0)" }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Clock size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Text size="sm" ta="left">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<ShieldCheck size={18} className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]" />
|
||||
<Text size="sm" ta="left">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||
Go to my dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous progress pill: a single rounded track that fills left-to-right as
|
||||
* the user advances, with faint ticks marking each step boundary.
|
||||
|
||||
@@ -89,6 +89,7 @@ export const URL_CONSTANTS = {
|
||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||
ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements",
|
||||
DASHBOARD: "/api/companies/dashboard",
|
||||
FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info",
|
||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
||||
|
||||
@@ -74,13 +74,6 @@ const useAuth = () => {
|
||||
setCookie("auth-token", res.token, 7);
|
||||
setCookie("refresh-token", res.refreshToken, 7);
|
||||
await authQuery.refetch();
|
||||
const otpCode = res.otp?.split(" ")?.[6] ?? "";
|
||||
localStorage.setItem("otp", otpCode);
|
||||
localStorage.setItem("otp-phone", payload.phoneNumber);
|
||||
localStorage.setItem("otp-email", payload.email);
|
||||
api.auth.sendOTP
|
||||
.call({ phone: payload.phoneNumber, otp: otpCode })
|
||||
.catch(() => { });
|
||||
return { success: true, data: res };
|
||||
} catch (err) {
|
||||
return { success: false, error: extractApiError(err) };
|
||||
@@ -164,6 +157,15 @@ const useAuth = () => {
|
||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||
|
||||
// Booking is gated on backoffice approval of the active operational profile:
|
||||
// a customer can only book under a profile once its status is "active".
|
||||
const activeProfile =
|
||||
companyInfo?.company?.companyProfiles?.find(
|
||||
(p) => p.id === activeCompanyProfileId,
|
||||
) ?? null;
|
||||
const activeProfileStatus = activeProfile?.status ?? null;
|
||||
const canBook = activeProfileStatus === "active";
|
||||
|
||||
/** Refetch everything scoped to the active operational profile. */
|
||||
const invalidateScopedData = async () => {
|
||||
await Promise.all([
|
||||
@@ -232,6 +234,8 @@ const useAuth = () => {
|
||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||
activeProfileType,
|
||||
activeCompanyProfileId,
|
||||
activeProfileStatus,
|
||||
canBook,
|
||||
companyType,
|
||||
companyStatus,
|
||||
isCompanyApproved,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
HelloSection,
|
||||
InvoicesSection,
|
||||
RecentActivitySection,
|
||||
SetupPrompt,
|
||||
ShipmentsSection,
|
||||
StatsSection,
|
||||
} from "./components";
|
||||
@@ -21,7 +20,6 @@ export default function MyPortalPage() {
|
||||
null,
|
||||
);
|
||||
const {
|
||||
customer,
|
||||
companyProfiles,
|
||||
bookingsQuery,
|
||||
dashboardQuery,
|
||||
@@ -67,8 +65,6 @@ export default function MyPortalPage() {
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<SetupPrompt show={!customer} />
|
||||
|
||||
<StatsSection
|
||||
activeBookingsLength={activeBookings.length}
|
||||
newActiveThisWeek={newActiveThisWeek}
|
||||
@@ -108,9 +104,7 @@ export default function MyPortalPage() {
|
||||
<FreightVolumeSection
|
||||
totalTonnes={dashboard?.freightVolume.totalTonnes ?? 0}
|
||||
totalValue={dashboard?.freightVolume.totalValue ?? 0}
|
||||
currency={
|
||||
(dashboard?.freightVolume.currency ?? "ETB") as Currency
|
||||
}
|
||||
currency={(dashboard?.freightVolume.currency ?? "ETB") as Currency}
|
||||
ytdChangePct={dashboard?.freightVolume.ytdChangePct ?? 0}
|
||||
volumePoints={volumePoints}
|
||||
maxVolume={maxVolume}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { cv } from "../constants";
|
||||
|
||||
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"fanNumber",
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
];
|
||||
|
||||
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
|
||||
if (!profile) return true;
|
||||
return REQUIRED_FIELDS.some((field) => !profile[field]);
|
||||
}
|
||||
|
||||
interface SetupPromptProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) {
|
||||
const profileQuery = useQuery(
|
||||
api.companies.getProfile.queryOptions({ retry: false }),
|
||||
);
|
||||
|
||||
const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data);
|
||||
|
||||
if (!show && !incomplete) return null;
|
||||
|
||||
return (
|
||||
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box className="flex-1">
|
||||
<Group gap={6} align="center" mb={6}>
|
||||
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
|
||||
<Text fz={15} fw={700} c="edr-text">
|
||||
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="edr-muted" mb={12}>
|
||||
{incomplete
|
||||
? "Your company profile is incomplete. Fill in the missing details to unlock all features."
|
||||
: "Complete your company information to unlock all features and start booking shipments."}
|
||||
</Text>
|
||||
<Link to="/settings" className="no-underline">
|
||||
<Group gap={8} align="center" className="w-fit">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
{incomplete ? "Complete Profile" : "Complete Setup"}
|
||||
</Text>
|
||||
<ArrowRight size={16} color={cv("edr-green.7")} />
|
||||
</Group>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box className="hidden shrink-0 sm:block">
|
||||
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -1,15 +1,30 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { cv } from "../constants";
|
||||
|
||||
/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */
|
||||
type Accent = "green" | "amber" | "blue" | "slate";
|
||||
|
||||
const ACCENTS: Record<Accent, { soft: string; ink: string }> = {
|
||||
green: { soft: cv("edr-soft"), ink: cv("edr-green.7") },
|
||||
amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") },
|
||||
blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") },
|
||||
slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") },
|
||||
};
|
||||
|
||||
interface StatKpiProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string;
|
||||
deltaColor: string;
|
||||
/** Color family for the icon chip. */
|
||||
accent: Accent;
|
||||
/** Tint of the delta pill — defaults to the card accent. */
|
||||
deltaTone?: Accent | "muted";
|
||||
/** Draw a separating border on the left (on wide layouts). */
|
||||
divider?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const StatKpi = memo(function StatKpi({
|
||||
@@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
deltaColor,
|
||||
accent,
|
||||
deltaTone,
|
||||
divider,
|
||||
loading,
|
||||
}: StatKpiProps) {
|
||||
const a = ACCENTS[accent];
|
||||
const tone = deltaTone ?? accent;
|
||||
const pill =
|
||||
tone === "muted"
|
||||
? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") }
|
||||
: { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink };
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={4}
|
||||
className={
|
||||
divider ? "lg:border-l lg:border-edr-border lg:pl-7" : undefined
|
||||
divider
|
||||
? "flex flex-col lg:border-l lg:border-edr-divider lg:pl-4"
|
||||
: "flex flex-col"
|
||||
}
|
||||
>
|
||||
<Group gap={6} align="center" mb={7} wrap="nowrap">
|
||||
<Icon size={15} color={cv("edr-muted")} className="shrink-0" />
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} align="flex-end" wrap="nowrap">
|
||||
<Text fz={22} fw={800} lh={1} c="edr-text" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} lh={1.3} c={deltaColor} truncate>
|
||||
{delta}
|
||||
</Text>
|
||||
{/* Icon chip + metric label, aligned on one line. */}
|
||||
<Group gap={12} wrap="nowrap" align="start">
|
||||
<Box
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ background: a.soft }}
|
||||
>
|
||||
<Icon size={18} color={a.ink} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box className="flex-row! flex items-end gap-2">
|
||||
<Text
|
||||
fz={24}
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
c="edr-text"
|
||||
truncate
|
||||
className="tracking-tight"
|
||||
>
|
||||
{loading ? "—" : value}
|
||||
</Text>
|
||||
{delta && !loading && (
|
||||
<Box
|
||||
px={8}
|
||||
py={3}
|
||||
className="inline-flex w-fit rounded-full"
|
||||
style={{ background: pill.bg, maxWidth: "100%" }}
|
||||
>
|
||||
<Text fz={10} fw={700} lh={1.4} truncate style={{ color: pill.fg }}>
|
||||
{delta}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz={12} mt="xs" fw={600} c="edr-muted" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{/* Value + its trend pill, grouped together at the bottom of the cell. */}
|
||||
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { SimpleGrid } from "@mantine/core";
|
||||
import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
||||
import { formatPct } from "../constants";
|
||||
import { Card } from "./Card";
|
||||
import { StatKpi } from "./StatKpi";
|
||||
@@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({
|
||||
completionRate,
|
||||
spendYtd,
|
||||
spendYtdChangePct,
|
||||
dashboardLoading,
|
||||
}: StatsSectionProps) {
|
||||
return (
|
||||
<Card className="rounded-2xl border border-edr-border bg-gradient-to-br from-white to-edr-soft px-7 py-5">
|
||||
<SimpleGrid cols={{ base: 2, lg: 4 }} spacing={0} verticalSpacing={20}>
|
||||
<Card
|
||||
padding={24}
|
||||
className="border-edr-divider! shadow-[0_1px_2px_rgba(16,24,40,0.04)]"
|
||||
>
|
||||
<SimpleGrid
|
||||
cols={{ base: 2, lg: 4 }}
|
||||
spacing={{ base: 20, lg: 0 }}
|
||||
>
|
||||
<StatKpi
|
||||
icon={Truck}
|
||||
accent="green"
|
||||
label="Active Shipments"
|
||||
value={bookingsLoading ? "—" : activeBookingsLength.toString()}
|
||||
delta={bookingsLoading ? "" : `+${newActiveThisWeek} this week`}
|
||||
deltaColor="edr-green.7"
|
||||
value={activeBookingsLength.toString()}
|
||||
delta={newActiveThisWeek > 0 ? `+${newActiveThisWeek} this week` : ""}
|
||||
loading={bookingsLoading}
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Clock3}
|
||||
accent="amber"
|
||||
label="Awaiting Payment"
|
||||
value={outstandingInvoicesLength.toString()}
|
||||
delta={`${formatCurrency(totalOutstanding || 0, "ETB")} due`}
|
||||
deltaColor="edr-amber-text"
|
||||
loading={bookingsLoading}
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={CheckCircle2}
|
||||
accent="blue"
|
||||
label="Delivered (YTD)"
|
||||
value={deliveredCount ?? "—"}
|
||||
delta={completionRate ? `${completionRate}% completed` : ""}
|
||||
deltaColor="edr-muted"
|
||||
deltaTone="muted"
|
||||
loading={dashboardLoading}
|
||||
divider
|
||||
/>
|
||||
<StatKpi
|
||||
icon={Wallet}
|
||||
accent="green"
|
||||
label="Spend YTD"
|
||||
value={spendYtd ?? "—"}
|
||||
delta={spendYtdChangePct ? `${formatPct(spendYtdChangePct)} YoY` : ""}
|
||||
deltaColor="edr-green.7"
|
||||
loading={dashboardLoading}
|
||||
divider
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection";
|
||||
export { HelloSection } from "./HelloSection";
|
||||
export { InvoicesSection } from "./InvoicesSection";
|
||||
export { RecentActivitySection } from "./RecentActivitySection";
|
||||
export { SetupPrompt } from "./SetupPrompt";
|
||||
export { ShipmentsSection } from "./ShipmentsSection";
|
||||
export { StatKpi } from "./StatKpi";
|
||||
export { StatsSection } from "./StatsSection";
|
||||
export { Stepper } from "./Stepper";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,11 @@ import {
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: "all", label: "All bookings", statuses: undefined as string | undefined },
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
@@ -83,12 +87,19 @@ const STATUS_FILTERS = [
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{ key: "closed", label: "Cancelled / rejected", statuses: "CANCELLED,REJECTED" },
|
||||
{
|
||||
key: "closed",
|
||||
label: "Cancelled / rejected",
|
||||
statuses: "CANCELLED,REJECTED",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({ value: f.key, label: f.label }));
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
|
||||
value: f.key,
|
||||
label: f.label,
|
||||
}));
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
@@ -99,42 +110,42 @@ const STAT_CARDS: Array<{
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
@@ -142,8 +153,12 @@ function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg ? `var(--mantine-color-${cfg.badgeText}-7, #475569)` : "#475569";
|
||||
const dot = cfg ? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)` : "#94A3B8";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
@@ -194,7 +209,10 @@ function PrimaryAction({
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{ backgroundColor: "var(--mantine-color-edr-ink-0)", color: "#fff" }}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-edr-ink-0)",
|
||||
color: "#fff",
|
||||
}}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
@@ -213,7 +231,14 @@ function PrimaryAction({
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button size="xs" radius="md" variant="default" fw={600} fz={13} onClick={go}>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={go}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
@@ -225,7 +250,11 @@ function ColHeader({ label }: { label: string }) {
|
||||
fz={11}
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
style={{ letterSpacing: "0.6px", textTransform: "uppercase", whiteSpace: "nowrap" }}
|
||||
style={{
|
||||
letterSpacing: "0.6px",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -240,22 +269,19 @@ function fmtDate(iso?: string | null): string {
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(
|
||||
statuses: string | undefined,
|
||||
companyProfileId?: string,
|
||||
): number | undefined {
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, companyProfileId, page: 1, pageSize: 1 },
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
@@ -331,21 +357,10 @@ export default function MyBookings() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
|
||||
// Operational-service options (importer / exporter / freight forwarder) for
|
||||
// the per-page filter. Empty for non-customer companies.
|
||||
const { company } = useAuth();
|
||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||
const serviceOptions = companyProfiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
||||
}));
|
||||
const [trackingBooking, setTrackingBooking] = useState<Freight.IBooking | null>(
|
||||
null,
|
||||
);
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
|
||||
@@ -358,15 +373,10 @@ export default function MyBookings() {
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter ||
|
||||
!!freightFilter ||
|
||||
!!serviceFilter ||
|
||||
!!createdFrom ||
|
||||
!!createdTo;
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setServiceFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
@@ -377,7 +387,6 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
companyProfileId: serviceFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
@@ -388,7 +397,6 @@ export default function MyBookings() {
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
serviceFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
@@ -400,33 +408,25 @@ export default function MyBookings() {
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only). Scoped to the
|
||||
// selected service so the cards match the filtered table.
|
||||
const svc = serviceFilter ?? undefined;
|
||||
const allCount = useStatusCount(undefined, svc);
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const transitCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "transit")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const closedCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "closed")!.statuses,
|
||||
svc,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
@@ -454,8 +454,7 @@ export default function MyBookings() {
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty =
|
||||
!isLoading && !isError && rows.length === 0;
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
@@ -465,7 +464,8 @@ export default function MyBookings() {
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel = b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
@@ -480,7 +480,11 @@ export default function MyBookings() {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Package size={18} color="var(--mantine-color-edr-green-7)" strokeWidth={2} />
|
||||
<Package
|
||||
size={18}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text" truncate>
|
||||
@@ -586,7 +590,12 @@ export default function MyBookings() {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group justify="flex-end" gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -604,7 +613,12 @@ export default function MyBookings() {
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" size={30} radius="md" aria-label="More options">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
@@ -635,7 +649,12 @@ export default function MyBookings() {
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Group gap={10} align="center">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
</Group>
|
||||
@@ -676,7 +695,9 @@ export default function MyBookings() {
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
@@ -702,7 +723,9 @@ export default function MyBookings() {
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) => selectFilter((value as StatusFilterKey) ?? "all")}
|
||||
onChange={(value) =>
|
||||
selectFilter((value as StatusFilterKey) ?? "all")
|
||||
}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
@@ -744,22 +767,6 @@ export default function MyBookings() {
|
||||
style={{ width: 150 }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
{serviceOptions.length > 1 && (
|
||||
<Select
|
||||
placeholder="All services"
|
||||
data={serviceOptions}
|
||||
value={serviceFilter}
|
||||
onChange={(v) => {
|
||||
setServiceFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 200 }}
|
||||
aria-label="Filter by service"
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
@@ -804,11 +811,19 @@ export default function MyBookings() {
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
mb="xs"
|
||||
>
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{query ? "No bookings match your search" : "No bookings here yet"}
|
||||
{query
|
||||
? "No bookings match your search"
|
||||
: "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
{query
|
||||
@@ -821,13 +836,8 @@ export default function MyBookings() {
|
||||
to="/bookings/new"
|
||||
state={{ fresh: true }}
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
mt="md"
|
||||
leftSection={<Plus size={15} />}
|
||||
>
|
||||
Create first booking
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
@@ -835,7 +845,9 @@ export default function MyBookings() {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/bookings/${(row as Freight.IBooking).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
@@ -861,7 +873,8 @@ export default function MyBookings() {
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ?? trackingBooking?.originYard?.code
|
||||
trackingBooking?.originYard?.label ??
|
||||
trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
@@ -83,6 +83,12 @@ export default function NewBookingPage() {
|
||||
api.bookings.referenceData.queryOptions(),
|
||||
);
|
||||
|
||||
// Booking is gated on profile approval: a customer whose active profile isn't
|
||||
// approved yet is bounced back to the list, where the gate is explained.
|
||||
if (!auth.isPending && auth.company && !auth.canBook) {
|
||||
return <Navigate to="/bookings" replace />;
|
||||
}
|
||||
|
||||
if (!auth.isPending && !auth.company) {
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
CompanyProfileResponse,
|
||||
CreateCompanyPayload,
|
||||
DashboardSummary,
|
||||
OnboardingRequirements,
|
||||
ProfileTypeValue,
|
||||
} from "./companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
@@ -171,6 +172,12 @@ export const api = {
|
||||
"completeOnboarding",
|
||||
companiesService.completeOnboarding,
|
||||
),
|
||||
|
||||
onboardingRequirements: endpoint<void, OnboardingRequirements>(
|
||||
"companies",
|
||||
"onboardingRequirements",
|
||||
companiesService.getOnboardingRequirements,
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
|
||||
@@ -82,6 +82,47 @@ export interface CompanyInfoResponse {
|
||||
company: CompanyResponse;
|
||||
}
|
||||
|
||||
/** A single onboarding document field, as resolved and described by the backend. */
|
||||
export interface OnboardingDocumentField {
|
||||
fileKey: string;
|
||||
fileLabel: string;
|
||||
helpText: string | null;
|
||||
isRequired: boolean;
|
||||
isMultiple: boolean;
|
||||
maxFiles: number;
|
||||
allowedExtensions: string[];
|
||||
maxSizeMb: number;
|
||||
displayOrder: number;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingLicenseProfile {
|
||||
profileId: string;
|
||||
type: string;
|
||||
reference: string;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-driven onboarding requirements. The portal renders this verbatim: the
|
||||
* backend decides which documents apply (by nationality) and what is still
|
||||
* outstanding, so the client never hardcodes required fields or document sets.
|
||||
*/
|
||||
export interface OnboardingRequirements {
|
||||
documentSettingCode: string;
|
||||
nationality: string;
|
||||
companyInfo: {
|
||||
complete: boolean;
|
||||
missingFields: { key: string; label: string }[];
|
||||
};
|
||||
documents: OnboardingDocumentField[];
|
||||
licenseProfiles: OnboardingLicenseProfile[];
|
||||
progress: { completed: number; total: number };
|
||||
isComplete: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
outstanding: string[];
|
||||
}
|
||||
|
||||
export interface CompanyProfileInput {
|
||||
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
||||
businessLicense?: string;
|
||||
@@ -229,6 +270,14 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Server-driven list of outstanding onboarding requirements + completeness. */
|
||||
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
|
||||
const response = await client.get<ApiResponse<OnboardingRequirements>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadDocuments: async (
|
||||
companyId: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
|
||||
@@ -34,7 +34,8 @@ export interface SignupResponse {
|
||||
|
||||
export interface OtpPayload {
|
||||
phone: string;
|
||||
otp: string;
|
||||
/** Required on verify; omitted on send (the server generates the code). */
|
||||
otp?: string;
|
||||
}
|
||||
|
||||
export interface OtpResponse {
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ProfileResponse {
|
||||
contactPersonPosition: string | null;
|
||||
contactPersonEmail: string | null;
|
||||
contactPersonPhone: string | null;
|
||||
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
|
||||
contactVerifiedPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
@@ -66,6 +68,7 @@ export interface UpdateProfilePayload {
|
||||
contactPersonPosition?: string;
|
||||
contactPersonEmail?: string;
|
||||
contactPersonPhone?: string;
|
||||
contactVerifiedPhone?: string;
|
||||
generalManagerName?: string;
|
||||
generalManagerEmail?: string;
|
||||
generalManagerPhone?: string;
|
||||
|
||||
Reference in New Issue
Block a user