mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
Merge pull request #540 from Tria-plc/freight/feat/fixes-v1
Added a changes approval system to the customer data and fix various things
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";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
@@ -2265,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),
|
||||
@@ -2279,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,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;
|
||||
}
|
||||
|
||||
@@ -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