mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
resolve merge conflict
This commit is contained in:
@@ -118,6 +118,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage";
|
||||
import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage";
|
||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||
import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage";
|
||||
import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage";
|
||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||
@@ -411,6 +413,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "EDR Last Mile Returns",
|
||||
href: "/dashboard/edr-last-mile-returns",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Container Returns",
|
||||
href: "/dashboard/container-returns",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
@@ -1060,6 +1074,8 @@ const App = () => {
|
||||
<Route path="intercity" element={<IntercityPage />} />
|
||||
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
|
||||
<Route path="import-trucks" element={<ImportTrucksPage />} />
|
||||
<Route path="edr-last-mile-returns" element={<EDRLastMileReturnsPage />} />
|
||||
<Route path="container-returns" element={<ContainerReturnsPage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import type { Company } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
export function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
export function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
@@ -201,8 +200,9 @@ function DiffRow({
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||
* Request changes actions. Past decisions live in the History tab's unified
|
||||
* timeline (see {@link CompanyTimeline}), not here.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
const requestChanges = useMutation(
|
||||
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
id: string;
|
||||
kind: "reject" | "request-changes";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
if (!pending) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
const confirmAction = () => {
|
||||
if (!actionTarget) return;
|
||||
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||
mutation.mutate(
|
||||
{ id: actionTarget.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setActionTarget(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pending.note && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
Changes were requested on an earlier round of this same
|
||||
submission: <strong>{pending.note}</strong> — check whether
|
||||
this resubmission actually addresses it before approving.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setActionTarget({ id: pending.id, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
opened={actionTarget !== null}
|
||||
onClose={() => setActionTarget(null)}
|
||||
title={
|
||||
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
<Alert
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
>
|
||||
{actionTarget?.kind === "reject"
|
||||
? "The customer will see this note and can amend and resubmit."
|
||||
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
label={
|
||||
actionTarget?.kind === "reject"
|
||||
? "Reason for rejection"
|
||||
: "What needs to change"
|
||||
}
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
onClick={() => setActionTarget(null)}
|
||||
disabled={reject.isPending || requestChanges.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
loading={reject.isPending || requestChanges.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
Reject changes
|
||||
{actionTarget?.kind === "reject"
|
||||
? "Reject changes"
|
||||
: "Request changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilePlus2, FileX2, History } from "lucide-react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "@/types/customer";
|
||||
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
interface DocDiff {
|
||||
key: string;
|
||||
label: string;
|
||||
fromFile: { id: string; name: string } | null;
|
||||
toFile: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface FieldDiff {
|
||||
key: string;
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string;
|
||||
kind: "approved" | "rejected" | "changes_requested" | "revision";
|
||||
at: string;
|
||||
note?: string | null;
|
||||
summary?: string;
|
||||
fieldDiffs: FieldDiff[];
|
||||
docDiffs: DocDiff[];
|
||||
}
|
||||
|
||||
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
|
||||
approved: { label: "Approved", color: "edr-green" },
|
||||
rejected: { label: "Rejected", color: "red" },
|
||||
changes_requested: { label: "Changes requested", color: "yellow" },
|
||||
revision: { label: "Recorded", color: "blue" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after doc diff — a
|
||||
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
|
||||
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
|
||||
* append after that pair, so adjacency survives. A remove or add with no
|
||||
* adjacent partner stands alone.
|
||||
*/
|
||||
function pairIntents(
|
||||
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
|
||||
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
|
||||
): DocDiff[] {
|
||||
const diffs: DocDiff[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
diffs.push({
|
||||
key: `${current.fileId}-${next.fileId}`,
|
||||
label: labelFor(next),
|
||||
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
|
||||
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
diffs.push({
|
||||
key: `${current.fileId}-${i}`,
|
||||
label: labelFor(current),
|
||||
fromFile:
|
||||
current.op === "remove"
|
||||
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||
: null,
|
||||
toFile:
|
||||
current.op === "add"
|
||||
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||
: null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical field diffs on a change request only ever recorded the proposed
|
||||
* ("to") value — there is no stored "before" snapshot — so `from` reads the
|
||||
* CURRENT company value. That's exact for the most recent entry; for an older
|
||||
* one it can drift if the field changed again since. A real limitation of the
|
||||
* data model, not something this view can reconstruct.
|
||||
*/
|
||||
function fromChangeRequest(
|
||||
r: CompanyChangeRequest,
|
||||
company: Company,
|
||||
): TimelineEntry {
|
||||
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
|
||||
(k) => k !== "faydaIdentity",
|
||||
);
|
||||
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
|
||||
key,
|
||||
label: FIELD_LABELS[key] ?? humanize(key),
|
||||
from: currentValue(company, key),
|
||||
to:
|
||||
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
|
||||
? "—"
|
||||
: String(r.snapshot[key]),
|
||||
}));
|
||||
|
||||
const docDiffs: DocDiff[] = [
|
||||
...pairIntents(r.licenseChanges, () => "Business license"),
|
||||
...pairIntents(r.documentChanges, (c) =>
|
||||
humanize((c as DocumentChangeIntent).code),
|
||||
),
|
||||
...r.documentFileIds.map((fileId, i) => ({
|
||||
key: fileId,
|
||||
label: "Document",
|
||||
fromFile: null,
|
||||
toFile: { id: fileId, name: `Document ${i + 1}` },
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.status as TimelineEntry["kind"],
|
||||
at: r.reviewedAt ?? r.updatedAt,
|
||||
note: r.note,
|
||||
fieldDiffs,
|
||||
docDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRevision(rev: CompanyRevision): TimelineEntry {
|
||||
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
|
||||
const fieldDiffs: FieldDiff[] = rev.changes
|
||||
.filter((c) => !isDocChange(c))
|
||||
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
|
||||
const docDiffs: DocDiff[] = rev.changes
|
||||
.filter(isDocChange)
|
||||
.map((c) => ({
|
||||
key: c.field,
|
||||
label: c.label,
|
||||
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
|
||||
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: rev.id,
|
||||
kind: "revision",
|
||||
at: rev.createdAt,
|
||||
summary: rev.summary,
|
||||
fieldDiffs,
|
||||
docDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One combined, chronological timeline of everything that's happened to a
|
||||
* company's record: onboarding-phase edits (no approval gate, from
|
||||
* `CompanyRevision`) and post-approval settings changes (reviewed via
|
||||
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
|
||||
* lists — merged here into one sorted feed so "what changed and when" has a
|
||||
* single answer instead of two places to check.
|
||||
*/
|
||||
export function CompanyTimeline({ company }: { company: Company }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
const changeRequestsQuery = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
const revisionsQuery = useQuery(
|
||||
api.customers.revisions.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
|
||||
const entries: TimelineEntry[] = [
|
||||
...(changeRequestsQuery.data ?? [])
|
||||
.filter((r) => r.status !== "pending")
|
||||
.map((r) => fromChangeRequest(r, company)),
|
||||
...(revisionsQuery.data ?? []).map(fromRevision),
|
||||
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
|
||||
const openFile = (file: { id: string; name: string }) =>
|
||||
void fetchViewableFile(file.id, file.name).then(view);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack align="center" gap={6} py="xl">
|
||||
<History size={24} className="text-edr-muted" />
|
||||
<Text size="sm" c="dimmed">
|
||||
No changes recorded yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{entries.map((entry) => {
|
||||
const badge = KIND_BADGE[entry.kind];
|
||||
return (
|
||||
<Card key={entry.id} withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm">
|
||||
<Badge color={badge.color} variant="light" radius="md">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
{entry.summary && (
|
||||
<Text size="sm" c="edr-text" tt="capitalize">
|
||||
{entry.summary}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(entry.at)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.note && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Note:</strong> {entry.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{entry.fieldDiffs.map((f) => (
|
||||
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{entry.docDiffs.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
{entry.docDiffs.map((d) => (
|
||||
<Group key={d.key} gap={8} wrap="nowrap">
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{d.label}
|
||||
</Text>
|
||||
{d.fromFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FileX2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
td="line-through"
|
||||
onClick={() => openFile(d.fromFile!)}
|
||||
>
|
||||
{d.fromFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
{d.fromFile && d.toFile && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
{d.toFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FilePlus2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openFile(d.toFile!)}
|
||||
>
|
||||
{d.toFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details recorded for this entry.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
WAREHOUSE_ZONE_TYPES,
|
||||
WAREHOUSE_STATUSES,
|
||||
INVENTORY_STATUSES,
|
||||
type ImportUnloadedItem,
|
||||
type Warehouse,
|
||||
type WarehouseInventoryItem,
|
||||
type WarehouseYard,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
@@ -120,6 +122,44 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* An unloaded-queue row seen as the inventory item `ReleaseOrderModal` expects.
|
||||
* Both truck-arrival openers (last mile, import trucks) work off queue rows.
|
||||
*/
|
||||
export const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
/**
|
||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||
|
||||
@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
revisions: (id: string) =>
|
||||
["customers", "detail", id, "revisions"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
|
||||
@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
CHANGE_REQUESTS: (companyId: string) =>
|
||||
`/companies/${companyId}/change-requests`,
|
||||
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
|
||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
|
||||
`/companies/change-requests/${id}/request-changes`,
|
||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||
`/companies/documents/${fileId}/request-change`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
@@ -132,6 +135,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() {
|
||||
View / sign contract
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FileText size={16} />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob =
|
||||
await bookingsService.downloadCarriageAcceptanceSheet(
|
||||
booking.id,
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `carriage-acceptance-${booking.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Carriage acceptance sheet
|
||||
</Button>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
} from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
Company,
|
||||
CompanyProfile,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -78,6 +81,32 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
function downloadTinRecord(company: Company) {
|
||||
const lines = [
|
||||
`TIN: ${company.tin}`,
|
||||
`Company name: ${company.name}`,
|
||||
`Licence number: ${company.licenceNumber ?? ""}`,
|
||||
`Status: ${company.statusDescription ?? ""}`,
|
||||
`Date registered: ${company.dateRegistered ?? ""}`,
|
||||
`Renewed from: ${company.renewedFrom ?? ""}`,
|
||||
`Renewal date: ${company.renewalDate ?? ""}`,
|
||||
`Renewed to: ${company.renewedTo ?? ""}`,
|
||||
`Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
|
||||
];
|
||||
const blob = new Blob([lines.join("\n")], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `tin-${company.tin}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RequestDocumentChangeModal
|
||||
|
||||
@@ -245,6 +245,16 @@ export default function CustomersPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
header: "Approved",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
required: true,
|
||||
description: "Pre-filled from the truck type — override only for a one-off",
|
||||
},
|
||||
{ name: "pricePerKm", label: "Price per KM (ETB)", type: "number", description: "Haulage rate charged per kilometre" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
@@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
pricePerKm: 0,
|
||||
status: "ACTIVE",
|
||||
availability: "FREE",
|
||||
description: "",
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { toReleaseInventoryItem } from "@/components/warehouses/options";
|
||||
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||
@@ -299,40 +300,6 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: "IMPORT",
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
const releasePrefillFromLastMile = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
|
||||
@@ -68,6 +68,7 @@ const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [
|
||||
{ value: "card", label: "Card" },
|
||||
{ value: "dmoney", label: "D-Money" },
|
||||
{ value: "cac-bank", label: "CAC Bank" },
|
||||
{ value: "cbe-bill", label: "CBE Bill" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
import { Fragment, useMemo, useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
size: string | null;
|
||||
type: string | null;
|
||||
bookingRef: string;
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
companyName: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
plate: string | null;
|
||||
isReturn: boolean;
|
||||
}
|
||||
|
||||
interface BookingReturnGroup {
|
||||
bookingId: string;
|
||||
bookingRef: string;
|
||||
companyName: string | null;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
containers: ContainerReturnRow[];
|
||||
}
|
||||
|
||||
export default function ContainerReturnsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
|
||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||
queryKey: ["import-unloaded-queue"],
|
||||
queryFn: async () => {
|
||||
const response = await api.warehouses.importUnloadedQueue.call();
|
||||
return response ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: returnedContainers = [] } = useQuery({
|
||||
queryKey: ["empty-container-returns"],
|
||||
queryFn: async () => {
|
||||
return await importOperationsService.listEmptyReturns().catch(() => []);
|
||||
},
|
||||
});
|
||||
|
||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
||||
const containerReturnsQuery = useQuery({
|
||||
queryKey: ["container-returns", bookingIds],
|
||||
queryFn: async () => {
|
||||
const groups = new Map<string, BookingReturnGroup>();
|
||||
|
||||
for (const item of unloadedQueue) {
|
||||
if (!item.bookingId) continue;
|
||||
|
||||
// EDR last-mile returns: same EDR truck that delivered will return with empty containers
|
||||
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
|
||||
if (edrTrucks.length > 0) {
|
||||
const inventory = await api.warehouses.listInventory
|
||||
.call({ filter: { bookingId: item.bookingId } })
|
||||
.catch(() => []);
|
||||
|
||||
const returnContainers: ContainerReturnRow[] = inventory
|
||||
.filter((inv: any) => inv.isReturn)
|
||||
.map((inv: any) => ({
|
||||
key: inv.id,
|
||||
containerNumber: inv.containerNumber || "—",
|
||||
size: inv.containerSize || null,
|
||||
type: inv.containerType || null,
|
||||
bookingRef: (item.bookingReference ?? item.bookingId) || "",
|
||||
bookingId: item.bookingId || "",
|
||||
customerId: item.customerId || null,
|
||||
companyName: item.customerName ?? null,
|
||||
returnType: "EDR" as const,
|
||||
plate: edrTrucks[0]?.truckPlateNumber || null,
|
||||
isReturn: true,
|
||||
}));
|
||||
|
||||
if (returnContainers.length > 0) {
|
||||
const key = `edr-${item.bookingId}`;
|
||||
groups.set(key, {
|
||||
bookingId: item.bookingId,
|
||||
bookingRef: item.bookingReference ?? item.bookingId,
|
||||
companyName: item.customerName ?? null,
|
||||
customerId: item.customerId || null,
|
||||
returnType: "EDR",
|
||||
containers: returnContainers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customer self-haul last-mile returns: same customer truck that delivered will return with empty containers
|
||||
const customerTrucks = await warehouseService.getCustomerTrucks(item.bookingId).catch(() => []);
|
||||
if (customerTrucks.length > 0) {
|
||||
const inventory = await api.warehouses.listInventory
|
||||
.call({ filter: { bookingId: item.bookingId } })
|
||||
.catch(() => []);
|
||||
|
||||
const returnContainers: ContainerReturnRow[] = inventory
|
||||
.filter((inv: any) => inv.isReturn)
|
||||
.map((inv: any) => ({
|
||||
key: inv.id,
|
||||
containerNumber: inv.containerNumber || "—",
|
||||
size: inv.containerSize || null,
|
||||
type: inv.containerType || null,
|
||||
bookingRef: (item.bookingReference ?? item.bookingId) || "",
|
||||
bookingId: item.bookingId || "",
|
||||
customerId: item.customerId || null,
|
||||
companyName: item.customerName ?? null,
|
||||
returnType: "CUSTOMER" as const,
|
||||
plate: customerTrucks[0]?.plateNumber || null,
|
||||
isReturn: true,
|
||||
}));
|
||||
|
||||
if (returnContainers.length > 0) {
|
||||
const key = `customer-${item.bookingId}`;
|
||||
groups.set(key, {
|
||||
bookingId: item.bookingId,
|
||||
bookingRef: item.bookingReference ?? item.bookingId,
|
||||
companyName: item.customerName ?? null,
|
||||
customerId: item.customerId || null,
|
||||
returnType: "CUSTOMER",
|
||||
containers: returnContainers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
},
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (filterType === "all") return allGroups;
|
||||
if (filterType === "edr") return allGroups.filter((g) => g.returnType === "EDR");
|
||||
if (filterType === "customer") return allGroups.filter((g) => g.returnType === "CUSTOMER");
|
||||
return allGroups;
|
||||
}, [allGroups, filterType]);
|
||||
|
||||
const controls = useListControls(filteredGroups, {
|
||||
searchKeys: ["bookingRef", "companyName"],
|
||||
});
|
||||
|
||||
const createReturnsMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
trucks: Array<{
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
}>;
|
||||
}>;
|
||||
}) => {
|
||||
const results = [];
|
||||
for (const truck of payload.trucks) {
|
||||
for (const container of truck.containers) {
|
||||
const result = await importOperationsService.createEmptyReturn({
|
||||
containerNumber: container.containerNumber,
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
facility: container.warehouse,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
|
||||
setReturnModalOpen(false);
|
||||
setActiveKey(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to record returns",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||
|
||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Container Returns"
|
||||
subtitle="Empty containers returned by last-mile trucks (EDR or customer self-haul)"
|
||||
/>
|
||||
|
||||
<Group mb="lg" justify="space-between">
|
||||
<SegmentedControl
|
||||
value={filterType}
|
||||
onChange={(val) => setFilterType(val as ReturnType)}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "EDR Last Mile", value: "edr" },
|
||||
{ label: "Customer Self-Haul", value: "customer" },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>
|
||||
Record Return
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} mb="xs">Returned Containers</Text>
|
||||
<Table.ScrollContainer minWidth={1000} mb="lg">
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Returned Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{returnedContainers.map((ret: any) => (
|
||||
<Table.Tr key={ret.id}>
|
||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{ret.status}</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
<Alert color="gray">No {filterType !== "all" ? filterType : ""} container returns found.</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Return Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{controls.pagedRows.map((group) => {
|
||||
const groupKey = `${group.returnType.toLowerCase()}-${group.bookingId}`;
|
||||
const isOpen = expanded === groupKey;
|
||||
return (
|
||||
<Fragment key={groupKey}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setExpanded(isOpen ? null : groupKey)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{group.bookingRef}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{group.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge>{group.containers.length} container{group.containers.length !== 1 ? "s" : ""}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setActiveKey(groupKey);
|
||||
setReturnModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Record Return
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{group.containers.map((container) => (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>{container.containerNumber}</Table.Td>
|
||||
<Table.Td>{container.size ?? "—"}</Table.Td>
|
||||
<Table.Td>{container.type ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContainerReturnModal
|
||||
opened={returnModalOpen}
|
||||
onClose={() => setReturnModalOpen(false)}
|
||||
group={activeGroup}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
<StandaloneReturnModal
|
||||
opened={standaloneModalOpen}
|
||||
onClose={() => setStandaloneModalOpen(false)}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
group: BookingReturnGroup | null;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
|
||||
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
return await warehouseService.list({});
|
||||
},
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh: any) => ({
|
||||
value: wh.id,
|
||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!group || !selectedContainers.length || !warehouse) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
|
||||
const containers = group.containers
|
||||
.filter((c) => selectedContainers.includes(c.key))
|
||||
.map((c) => ({
|
||||
containerNumber: c.containerNumber,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
}));
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
{
|
||||
bookingId: group.bookingId,
|
||||
customerId: group.customerId,
|
||||
returnType: group.returnType,
|
||||
containers,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Record Container Return" size="lg">
|
||||
{group && (
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Text fw={600}>{group.bookingRef}</Text>
|
||||
<Badge color={group.returnType === "EDR" ? "edr-green" : "blue"}>
|
||||
{group.returnType === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
Select containers to return:
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{group.containers.map((container) => (
|
||||
<Checkbox
|
||||
key={container.key}
|
||||
label={`${container.containerNumber} (${container.size || "bulk"})`}
|
||||
checked={selectedContainers.includes(container.key)}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setSelectedContainers([...selectedContainers, container.key]);
|
||||
} else {
|
||||
setSelectedContainers(selectedContainers.filter((c) => c !== container.key));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
value={warehouse}
|
||||
onChange={setWarehouse}
|
||||
data={warehouseOptions}
|
||||
required
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
placeholder="Damage, residue, or cleanliness notes"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Handover Note"
|
||||
placeholder="Consignee, trucker, or authorization notes"
|
||||
value={handoverNote}
|
||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedContainers.length || !warehouse}
|
||||
loading={loading}
|
||||
>
|
||||
Record Return ({selectedContainers.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface StandaloneReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
return await warehouseService.list({});
|
||||
},
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouse]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoneId(null);
|
||||
}, [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh: any) => ({
|
||||
value: wh.id,
|
||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!containerNumber || !warehouse || !returnedBy) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
const selectedYard = yards?.find((y) => y.id === yardId);
|
||||
const selectedZone = zones?.find((z) => z.id === zoneId);
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
{
|
||||
bookingId: null,
|
||||
customerId: null,
|
||||
returnType: returnedBy,
|
||||
containers: [
|
||||
{
|
||||
containerNumber,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
zone: selectedZone?.name,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
setCondition("");
|
||||
setHandoverNote("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Record Container Return (Standalone)" size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Record container return without booking association
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Container Number"
|
||||
placeholder="e.g., TEMU1234567"
|
||||
value={containerNumber}
|
||||
onChange={(e) => setContainerNumber(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Truck" },
|
||||
{ value: "CUSTOMER", label: "Customer Truck" },
|
||||
]}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
value={warehouse}
|
||||
onChange={setWarehouse}
|
||||
data={warehouseOptions}
|
||||
required
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouse}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
placeholder="Damage, residue, or cleanliness notes"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Handover Note"
|
||||
placeholder="Consignee, trucker, or authorization notes"
|
||||
value={handoverNote}
|
||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!containerNumber || !returnedBy || !warehouse}
|
||||
loading={loading}
|
||||
>
|
||||
Record Return
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
|
||||
interface ReturnContainer {
|
||||
containerNumber: string;
|
||||
size: string | null;
|
||||
type: string | null;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface TruckReturn {
|
||||
key: string;
|
||||
plate: string;
|
||||
companyName: string | null;
|
||||
bookingRef: string;
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
containers: ReturnContainer[];
|
||||
}
|
||||
|
||||
|
||||
export default function EDRLastMileReturnsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
|
||||
const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({
|
||||
queryKey: ["import-unloaded-queue"],
|
||||
queryFn: async () => {
|
||||
const response = await api.warehouses.importUnloadedQueue.call();
|
||||
return response ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
||||
const truckReturnsQuery = useQuery({
|
||||
queryKey: ["edr-last-mile-returns", bookingIds],
|
||||
queryFn: async () => {
|
||||
const grouped = new Map<string, TruckReturn>();
|
||||
|
||||
for (const item of unloadedQueue) {
|
||||
if (!item.bookingId) continue;
|
||||
|
||||
const edrTrucks = await warehouseService.getLastMileTrucks(item.bookingId).catch(() => []);
|
||||
for (const truck of edrTrucks) {
|
||||
const inventory = await api.warehouses.listInventory.call({ filter: { bookingId: item.bookingId } }).catch(() => []);
|
||||
|
||||
const returnContainers = inventory
|
||||
.filter((inv: any) => inv.isReturn)
|
||||
.map((inv: any) => ({
|
||||
containerNumber: inv.containerNumber || "—",
|
||||
size: inv.containerSize || null,
|
||||
type: inv.containerType || null,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
if (returnContainers.length > 0) {
|
||||
const key = `${item.bookingId}-${truck.vehicleId}`;
|
||||
grouped.set(key, {
|
||||
key,
|
||||
plate: [truck.truckPlateNumber, truck.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
companyName: item.customerName ?? null,
|
||||
bookingRef: item.bookingReference ?? item.bookingId,
|
||||
bookingId: item.bookingId,
|
||||
customerId: item.customerId || null,
|
||||
containers: returnContainers,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
},
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const trucksWithReturns = useMemo(() => truckReturnsQuery.data ?? [], [truckReturnsQuery.data]);
|
||||
const controls = useListControls(trucksWithReturns, {
|
||||
searchKeys: ["plate", "companyName", "bookingRef"],
|
||||
});
|
||||
|
||||
const createReturnsMutation = useMutation({
|
||||
mutationFn: async (payload: { trucks: Array<{ bookingId: string; customerId: string | null; containers: Array<{ containerNumber: string; returnDate: string; facility: string; yard?: string; zone?: string; condition?: string; handoverNote?: string }> }> }) => {
|
||||
const results = [];
|
||||
for (const truck of payload.trucks) {
|
||||
for (const container of truck.containers) {
|
||||
const result = await importOperationsService.createEmptyReturn({
|
||||
containerNumber: container.containerNumber,
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
facility: container.facility,
|
||||
yard: container.yard,
|
||||
zone: container.zone,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Empty container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["edr-last-mile-returns", bookingIds] });
|
||||
setReturnModalOpen(false);
|
||||
setActiveKey(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to record returns",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const activeTruck = activeKey ? trucksWithReturns.find(t => t.key === activeKey) ?? null : null;
|
||||
|
||||
if (queueLoading || truckReturnsQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="EDR Last Mile Returns"
|
||||
subtitle="Empty containers returned by EDR-haulage trucks — single or bulk processing"
|
||||
/>
|
||||
|
||||
{trucksWithReturns.length === 0 ? (
|
||||
<Alert color="gray">No EDR trucks with return containers found.</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Return Containers</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{controls.pagedRows.map((truck) => {
|
||||
const isOpen = expanded === truck.key;
|
||||
return (
|
||||
<Fragment key={truck.key}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setExpanded(isOpen ? null : truck.key)}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{truck.plate}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{truck.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>{truck.bookingRef}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge>{truck.containers.length} container{truck.containers.length !== 1 ? "s" : ""}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
setActiveKey(truck.key);
|
||||
setReturnModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Process Returns
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox disabled />
|
||||
</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{truck.containers.map((container, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<Checkbox checked={container.selected} />
|
||||
</Table.Td>
|
||||
<Table.Td>{container.containerNumber}</Table.Td>
|
||||
<Table.Td>{container.size ?? "—"}</Table.Td>
|
||||
<Table.Td>{container.type ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
pageCount={controls.pageCount}
|
||||
totalCount={controls.totalCount}
|
||||
itemLabel="trucks"
|
||||
onPaginationChange={controls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmptyContainerReturnModal
|
||||
opened={returnModalOpen}
|
||||
onClose={() => setReturnModalOpen(false)}
|
||||
truck={activeTruck}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
truck: TruckReturn | null;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function EmptyContainerReturnModal({ opened, onClose, truck, onSubmit, loading }: EmptyContainerReturnModalProps) {
|
||||
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
return await warehouseService.list({});
|
||||
},
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({
|
||||
value: wh.id,
|
||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
||||
})) : [];
|
||||
|
||||
const selectedWarehouse = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!truck || !selectedContainers.length || !warehouse) return;
|
||||
|
||||
const containers = truck.containers
|
||||
.filter((c) => selectedContainers.includes(c.containerNumber))
|
||||
.map((c) => ({
|
||||
containerNumber: c.containerNumber,
|
||||
returnDate,
|
||||
facility: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedWarehouse?.code || undefined,
|
||||
zone: undefined,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
}));
|
||||
|
||||
onSubmit({
|
||||
trucks: [{
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId,
|
||||
containers,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Process Empty Container Returns" size="lg">
|
||||
{truck && (
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<Text fw={600}>{truck.plate}</Text>
|
||||
<Text size="sm" c="dimmed">{truck.bookingRef}</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">Select containers to return:</Text>
|
||||
<Stack gap="xs">
|
||||
{truck.containers.map((container) => (
|
||||
<Checkbox
|
||||
key={container.containerNumber}
|
||||
label={`${container.containerNumber} (${container.size || "bulk"})`}
|
||||
checked={selectedContainers.includes(container.containerNumber)}
|
||||
onChange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
setSelectedContainers([...selectedContainers, container.containerNumber]);
|
||||
} else {
|
||||
setSelectedContainers(selectedContainers.filter(c => c !== container.containerNumber));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
value={warehouse}
|
||||
onChange={setWarehouse}
|
||||
data={warehouseOptions}
|
||||
required
|
||||
searchable
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Return Date"
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
placeholder="Damage, residue, or cleanliness notes"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Handover Note"
|
||||
placeholder="Consignee, trucker, or authorization notes"
|
||||
value={handoverNote}
|
||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedContainers.length || !warehouse}
|
||||
loading={loading}
|
||||
>
|
||||
{selectedContainers.length > 1 ? "Bulk" : "Single"} Return ({selectedContainers.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Receipt,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
@@ -27,14 +28,22 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { InspectionReportModal } from "@/components/warehouses/InspectionReportModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { WarehouseGateTimesModal } from "@/components/operations/WarehouseGateTimesModal";
|
||||
import { extractDownloadErrorMessage, formatNumber } from "@/components/warehouses/options";
|
||||
import {
|
||||
ReleaseOrderModal,
|
||||
type ReleaseOrderTruckPrefill,
|
||||
} from "@/components/warehouses/ReleaseOrderModal";
|
||||
import {
|
||||
extractDownloadErrorMessage,
|
||||
formatNumber,
|
||||
toReleaseInventoryItem,
|
||||
} from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { warehouseService, type LastMileArrivalTruck } from "@/services/warehouse.service";
|
||||
import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
import type { ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||
|
||||
/**
|
||||
* Import trucks — the unloaded queue seen truck-first instead of item-first.
|
||||
@@ -53,6 +62,20 @@ import type { ImportUnloadedItem } from "@/types/warehouse";
|
||||
|
||||
const COLS = 11;
|
||||
|
||||
/** Truck columns — the table head, and repeated inside each expanded booking. */
|
||||
const TRUCK_COLUMNS = [
|
||||
"Plate",
|
||||
"Type",
|
||||
"Containers",
|
||||
"Truck Arrival",
|
||||
"Truck Leaving",
|
||||
"Weight",
|
||||
"Demurrage",
|
||||
"Storage",
|
||||
"Detention",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`;
|
||||
|
||||
@@ -109,6 +132,8 @@ interface TruckRow {
|
||||
vehicleId: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
/** Identity handed to the release modal so it opens on THIS truck. */
|
||||
prefill: ReleaseOrderTruckPrefill;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,10 +142,17 @@ interface TruckRow {
|
||||
*/
|
||||
function TruckRows({ group }: { group: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
const [detentionOpen, setDetentionOpen] = useState(false);
|
||||
const [gateTimesOpen, setGateTimesOpen] = useState(false);
|
||||
// The truck whose arrival/exit weighing is open — kept in state so the prefill
|
||||
// object stays referentially stable while the modal is up.
|
||||
const [release, setRelease] = useState<{
|
||||
item: WarehouseInventoryItem;
|
||||
prefill: ReleaseOrderTruckPrefill;
|
||||
} | null>(null);
|
||||
|
||||
const edrQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", group.bookingId],
|
||||
@@ -194,6 +226,15 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
vehicleId: t.vehicleId,
|
||||
arrivedAt: t.arrivedAt,
|
||||
departedAt: t.departedAt,
|
||||
prefill: {
|
||||
truckPlateNumber: t.truckPlateNumber,
|
||||
trailerPlateNumber: t.trailerPlateNumber,
|
||||
driverName: t.driverName,
|
||||
driverLicense: t.driverLicense,
|
||||
driverPhone: t.driverPhone,
|
||||
truckType: t.truckType,
|
||||
containerNumber: t.containerNumber,
|
||||
},
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
@@ -208,11 +249,39 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
vehicleId: null,
|
||||
arrivedAt: t.arrivedAt ?? null,
|
||||
departedAt: t.departedAt ?? null,
|
||||
prefill: {
|
||||
truckPlateNumber: t.plateNumber,
|
||||
driverName: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containerNumber: containers.join(", "),
|
||||
},
|
||||
...costsFor(containers),
|
||||
};
|
||||
};
|
||||
const trucks: TruckRow[] = isEdr ? edrTrucks.map(fromEdr) : customerTrucks.map(fromCustomer);
|
||||
|
||||
/**
|
||||
* Arrival and leaving are one form per truck: the modal picks the step from
|
||||
* that plate's own saved weighing block, so both menu items open it the same
|
||||
* way. The booking-level opener (inventory workbench) stays as it was.
|
||||
*/
|
||||
const openRelease = (t: TruckRow) => {
|
||||
const row = group.rows.find((r) => r.id === t.inventoryIds[0]) ?? group.rows[0];
|
||||
if (!row) return;
|
||||
setRelease({ item: toReleaseInventoryItem(row), prefill: t.prefill });
|
||||
};
|
||||
|
||||
const closeRelease = () => {
|
||||
setRelease(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["booking-edr-trucks", group.bookingId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["booking-customer-trucks", group.bookingId] });
|
||||
// The saved weighing block lives in the inventory row's notes — refetch the
|
||||
// queue or the next open would still show the truck as never arrived.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.warehouses.importUnloadedQueue.queryOptions({}).queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
const openDocument = async (
|
||||
kind: "release" | "handover",
|
||||
inventoryId: string,
|
||||
@@ -267,6 +336,18 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The booking row sits between the head and these trucks, so repeat the
|
||||
column labels — otherwise an expanded booking reads as unlabelled. */}
|
||||
<Table.Tr bg="var(--mantine-color-gray-1)">
|
||||
<Table.Td />
|
||||
{TRUCK_COLUMNS.map((label) => (
|
||||
<Table.Td key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||
<Text size="xs" fw={700} c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
{trucks.map((t, idx) => {
|
||||
const detention = t.vehicleId ? detentionByVehicle.get(t.vehicleId) : undefined;
|
||||
const primaryId = t.inventoryIds[0] ?? null;
|
||||
@@ -337,6 +418,21 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Arrival
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Leaving
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={14} />}
|
||||
disabled={!primaryId}
|
||||
@@ -388,6 +484,12 @@ function TruckRows({ group }: { group: BookingGroup }) {
|
||||
onClose={() => setInspectId(null)}
|
||||
inventoryId={inspectId}
|
||||
/>
|
||||
<ReleaseOrderModal
|
||||
opened={Boolean(release)}
|
||||
onClose={closeRelease}
|
||||
item={release?.item ?? null}
|
||||
truckPrefill={release?.prefill ?? null}
|
||||
/>
|
||||
{isEdr && (
|
||||
<>
|
||||
<TruckDetentionModal
|
||||
@@ -459,16 +561,11 @@ export default function ImportTrucksPage() {
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Truck Arrival</Table.Th>
|
||||
<Table.Th>Truck Leaving</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Demurrage</Table.Th>
|
||||
<Table.Th>Storage</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
{TRUCK_COLUMNS.map((label) => (
|
||||
<Table.Th key={label} ta={label === "Actions" ? "right" : undefined}>
|
||||
{label}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
|
||||
@@ -150,8 +150,9 @@ function AllocationRules() {
|
||||
.filter((yard) => yard.code)
|
||||
.map((yard) => ({
|
||||
value: yard.code,
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
label: `${yard.name} (${yard.code})${yard.warehouse?.code ? ` — ${yard.warehouse.code}` : ''}`,
|
||||
}));
|
||||
const yardNameByCode = Object.fromEntries(yards.map((yard) => [yard.code, yard.name]));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
@@ -223,7 +224,11 @@ function AllocationRules() {
|
||||
{
|
||||
id: 'targetYard',
|
||||
header: 'Target yard',
|
||||
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light">
|
||||
{yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
@@ -307,7 +312,8 @@ function AllocationRules() {
|
||||
{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'}.
|
||||
is received, <b>send it to</b>{' '}
|
||||
{(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyRevision,
|
||||
CompanyStats,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -2778,6 +2779,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
||||
),
|
||||
|
||||
revisions: endpoint<{ id: string }, CompanyRevision[]>(
|
||||
"customers",
|
||||
"revisions",
|
||||
({ id }) => customersService.revisions(id),
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id),
|
||||
),
|
||||
|
||||
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"approveChangeRequest",
|
||||
@@ -2802,6 +2810,21 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
requestChangeRequestChanges: endpoint<
|
||||
{ id: string; note: string },
|
||||
CompanyChangeRequest
|
||||
>(
|
||||
"customers",
|
||||
"requestChangeRequestChanges",
|
||||
({ id, note }) => customersService.requestChangeRequestChanges(id, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one document. Invalidates the documents list
|
||||
* and the company itself, since an open request blocks role approval.
|
||||
|
||||
@@ -455,6 +455,13 @@ export const bookingsService = {
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
},
|
||||
|
||||
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
||||
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||
responseType: "blob",
|
||||
});
|
||||
return ensurePdfBlob(response.data as Blob);
|
||||
},
|
||||
|
||||
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyRevision,
|
||||
CompanyStats,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -146,6 +147,13 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Onboarding-phase edit history for a company (version history), newest first. */
|
||||
revisions(companyId: string): Promise<CompanyRevision[]> {
|
||||
return apiClient
|
||||
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Approve a pending change request — applies the proposed changes. */
|
||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
@@ -165,6 +173,19 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
|
||||
requestChangeRequestChanges(
|
||||
id: string,
|
||||
note: string,
|
||||
): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
|
||||
{ note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the customer to correct one uploaded document. Narrower than rejecting
|
||||
* the whole role: the customer keeps their other documents and only re-uploads
|
||||
|
||||
@@ -19,7 +19,8 @@ export type PaymentMethod =
|
||||
| "waafi"
|
||||
| "card"
|
||||
| "dmoney"
|
||||
| "cac-bank";
|
||||
| "cac-bank"
|
||||
| "cbe-bill";
|
||||
|
||||
export interface PaymentRow {
|
||||
id: string;
|
||||
|
||||
@@ -69,7 +69,11 @@ export interface CompanyProfile {
|
||||
}
|
||||
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||
export type ChangeRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "changes_requested";
|
||||
|
||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||
export interface LicenseChangeIntent {
|
||||
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One field/document change recorded on a company revision. A document change
|
||||
* carries `fromFileId`/`toFileId` alongside the display names, so the
|
||||
* previous and current file can both be opened, not just named.
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding-phase edit history — records what changed on a company record
|
||||
* before it reached Active, the write path that has no approval gate.
|
||||
*/
|
||||
export interface CompanyRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset link is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
@@ -215,6 +246,7 @@ export interface Company {
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user