mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
resolve merge conflict
This commit is contained in:
@@ -16,6 +16,10 @@ import {
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import { applyTokens } from "./http";
|
||||
import {
|
||||
startTokenRefreshScheduler,
|
||||
stopTokenRefreshScheduler,
|
||||
} from "./refreshScheduler";
|
||||
import type { AuthTokens, AuthUser } from "./types";
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
void bootstrap();
|
||||
}, []);
|
||||
|
||||
// Keep the server session alive while a user is logged in. Runs after
|
||||
// login, MFA verification, and page-reload bootstrap alike.
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
stopTokenRefreshScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
startTokenRefreshScheduler();
|
||||
return stopTokenRefreshScheduler;
|
||||
}, [user]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
|
||||
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
||||
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-flight token refresh: concurrent callers (the 401 interceptor and
|
||||
* the proactive scheduler) share one in-flight request so the refresh token
|
||||
* is only rotated once. Throws if no refresh token is stored or the server
|
||||
* rejects it — callers decide how to end the session.
|
||||
*/
|
||||
const refreshSessionTokens = async (): Promise<AuthTokens> => {
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing refresh token");
|
||||
}
|
||||
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
return tokens;
|
||||
};
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
|
||||
@@ -65,8 +89,7 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||
clearSessionCookies();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -74,15 +97,7 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
const tokens = await refreshSessionTokens();
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${tokens.token}`,
|
||||
@@ -97,4 +112,4 @@ api.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
export { api, applyTokens };
|
||||
export { api, applyTokens, refreshSessionTokens };
|
||||
|
||||
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import {
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
clearSessionCookies,
|
||||
getCookie,
|
||||
} from "./cookies";
|
||||
import { refreshSessionTokens } from "./http";
|
||||
|
||||
/**
|
||||
* Proactively refreshes the token pair on a fixed cadence so the server-side
|
||||
* session (a sliding 1-hour window, extended only by /auth/refresh-token) is
|
||||
* kept alive while the app is open. The 401 interceptor in http.ts remains
|
||||
* the reactive fallback; both share the same single-flight refresh call.
|
||||
*
|
||||
* The interval MUST stay well under the server session window (60 min).
|
||||
*/
|
||||
const DEFAULT_INTERVAL_MINUTES = 10;
|
||||
|
||||
const getIntervalMs = () => {
|
||||
const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
|
||||
return (
|
||||
(Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_INTERVAL_MINUTES) * 60_000
|
||||
);
|
||||
};
|
||||
|
||||
let timerId: number | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
|
||||
const refreshNow = async () => {
|
||||
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||
// Logged out elsewhere; nothing to keep alive.
|
||||
stopTokenRefreshScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshSessionTokens();
|
||||
lastRefreshAt = Date.now();
|
||||
} catch (error) {
|
||||
// Network hiccups are retried on the next tick; only an explicit server
|
||||
// rejection means the session is dead.
|
||||
if (isAxiosError(error) && error.response) {
|
||||
stopTokenRefreshScheduler();
|
||||
clearSessionCookies();
|
||||
window.location.replace("/auth");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Browsers freeze timers in background tabs — a tab waking up past its
|
||||
* refresh deadline refreshes immediately instead of waiting a full interval.
|
||||
*/
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (Date.now() - lastRefreshAt >= getIntervalMs()) {
|
||||
void refreshNow();
|
||||
}
|
||||
};
|
||||
|
||||
export const startTokenRefreshScheduler = () => {
|
||||
stopTokenRefreshScheduler();
|
||||
|
||||
// Token age is unknown here (fresh login vs. hours-old page reload), so
|
||||
// refresh right away to extend the session window from "now".
|
||||
lastRefreshAt = 0;
|
||||
void refreshNow();
|
||||
|
||||
timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
|
||||
export const stopTokenRefreshScheduler = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FilePlus2,
|
||||
FileX2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
companyLocation: "Location",
|
||||
companyAddress: "Address",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
licenceNumber: "Licence number",
|
||||
contactPersonName: "Contact person",
|
||||
contactPersonPosition: "Contact position",
|
||||
contactPersonEmail: "Contact email",
|
||||
contactPersonPhone: "Contact phone",
|
||||
generalManagerName: "General manager",
|
||||
generalManagerEmail: "GM email",
|
||||
generalManagerPhone: "GM phone",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
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> = {
|
||||
companyName: c.name,
|
||||
companyEmail: c.email,
|
||||
companyPhone: c.phone,
|
||||
companyLocation: c.country,
|
||||
companyAddress: c.address,
|
||||
tin: c.tin,
|
||||
vatNumber: c.vatNumber,
|
||||
fanNumber: c.fanNumber,
|
||||
nationality: c.nationality,
|
||||
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
|
||||
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
|
||||
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
|
||||
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
|
||||
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
|
||||
};
|
||||
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
const changed = from !== to;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
{changed && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
const approve = useMutation(
|
||||
api.customers.approveChangeRequest.mutationOptions(),
|
||||
);
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | 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;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
: ([] as string[]);
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{pending && (
|
||||
<Card withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ClipboardCheck size={18} className="text-edr-muted" />
|
||||
<Text fw={600} c="edr-text">
|
||||
Profile changes awaiting review
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="md">
|
||||
Pending
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
<DiffRow
|
||||
key={key}
|
||||
label={FIELD_LABELS[key] ?? humanize(key)}
|
||||
from={currentValue(company, key)}
|
||||
to={
|
||||
pending.snapshot[key] === null ||
|
||||
pending.snapshot[key] === undefined ||
|
||||
pending.snapshot[key] === ""
|
||||
? "—"
|
||||
: String(pending.snapshot[key])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{docCount > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
||||
request — review them in the Documents tab.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseChanges.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
Business license changes
|
||||
</Text>
|
||||
{licenseChanges.map((c, i) => (
|
||||
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
|
||||
{c.op === "add" ? (
|
||||
<FilePlus2 size={15} className="text-edr-muted" />
|
||||
) : (
|
||||
<FileX2 size={15} className="text-edr-muted" />
|
||||
)}
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={c.op === "add" ? "green" : "red"}
|
||||
>
|
||||
{c.op === "add" ? "Add" : "Remove"}
|
||||
</Badge>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: c.fileName ?? "License document",
|
||||
url: fileViewUrl(c.fileId),
|
||||
})
|
||||
}
|
||||
style={{
|
||||
textDecoration:
|
||||
c.op === "remove" ? "line-through" : undefined,
|
||||
}}
|
||||
>
|
||||
{c.fileName ?? "License document"}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</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"
|
||||
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>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "N changes pending" pill for the customer list/detail header. */
|
||||
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
||||
);
|
||||
const pending = (query.data ?? []).some((r) => r.status === "pending");
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<Badge
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Clock size={12} />}
|
||||
>
|
||||
Changes pending review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import type {
|
||||
@@ -25,6 +35,7 @@ const badgeStyle = {
|
||||
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||
active: "edr-green",
|
||||
pending: "yellow",
|
||||
rejected: "red",
|
||||
suspended: "orange",
|
||||
blacklisted: "red",
|
||||
};
|
||||
@@ -266,7 +277,9 @@ export function InvoiceStatusBadge({
|
||||
|
||||
/**
|
||||
* Inline approval action buttons for a profile row.
|
||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
||||
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||
* Rejecting captures a note the customer sees so they can fix and reapply.
|
||||
*/
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
@@ -278,33 +291,102 @@ export function ProfileApprovalActions({
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
const confirmReject = () => {
|
||||
mutate(
|
||||
{ profileId, status: "rejected", note: note.trim() },
|
||||
{ onSuccess: () => setRejectOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
const rejectModal = (
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject profile"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tell the customer what needs fixing. They'll see this note and can
|
||||
amend and resubmit the role for approval.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The uploaded business license is expired."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject profile
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{rejectModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,9 @@ export {
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
} from "./badges";
|
||||
export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText } from 'lucide-react';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type ContainerItem,
|
||||
type ContainerItemStage,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ContainerItemsModalProps {
|
||||
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'RECEIVED', label: 'Received' },
|
||||
{ value: 'GRN', label: "GRN'd" },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'LOADED', label: 'Loaded' },
|
||||
{ value: 'LEFT', label: 'Left' },
|
||||
{ value: 'DELIVERED', label: 'Delivered' },
|
||||
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
ASSIGNED: 'indigo',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/** Loadable = not yet on a truck (before LOADED). */
|
||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
||||
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||
const isLoadable = (i: ContainerItem) =>
|
||||
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||
|
||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||
const { toast } = useToast();
|
||||
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
// Only arrived, not-yet-departed trucks can be loaded.
|
||||
const truckOptions = trucks
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.filter(
|
||||
(t) =>
|
||||
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||
!(t as { departedAt?: string }).departedAt,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||
});
|
||||
|
||||
const requestSign = async () => {
|
||||
try {
|
||||
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||
if (res.alreadySigned) {
|
||||
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
||||
} else {
|
||||
toast({
|
||||
title: 'Handover not signed',
|
||||
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||
try {
|
||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{i.truckAssignmentId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
||||
{i.loaded && i.truckAssignmentId && (
|
||||
<Tooltip
|
||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||
disabled={i.handoverSigned}
|
||||
withArrow
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={i.handoverSigned ? 'orange' : 'gray'}
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() =>
|
||||
i.handoverSigned
|
||||
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
||||
: requestSign()
|
||||
}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Warehouse updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||
toast({ title: 'Warehouse created' });
|
||||
}
|
||||
onClose();
|
||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -169,7 +167,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -174,13 +174,13 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
{hasWeightLoss && (
|
||||
<Group grow mt="xs">
|
||||
<NumberInput
|
||||
label="Expected weight (kg)"
|
||||
label="Expected weight (t)"
|
||||
min={0}
|
||||
value={expectedWeight}
|
||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual weight (kg)"
|
||||
label="Actual weight (t)"
|
||||
min={0}
|
||||
value={actualWeight}
|
||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} t`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento
|
||||
<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`} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} t`} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
|
||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
/** Inventory table + all lifecycle actions (advance / move / history). */
|
||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
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);
|
||||
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const stored = await storeMutation.mutateAsync(item.id);
|
||||
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||
toast({
|
||||
title: 'Inventory stored',
|
||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
case 'ready-for-loading':
|
||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||
case 'load':
|
||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
opened={Boolean(reserveItem)}
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModal
|
||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||
|
||||
<NumberInput
|
||||
label="Loaded weight (kg)"
|
||||
label="Loaded weight (t)"
|
||||
placeholder="Defaults to item weight"
|
||||
min={0}
|
||||
value={loadedWeight}
|
||||
|
||||
@@ -31,7 +31,7 @@ const STAGE_COLOR: Record<string, string> = {
|
||||
LOADED: 'green',
|
||||
};
|
||||
|
||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
|
||||
|
||||
interface BookingGroup {
|
||||
bookingId: string | null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackageSearch,
|
||||
@@ -61,7 +65,6 @@ import type {
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -520,14 +525,14 @@ function TruckEntranceFields({
|
||||
{value.weighingRequired && (
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
label="Gross weight (t)"
|
||||
required
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
label="Exit tare weight (t)"
|
||||
required
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
@@ -596,7 +601,7 @@ function TruckEntranceFields({
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
label="Net weight (t)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||
color="yellow"
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Ready Pickup
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck_dispatch
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||
>
|
||||
Deliver
|
||||
</Button>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Tooltip label="Storage / fee preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
<PackageCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||
Store…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus !== 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||
Move…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||
Ready for pickup
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!r.hasAssignedTruck}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.hasAssignedTruck
|
||||
? r.releaseOrderReference
|
||||
? 'Truck leaving'
|
||||
: 'Truck arrival'
|
||||
: 'Truck arrival — assign a truck first'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
||||
Exit paper
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
||||
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
Storage / fee preview
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
History
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
<TruckDispatchModal
|
||||
opened={Boolean(loadTruckItem)}
|
||||
onClose={() => setLoadTruckItem(null)}
|
||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ContainerItemsModal
|
||||
opened={Boolean(containerItemsItem)}
|
||||
onClose={() => setContainerItemsItem(null)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
||||
].map(([powerPlate, trailerPlate], index) => ({
|
||||
value: powerPlate,
|
||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
||||
trailerPlate,
|
||||
}));
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
@@ -78,7 +55,7 @@ const lineValue = (notes: string | null | undefined, label: string) => {
|
||||
};
|
||||
|
||||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
||||
const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
|
||||
if (!value) return '';
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : '';
|
||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||
// total cargo weight of the containers selected as loaded on it.
|
||||
const { data: containerWeights = [] } = useQuery({
|
||||
queryKey: ['release-container-weights', bookingId],
|
||||
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
const [reference, setReference] = useState('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
truckType: t.truckType,
|
||||
})),
|
||||
];
|
||||
const truckSelectOptions = [
|
||||
...assignedTruckOptions,
|
||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
trailerPlate: t.trailerPlate,
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
truckType: '',
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
// operator types the plate manually in the field below.
|
||||
const truckSelectOptions = assignedTruckOptions;
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
|
||||
// Which containers ride this truck, and their combined cargo weight. When the
|
||||
// booking has container weights, that sum is the authoritative net; the
|
||||
// operator selects the containers loaded on the truck at exit.
|
||||
const hasContainerWeights = containerWeights.length > 0;
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSelectData = containerWeights.map((c) => ({
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
}));
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
.toFixed(3),
|
||||
);
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
: item?.weight == null
|
||||
? netWeight
|
||||
: Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
placeholder="Select truck or type plate manually below"
|
||||
searchable
|
||||
clearable
|
||||
data={truckSelectOptions}
|
||||
disabled={isTruckIdentityLocked}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
}}
|
||||
/>
|
||||
{truckSelectOptions.length > 0 && (
|
||||
<Select
|
||||
label="Assigned first / last-mile truck"
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
// Enabled at arrival so the operator picks which assigned truck came;
|
||||
// only locked on the exit (leaving) step once identity is captured.
|
||||
disabled={isEntranceLocked}
|
||||
data={truckSelectOptions}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
@@ -376,34 +385,55 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Group grow align="flex-start">
|
||||
{hasContainerWeights ? (
|
||||
<MultiSelect
|
||||
label="Containers on this truck"
|
||||
description={
|
||||
isExitStep
|
||||
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||
: 'Containers this truck will carry.'
|
||||
}
|
||||
placeholder="Select containers"
|
||||
searchable
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
value={systemNetWeight}
|
||||
readOnly
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an unloaded import item. The operator may pick warehouse → yard → zone
|
||||
* explicitly; leaving them blank falls back to the backend auto allocation.
|
||||
*/
|
||||
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId },
|
||||
enabled: Boolean(warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId },
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const isManual = Boolean(warehouseId || yardId || zoneId);
|
||||
const manualComplete = Boolean(warehouseId && yardId && zoneId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (isManual && !manualComplete) {
|
||||
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
|
||||
blank to let the system auto-allocate by rule / available capacity.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Auto-allocate"
|
||||
searchable
|
||||
clearable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId || null}
|
||||
onChange={(v) => {
|
||||
setWarehouseId(v ?? '');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!warehouseId}
|
||||
data={yardOptions}
|
||||
value={yardId || null}
|
||||
onChange={(v) => {
|
||||
setYardId(v ?? '');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
|
||||
{manualComplete ? 'Store here' : 'Store (auto)'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
|
||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
|
||||
*/
|
||||
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const text = await responseData.text();
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
const raw = parsed?.message ?? parsed?.error;
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
if (raw) return String(raw);
|
||||
} catch {
|
||||
/* not JSON — fall through */
|
||||
}
|
||||
}
|
||||
return extractErrorMessage(error, fallback);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
|
||||
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
|
||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||
PROFILE_STATUS: (profileId: string) =>
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
CHANGE_REQUESTS: (companyId: string) =>
|
||||
`/companies/${companyId}/change-requests`,
|
||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
|
||||
@@ -145,6 +145,7 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
manage: "edr_freight_app:tracking:manage",
|
||||
},
|
||||
fuel: {
|
||||
view: "edr_freight_app:fuel:view",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
|
||||
{
|
||||
id: "type",
|
||||
header: "Role",
|
||||
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
header: "Reference",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "businessLicense",
|
||||
header: "Business license",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.businessLicense || "—"}
|
||||
</Text>
|
||||
),
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
cell: ({ row }) => {
|
||||
const files = row.original.licenseFiles ?? [];
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{files.map((f) => (
|
||||
<Group key={f.id} gap={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[view],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
|
||||
<ProfileChips profiles={company.companyProfiles} />
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={860}>
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={company.companyProfiles}
|
||||
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Stack key={p.id} gap={6}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(p.licenseFiles ?? []).map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Group key={f.id} gap={8} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removal pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
{(p.licenseFiles ?? []).length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No license documents.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { Activity, Pencil, Plus, Radio, Trash2 } from "lucide-react";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
@@ -151,6 +153,8 @@ function RouteTrail({ path }: { path: LatLng[] }) {
|
||||
export function TrackingPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const canManage = hasPermission(user, FREIGHT_PERMS.tracking.manage);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||
const [mapsReady, setMapsReady] = useState(false);
|
||||
@@ -288,9 +292,11 @@ export function TrackingPage() {
|
||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
Register tracker
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
Register tracker
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
@@ -358,9 +364,11 @@ export function TrackingPage() {
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
{selected.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
{canManage && (
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -393,6 +401,7 @@ export function TrackingPage() {
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
disabled={!canManage}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
@@ -421,14 +430,16 @@ export function TrackingPage() {
|
||||
<Table.Td align="right">
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
{canManage && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
||||
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,
|
||||
|
||||
@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
header: 'Loaded Weight (kg)',
|
||||
header: 'Loaded Weight (t)',
|
||||
cell: ({ row }) => formatNumber(row.original.loadedWeight),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'payment',
|
||||
header: 'Payment',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
useUpdateAllocationRule,
|
||||
useUpdateFeeRule,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
@@ -121,8 +125,10 @@ function AllocationRules() {
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const update = useUpdateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -142,7 +148,8 @@ function AllocationRules() {
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -153,6 +160,22 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: AllocationRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
priority: rule.priority ?? 100,
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerStatus: rule.containerStatus ?? '',
|
||||
targetYardCode: rule.targetYardCode ?? '',
|
||||
storageType: rule.storageType ?? '',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
@@ -160,7 +183,7 @@ function AllocationRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
@@ -170,10 +193,20 @@ function AllocationRules() {
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Allocation rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -182,7 +215,7 @@ function AllocationRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -229,14 +262,19 @@ function AllocationRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -245,7 +283,7 @@ function AllocationRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
@@ -340,11 +378,11 @@ function AllocationRules() {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -363,8 +401,10 @@ function FeeRules() {
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
@@ -394,7 +434,8 @@ function FeeRules() {
|
||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
@@ -410,6 +451,27 @@ function FeeRules() {
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: FeeRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerType: rule.containerType ?? '',
|
||||
vehicleType: rule.vehicleType ?? '',
|
||||
freeDays: rule.freeDays ?? 3,
|
||||
freeHours: rule.freeHours ?? 3,
|
||||
ratePerDay: rule.ratePerDay ?? 0,
|
||||
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
|
||||
currency: rule.currency ?? 'USD',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
@@ -479,12 +541,17 @@ function FeeRules() {
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Fee rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
if (!editingId && tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
@@ -505,7 +572,7 @@ function FeeRules() {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,7 +582,7 @@ function FeeRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -577,14 +644,19 @@ function FeeRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -593,7 +665,7 @@ function FeeRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -775,11 +847,11 @@ function FeeRules() {
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyStats,
|
||||
@@ -99,6 +100,7 @@ import type {
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
PayInvoicePayload,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
@@ -1051,10 +1053,13 @@ export const api = {
|
||||
() => [["warehouse-inventory"], ["warehouses"]],
|
||||
),
|
||||
|
||||
store: endpoint<string, WarehouseInventoryItem>(
|
||||
store: endpoint<
|
||||
{ id: string; payload?: StoreInventoryPayload },
|
||||
WarehouseInventoryItem
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"store",
|
||||
(id) => warehouseService.store(id).then((r) => r.data),
|
||||
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
@@ -2261,13 +2266,13 @@ export const api = {
|
||||
),
|
||||
|
||||
setProfileStatus: endpoint<
|
||||
{ profileId: string; status: ProfileStatus },
|
||||
{ profileId: string; status: ProfileStatus; note?: string },
|
||||
CompanyProfile
|
||||
>(
|
||||
"customers",
|
||||
"setProfileStatus",
|
||||
({ profileId, status }) =>
|
||||
customersService.setProfileStatus(profileId, status),
|
||||
({ profileId, status, note }) =>
|
||||
customersService.setProfileStatus(profileId, status, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
@@ -2275,6 +2280,37 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
|
||||
"customers",
|
||||
"changeRequests",
|
||||
({ id }) => customersService.changeRequests(id),
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
||||
),
|
||||
|
||||
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"approveChangeRequest",
|
||||
({ id }) => customersService.approveChangeRequest(id),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"rejectChangeRequest",
|
||||
({ id, note }) => customersService.rejectChangeRequest(id, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||
"customers",
|
||||
"setCompanyStatus",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyStats,
|
||||
@@ -80,11 +81,15 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
|
||||
setProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
return apiClient
|
||||
.patch<CompanyProfile>(
|
||||
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
||||
{ status },
|
||||
{ status, note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
@@ -95,4 +100,32 @@ export const customersService = {
|
||||
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** List a company's profile-edit change requests (newest first). */
|
||||
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||
return apiClient
|
||||
.get<CompanyChangeRequest[]>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Approve a pending change request — applies the proposed changes. */
|
||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Reject a pending change request with a note. */
|
||||
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
|
||||
{ note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
@@ -63,7 +64,7 @@ import type {
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
export interface ContainerItem {
|
||||
containerNumber: string;
|
||||
@@ -74,9 +75,12 @@ export interface ContainerItem {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -135,6 +139,26 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
|
||||
requestHandoverSignature: async (
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
|
||||
);
|
||||
return data?.data ?? data;
|
||||
},
|
||||
|
||||
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||
getContainerWeights: async (
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Booking container numbers not yet loaded onto any truck. */
|
||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
@@ -235,8 +259,8 @@ export const warehouseService = {
|
||||
}),
|
||||
|
||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||
store: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
||||
store: (id: string, payload?: StoreInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
|
||||
reserve: (payload: ReserveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||
markReadyForLoading: (id: string) =>
|
||||
|
||||
@@ -30,14 +30,24 @@ export type ProfileType =
|
||||
| "transporter";
|
||||
|
||||
/** Mirrors backend `ProfileStatus`. */
|
||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||
export type ProfileStatus =
|
||||
| "active"
|
||||
| "pending"
|
||||
| "rejected"
|
||||
| "suspended"
|
||||
| "blacklisted";
|
||||
|
||||
/** A business-license document uploaded for a company profile. */
|
||||
/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
|
||||
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/** A business-license document uploaded for a company profile (FileRecord-backed). */
|
||||
export interface LicenseFile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
mimeType: string;
|
||||
/** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
|
||||
status: LicenseFileStatus;
|
||||
}
|
||||
|
||||
/** A single role a company is registered for, with its reference code. */
|
||||
@@ -52,6 +62,39 @@ export interface CompanyProfile {
|
||||
/** Business-license documents uploaded for this profile. */
|
||||
licenseFiles?: LicenseFile[];
|
||||
attributes?: Record<string, unknown> | null;
|
||||
/** Reviewer note when the role is rejected. */
|
||||
reviewNote?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||
export interface LicenseChangeIntent {
|
||||
profileId: string;
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged profile-edit change request. The customer's settings edits land here
|
||||
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
||||
*/
|
||||
export interface CompanyChangeRequest {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: ChangeRequestStatus;
|
||||
/** Proposed field values (the diff payload vs. the live company). */
|
||||
snapshot: Record<string, unknown>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||
|
||||
export type InventoryAction =
|
||||
| 'store'
|
||||
| 'reserve'
|
||||
| 'ready-for-loading'
|
||||
| 'load'
|
||||
| 'dispatch'
|
||||
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
||||
UNLOADED: 'store',
|
||||
UNLOADED_AT_DJIBOUTI_PORT: null,
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
STORED: 'ready-for-loading',
|
||||
RESERVED: 'ready-for-loading',
|
||||
ARRIVED_AT_WAREHOUSE: null,
|
||||
UNDER_INSPECTION: null,
|
||||
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
// Import goods skip storage; they need inspection before pickup.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. Import STORED is handled via the import queue.
|
||||
if (isImport) return null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/** Optional explicit storage location; blank → backend auto-allocates. */
|
||||
export interface StoreInventoryPayload {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
|
||||
@@ -8,6 +8,32 @@ export type ApiError = {
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Backend errors arrive as snake_case i18n-style codes (e.g.
|
||||
* "unable_to_log_in"). Map the known ones to friendly copy and prettify
|
||||
* anything else so raw codes never reach the UI. `code` stays raw for
|
||||
* programmatic checks.
|
||||
*/
|
||||
const API_ERROR_MESSAGES: Record<string, string> = {
|
||||
unable_to_log_in: "Incorrect email or password.",
|
||||
invalid_refresh_token: "Your session has expired. Please sign in again.",
|
||||
session_expired: "Your session has expired. Please sign in again.",
|
||||
session_not_found: "Your session has expired. Please sign in again.",
|
||||
user_not_found: "No account found for these credentials.",
|
||||
};
|
||||
|
||||
const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
|
||||
|
||||
function humanizeApiMessage(raw: string): string {
|
||||
const known = API_ERROR_MESSAGES[raw];
|
||||
if (known) return known;
|
||||
if (SNAKE_CASE_CODE.test(raw)) {
|
||||
const text = raw.replaceAll("_", " ");
|
||||
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function extractApiError(err: unknown): ApiError {
|
||||
if (err && typeof err === "object") {
|
||||
const obj = err as Record<string, unknown>;
|
||||
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
|
||||
const statusCode = response.status as number | undefined;
|
||||
const data = response.data as Record<string, unknown> | undefined;
|
||||
return {
|
||||
code: (data?.error as string) || (data?.message as string) || "api_error",
|
||||
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
|
||||
code: (data?.message as string) || (data?.error as string) || "api_error",
|
||||
message: humanizeApiMessage(
|
||||
(data?.message as string) ||
|
||||
(data?.error as string) ||
|
||||
"An unexpected error occurred",
|
||||
),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user