Merge branch 'staging' into freight/fix/pay

This commit is contained in:
ghost2023
2026-08-01 14:21:28 +03:00
79 changed files with 2209 additions and 260 deletions

View File

@@ -29,7 +29,6 @@ import {
Upload,
UserCheck,
} from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
@@ -299,7 +298,14 @@ function DocumentRow({
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
{new Date(doc.uploadedAt).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}
</Text>
</Box>
</Group>

View File

@@ -2,7 +2,6 @@ import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Group,
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer";
import type { Company } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
const FIELD_LABELS: Record<string, string> = {
export const FIELD_LABELS: Record<string, string> = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
};
/** Best-effort current value on the live company for a proposed field key. */
function currentValue(company: Company, key: string): string {
export function currentValue(company: Company, key: string): string {
const c = company as unknown as Record<string, unknown>;
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
const map: Record<string, unknown> = {
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
);
}
function DiffRow({
export function DiffRow({
label,
from,
to,
@@ -201,8 +200,9 @@ function DiffRow({
/**
* Backoffice review surface for a customer's staged profile edits. Shows the
* pending change request as a proposed-vs-current diff with Approve / Reject
* (with note) actions, plus a short history of past decisions.
* pending change request as a proposed-vs-current diff with Approve / Reject /
* Request changes actions. Past decisions live in the History tab's unified
* timeline (see {@link CompanyTimeline}), not here.
*/
export function ChangeRequestReview({ company }: { company: Company }) {
const { user } = useAuth();
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const reject = useMutation(
api.customers.rejectChangeRequest.mutationOptions(),
);
const requestChanges = useMutation(
api.customers.requestChangeRequestChanges.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [rejectId, setRejectId] = useState<string | null>(null);
const [actionTarget, setActionTarget] = useState<{
id: string;
kind: "reject" | "request-changes";
} | null>(null);
const [note, setNote] = useState("");
const requests = query.data ?? [];
const pending = requests.find((r) => r.status === "pending");
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
if (!pending && history.length === 0) return null;
if (!pending) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
const licenseChanges = pending?.licenseChanges ?? [];
const documentChanges = pending?.documentChanges ?? [];
const confirmReject = () => {
if (!rejectId) return;
reject.mutate(
{ id: rejectId, note: note.trim() },
const confirmAction = () => {
if (!actionTarget) return;
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
mutation.mutate(
{ id: actionTarget.id, note: note.trim() },
{
onSuccess: () => {
setRejectId(null);
setActionTarget(null);
setNote("");
},
},
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Text>
</Group>
{pending.note && (
<Alert
color="yellow"
variant="light"
icon={<AlertTriangle size={16} />}
>
Changes were requested on an earlier round of this same
submission: <strong>{pending.note}</strong> check whether
this resubmission actually addresses it before approving.
</Alert>
)}
{proposedKeys.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{proposedKeys.map((key) => (
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setActionTarget({ id: pending.id, kind: "reject" });
setNote("");
}}
>
Reject
</Button>
<Button
variant="light"
color="yellow"
onClick={() => {
setActionTarget({ id: pending.id, kind: "request-changes" });
setNote("");
}}
>
Request changes
</Button>
<Button
color="edr-green"
loading={approve.isPending}
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
</Card>
)}
{history.length > 0 && (
<Card withBorder>
<Stack gap="sm">
<Text fw={600} c="edr-text">
Review history
</Text>
{history.map((r: CompanyChangeRequest) => (
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
<Badge
color={r.status === "approved" ? "edr-green" : "red"}
variant="light"
radius="md"
tt="capitalize"
>
{r.status}
</Badge>
<Box style={{ flex: 1 }}>
<Text size="sm" c="edr-text">
{formatDate(r.reviewedAt ?? r.updatedAt)}
</Text>
{r.note && (
<Text size="xs" c="dimmed">
Note: {r.note}
</Text>
)}
</Box>
</Group>
))}
</Stack>
</Card>
)}
<Modal
opened={rejectId !== null}
onClose={() => setRejectId(null)}
title="Reject changes"
opened={actionTarget !== null}
onClose={() => setActionTarget(null)}
title={
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
}
centered
radius="lg"
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
The customer will see this note and can amend and resubmit.
<Alert
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
variant="light"
icon={<AlertTriangle size={18} />}
>
{actionTarget?.kind === "reject"
? "The customer will see this note and can amend and resubmit."
: "The customer will see this note and can keep editing this same request — no need to start over."}
</Alert>
<Textarea
label="Reason for rejection"
label={
actionTarget?.kind === "reject"
? "Reason for rejection"
: "What needs to change"
}
placeholder="e.g. The company address doesn't match the trade license."
autosize
minRows={3}
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectId(null)}
disabled={reject.isPending}
onClick={() => setActionTarget(null)}
disabled={reject.isPending || requestChanges.isPending}
>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
loading={reject.isPending || requestChanges.isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
onClick={confirmAction}
>
Reject changes
{actionTarget?.kind === "reject"
? "Reject changes"
: "Request changes"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,299 @@
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { FilePlus2, FileX2, History } from "lucide-react";
import { useFileViewer } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
CompanyChangeRequest,
CompanyRevision,
CompanyRevisionChange,
DocumentChangeIntent,
LicenseChangeIntent,
} from "@/types/customer";
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
import { formatDate, humanize } from "./format";
interface DocDiff {
key: string;
label: string;
fromFile: { id: string; name: string } | null;
toFile: { id: string; name: string } | null;
}
interface FieldDiff {
key: string;
label: string;
from: string;
to: string;
}
interface TimelineEntry {
id: string;
kind: "approved" | "rejected" | "changes_requested" | "revision";
at: string;
note?: string | null;
summary?: string;
fieldDiffs: FieldDiff[];
docDiffs: DocDiff[];
}
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
approved: { label: "Approved", color: "edr-green" },
rejected: { label: "Rejected", color: "red" },
changes_requested: { label: "Changes requested", color: "yellow" },
revision: { label: "Recorded", color: "blue" },
};
/**
* Pair adjacent remove-then-add intents into one before/after doc diff — a
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
* append after that pair, so adjacency survives. A remove or add with no
* adjacent partner stands alone.
*/
function pairIntents(
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
): DocDiff[] {
const diffs: DocDiff[] = [];
let i = 0;
while (i < intents.length) {
const current = intents[i];
const next = intents[i + 1];
if (current.op === "remove" && next?.op === "add") {
diffs.push({
key: `${current.fileId}-${next.fileId}`,
label: labelFor(next),
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
});
i += 2;
continue;
}
diffs.push({
key: `${current.fileId}-${i}`,
label: labelFor(current),
fromFile:
current.op === "remove"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
toFile:
current.op === "add"
? { id: current.fileId, name: current.fileName ?? "Document" }
: null,
});
i += 1;
}
return diffs;
}
/**
* Historical field diffs on a change request only ever recorded the proposed
* ("to") value — there is no stored "before" snapshot — so `from` reads the
* CURRENT company value. That's exact for the most recent entry; for an older
* one it can drift if the field changed again since. A real limitation of the
* data model, not something this view can reconstruct.
*/
function fromChangeRequest(
r: CompanyChangeRequest,
company: Company,
): TimelineEntry {
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
(k) => k !== "faydaIdentity",
);
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
key,
label: FIELD_LABELS[key] ?? humanize(key),
from: currentValue(company, key),
to:
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
? "—"
: String(r.snapshot[key]),
}));
const docDiffs: DocDiff[] = [
...pairIntents(r.licenseChanges, () => "Business license"),
...pairIntents(r.documentChanges, (c) =>
humanize((c as DocumentChangeIntent).code),
),
...r.documentFileIds.map((fileId, i) => ({
key: fileId,
label: "Document",
fromFile: null,
toFile: { id: fileId, name: `Document ${i + 1}` },
})),
];
return {
id: r.id,
kind: r.status as TimelineEntry["kind"],
at: r.reviewedAt ?? r.updatedAt,
note: r.note,
fieldDiffs,
docDiffs,
};
}
function fromRevision(rev: CompanyRevision): TimelineEntry {
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
const fieldDiffs: FieldDiff[] = rev.changes
.filter((c) => !isDocChange(c))
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
const docDiffs: DocDiff[] = rev.changes
.filter(isDocChange)
.map((c) => ({
key: c.field,
label: c.label,
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
}));
return {
id: rev.id,
kind: "revision",
at: rev.createdAt,
summary: rev.summary,
fieldDiffs,
docDiffs,
};
}
/**
* One combined, chronological timeline of everything that's happened to a
* company's record: onboarding-phase edits (no approval gate, from
* `CompanyRevision`) and post-approval settings changes (reviewed via
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
* lists — merged here into one sorted feed so "what changed and when" has a
* single answer instead of two places to check.
*/
export function CompanyTimeline({ company }: { company: Company }) {
const { view, viewer } = useFileViewer();
const changeRequestsQuery = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
const revisionsQuery = useQuery(
api.customers.revisions.queryOptions({ input: { id: company.id } }),
);
const entries: TimelineEntry[] = [
...(changeRequestsQuery.data ?? [])
.filter((r) => r.status !== "pending")
.map((r) => fromChangeRequest(r, company)),
...(revisionsQuery.data ?? []).map(fromRevision),
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
const openFile = (file: { id: string; name: string }) =>
void fetchViewableFile(file.id, file.name).then(view);
if (entries.length === 0) {
return (
<Card withBorder>
<Stack align="center" gap={6} py="xl">
<History size={24} className="text-edr-muted" />
<Text size="sm" c="dimmed">
No changes recorded yet.
</Text>
</Stack>
</Card>
);
}
return (
<Stack gap="md">
{entries.map((entry) => {
const badge = KIND_BADGE[entry.kind];
return (
<Card key={entry.id} withBorder>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm">
<Badge color={badge.color} variant="light" radius="md">
{badge.label}
</Badge>
{entry.summary && (
<Text size="sm" c="edr-text" tt="capitalize">
{entry.summary}
</Text>
)}
</Group>
<Text size="xs" c="dimmed">
{formatDate(entry.at)}
</Text>
</Group>
{entry.note && (
<Alert color="yellow" variant="light">
<Text size="sm">
<strong>Note:</strong> {entry.note}
</Text>
</Alert>
)}
{entry.fieldDiffs.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{entry.fieldDiffs.map((f) => (
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
))}
</SimpleGrid>
)}
{entry.docDiffs.length > 0 && (
<Stack gap={8}>
{entry.docDiffs.map((d) => (
<Group key={d.key} gap={8} wrap="nowrap">
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{d.label}
</Text>
{d.fromFile && (
<Group gap={4} wrap="nowrap">
<FileX2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
td="line-through"
onClick={() => openFile(d.fromFile!)}
>
{d.fromFile.name}
</Anchor>
</Group>
)}
{d.fromFile && d.toFile && (
<Text size="sm" c="edr-muted">
</Text>
)}
{d.toFile && (
<Group gap={4} wrap="nowrap">
<FilePlus2 size={14} className="text-edr-muted" />
<Anchor
component="button"
type="button"
size="sm"
onClick={() => openFile(d.toFile!)}
>
{d.toFile.name}
</Anchor>
</Group>
)}
</Group>
))}
</Stack>
)}
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
<Text size="sm" c="dimmed">
No details recorded for this entry.
</Text>
)}
</Stack>
</Card>
);
})}
{viewer}
</Stack>
);
}

View File

@@ -13,6 +13,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,
type RequestDocumentChangeModalProps,

View File

@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
["customers", "detail", id, "reset-target"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
revisions: (id: string) =>
["customers", "detail", id, "revisions"] as const,
},
INVOICES: {

View File

@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
`/companies/company-profiles/${profileId}/status`,
CHANGE_REQUESTS: (companyId: string) =>
`/companies/${companyId}/change-requests`,
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
CHANGE_REQUEST_APPROVE: (id: string) =>
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
`/companies/change-requests/${id}/request-changes`,
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
`/companies/documents/${fileId}/request-change`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import "@edr/ui-common/display-timezone";
// The pin must hold on ANY machine timezone — these assertions are the bug:
// before the patch they only passed on a PC already set to UTC+3.
describe("display-timezone pin (EAT, UTC+3)", () => {
const utcMidnight = new Date("2026-01-01T00:00:00Z");
it("formats Date.toLocale* in EAT regardless of machine timezone", () => {
expect(utcMidnight.toLocaleTimeString("en-GB", { hour12: false })).toBe(
"03:00:00",
);
// 22:00 UTC is already the NEXT day in EAT.
expect(new Date("2026-01-01T22:00:00Z").toLocaleDateString("en-CA")).toBe(
"2026-01-02",
);
});
it("formats Intl.DateTimeFormat in EAT and keeps instanceof/statics", () => {
const fmt = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
expect(fmt.format(utcMidnight)).toBe("03:00");
expect(fmt).toBeInstanceOf(Intl.DateTimeFormat);
expect(Intl.DateTimeFormat.supportedLocalesOf(["en-GB"])).toContain(
"en-GB",
);
});
it("respects an explicit timeZone option", () => {
expect(
utcMidnight.toLocaleTimeString("en-GB", {
hour12: false,
timeZone: "UTC",
}),
).toBe("00:00:00");
});
});

View File

@@ -1,3 +1,6 @@
// Must stay the first import: pins all date/time display to EAT before any
// module can create a formatter in the PC's local timezone.
import "@edr/ui-common/display-timezone";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import { format } from "date-fns";
import { Input } from "@/shared/common/ui/input";
import {
Select,
@@ -219,7 +218,7 @@ const buildQuery = (): CollectionQueryDTO => {
<TableCell>{log.message}</TableCell>
<TableCell className="text-muted-foreground">
{log.timestamp && !isNaN(new Date(log.timestamp).getTime())
? format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")
? new Date(log.timestamp).toLocaleString("sv-SE")
: "N/A"}
</TableCell>
</TableRow>

View File

@@ -23,6 +23,7 @@ import {
Download,
Eye,
FileText,
History,
Hourglass,
IdCard,
LayoutGrid,
@@ -40,6 +41,7 @@ import {
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTimeline,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
@@ -64,6 +66,7 @@ import {
} from "@/services/files.service";
import { api } from "@/services/api";
import type {
Company,
CompanyProfile,
CustomerBooking,
CustomerDocument,
@@ -78,6 +81,32 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(company: Company) {
const lines = [
`TIN: ${company.tin}`,
`Company name: ${company.name}`,
`Licence number: ${company.licenceNumber ?? ""}`,
`Status: ${company.statusDescription ?? ""}`,
`Date registered: ${company.dateRegistered ?? ""}`,
`Renewed from: ${company.renewedFrom ?? ""}`,
`Renewal date: ${company.renewalDate ?? ""}`,
`Renewed to: ${company.renewedTo ?? ""}`,
`Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
];
const blob = new Blob([lines.join("\n")], {
type: "text/plain;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tin-${company.tin}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
<Stack gap={2}>
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
<InfoField label="TIN" value={company.tin} />
<InfoField label="VAT number" value={company.vatNumber} />
<InfoField label="FAN number" value={company.fanNumber} />
<InfoField
label="Submitted on"
value={formatDate(company.createdAt)}
/>
<InfoField
label="Approved on"
value={
company.approvedAt
? formatDate(company.approvedAt)
: "Not yet approved"
}
/>
<InfoField
label="Owner identity"
value={
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
<Card>
<Stack gap="lg">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
<Group gap="xs" wrap="nowrap" justify="space-between">
<Group gap="xs" wrap="nowrap">
<Text fw={600} c="edr-text">
eTrade registration
</Text>
{hasEtradeRecord ? (
<Badge size="sm" color="edr-green" variant="light">
Verified with eTrade
</Badge>
) : (
<Badge size="sm" color="gray" variant="light">
No eTrade record
</Badge>
)}
</Group>
{hasEtradeRecord && (
<ActionIcon
variant="default"
aria-label="Download TIN record"
onClick={() => downloadTinRecord(company)}
>
<Download size={16} />
</ActionIcon>
)}
</Group>
{hasEtradeRecord ? (
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
</Box>
</Box>
</Tabs.Panel>
{/* HISTORY */}
<Tabs.Panel value="history" pt="lg">
<CompanyTimeline company={company} />
</Tabs.Panel>
</Tabs>
<RequestDocumentChangeModal

View File

@@ -245,6 +245,16 @@ export default function CustomersPage() {
</Text>
),
},
{
id: "approved",
header: "Approved",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
</Text>
),
},
],
[],
);

View File

@@ -58,10 +58,15 @@ interface CargoNode extends RuleEngineRecord {
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
wagonTypes?: { id: string; code?: string; name?: string }[];
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
itemsPerWagonMap?: Record<string, number> | null;
isActive?: boolean;
displayOrder?: number;
}
/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
const ITEMS_FIT_PREFIX = "itemsFit__";
const str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
@@ -131,15 +136,33 @@ const CargoTypesPage = () => {
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
const formFields = useMemo<FormFieldDef[]>(
() =>
FORM_FIELDS.map((field) =>
field.name === "wagonTypeIds"
? { ...field, options: wagonTypeOptions ?? [] }
: field,
),
[wagonTypeOptions],
);
const formFields = useMemo<FormFieldDef[]>(() => {
const base = FORM_FIELDS.map((field) =>
field.name === "wagonTypeIds"
? { ...field, options: wagonTypeOptions ?? [] }
: field,
);
// PER_ITEM cargo: one "items per wagon" input per SELECTED wagon type — how
// many whole items physically fit that wagon (floor space binds before
// tonnage). Shown only while the wagon type is picked; the API requires a
// fit for every selected type on PER_ITEM cargo.
const fitFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({
name: `${ITEMS_FIT_PREFIX}${opt.value}`,
label: `Items per ${opt.label} wagon`,
type: "number",
required: true,
placeholder: "e.g. 4",
showIf: (values) =>
values.unitOfMeasure === "PER_ITEM" &&
Array.isArray(values.wagonTypeIds) &&
(values.wagonTypeIds as string[]).includes(opt.value),
getInitialValue: (record) =>
(record as CargoNode).itemsPerWagonMap?.[opt.value],
}));
const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
base.splice(wagonTypesAt + 1, 0, ...fitFields);
return base;
}, [wagonTypeOptions]);
const [search, setSearch] = useState("");
const [formMode, setFormMode] = useState<FormMode | null>(null);
@@ -203,7 +226,18 @@ const CargoTypesPage = () => {
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
const handleSubmit = (values: Record<string, unknown>) => {
const payload: Record<string, unknown> = { ...values };
// Fold the per-wagon-type fit inputs into the API's map shape. Null when
// none are visible (not PER_ITEM) so an update clears stale fits.
const payload: Record<string, unknown> = {};
const itemsPerWagonMap: Record<string, number> = {};
for (const [key, value] of Object.entries(values)) {
if (key.startsWith(ITEMS_FIT_PREFIX)) {
itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value);
} else {
payload[key] = value;
}
}
payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null;
// Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id;

View File

@@ -107,7 +107,14 @@ const ReminderList = () => {
Remind {dayjs(reminder.remindAt).fromNow()}
</p>
<span className="text-[11px] text-gray-400">
({dayjs(reminder.remindAt).format("MMM D, h:mm A")})
(
{new Date(reminder.remindAt).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
)
</span>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -2778,6 +2779,13 @@ export const api = {
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
),
revisions: endpoint<{ id: string }, CompanyRevision[]>(
"customers",
"revisions",
({ id }) => customersService.revisions(id),
({ id }) => QUERY_KEYS.CUSTOMERS.revisions(id),
),
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
"customers",
"approveChangeRequest",
@@ -2802,6 +2810,21 @@ export const api = {
],
),
requestChangeRequestChanges: endpoint<
{ id: string; note: string },
CompanyChangeRequest
>(
"customers",
"requestChangeRequestChanges",
({ id, note }) => customersService.requestChangeRequestChanges(id, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
/**
* Ask the customer to correct one document. Invalidates the documents list
* and the company itself, since an open request blocks role approval.

View File

@@ -5,6 +5,7 @@ import type {
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyRevision,
CompanyStats,
CustomerBooking,
CustomerDocument,
@@ -146,6 +147,13 @@ export const customersService = {
.then((r) => r.data);
},
/** Onboarding-phase edit history for a company (version history), newest first. */
revisions(companyId: string): Promise<CompanyRevision[]> {
return apiClient
.get<CompanyRevision[]>(URL_CONSTANTS.COMPANIES.REVISIONS(companyId))
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
@@ -165,6 +173,19 @@ export const customersService = {
.then((r) => r.data);
},
/** Ask for specific changes without rejecting — the request stays open for the customer's next edit to append to. */
requestChangeRequestChanges(
id: string,
note: string,
): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REQUEST_CHANGES(id),
{ note },
)
.then((r) => r.data);
},
/**
* Ask the customer to correct one uploaded document. Narrower than rejecting
* the whole role: the customer keeps their other documents and only re-uploads

View File

@@ -1,9 +1,7 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
import { format } from "date-fns";
import {
Clock,
User,
@@ -101,8 +99,14 @@ export function ActivityCard({ activity }: { activity: ActivityCardProps }) {
const iconBg = "bg-gray-100 dark:bg-gray-800";
const whiteBg = "bg-white dark:bg-gray-900";
const formattedDate = format(new Date(activity.timestamp), "MMM d, yyyy");
const formattedTime = format(new Date(activity.timestamp), "HH:mm:ss");
const formattedDate = new Date(activity.timestamp).toLocaleDateString(
"en-US",
{ month: "short", day: "numeric", year: "numeric" },
);
const formattedTime = new Date(activity.timestamp).toLocaleTimeString(
"en-GB",
{ hour12: false },
);
return (
<Card

View File

@@ -213,7 +213,6 @@ export default function AuditLogPageShared({
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
const primaryBtn =
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
const primaryIcon = "text-gray-900 dark:text-gray-100";
const getSeverityColor = (severity: string) => {
switch (severity) {
@@ -472,10 +471,14 @@ export default function AuditLogPageShared({
textSubtle,
)}>
<span className="whitespace-nowrap">
{format(new Date(log.timestamp), "MMM d, yyyy")}
{new Date(log.timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span className="whitespace-nowrap">
{format(new Date(log.timestamp), "HH:mm:ss")}
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
</span>
<span className="inline-flex items-center gap-2 min-w-0">
<span className={cn(textSubtle)}></span>
@@ -817,13 +820,17 @@ export default function AuditLogPageShared({
<TableCell className="font-medium">
<div className="flex flex-col">
<span className={cn("text-sm", textStrong)}>
{format(
new Date(log.timestamp),
"MMM d, yyyy",
{new Date(log.timestamp).toLocaleDateString(
"en-US",
{
month: "short",
day: "numeric",
year: "numeric",
},
)}
</span>
<span className={cn("text-xs", textSubtle)}>
{format(new Date(log.timestamp), "HH:mm:ss")}
{new Date(log.timestamp).toLocaleTimeString("en-GB", { hour12: false })}
</span>
</div>
</TableCell>

View File

@@ -69,7 +69,11 @@ export interface CompanyProfile {
}
/** Lifecycle of a staged customer profile-edit review. */
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
export type ChangeRequestStatus =
| "pending"
| "approved"
| "rejected"
| "changes_requested";
/** A staged business-license add/remove on one profile, awaiting review. */
export interface LicenseChangeIntent {
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
updatedAt: string;
}
/**
* One field/document change recorded on a company revision. A document change
* carries `fromFileId`/`toFileId` alongside the display names, so the
* previous and current file can both be opened, not just named.
*/
export interface CompanyRevisionChange {
field: string;
label: string;
from: string | null;
to: string | null;
fromFileId?: string | null;
toFileId?: string | null;
}
/**
* Onboarding-phase edit history — records what changed on a company record
* before it reached Active, the write path that has no approval gate.
*/
export interface CompanyRevision {
id: string;
companyId: string;
actorId: string | null;
summary: string;
changes: CompanyRevisionChange[];
createdAt: string;
}
/** The channel a customer's password-reset link is delivered over. */
export type ResetChannel = "email" | "phone";
@@ -215,6 +246,7 @@ export interface Company {
onboardingCompleted?: boolean;
createdAt: string;
updatedAt: string;
approvedAt?: string | null;
}
/**

View File

@@ -14,7 +14,7 @@
"dependencies": {
"@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*",
"@hookform/resolvers": "^5.4.0",
"@hookform/resolvers": "^5.6.0",
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",

View File

@@ -28,6 +28,31 @@ interface ETradeInfoProps {
const isValidTin = (tin: string) => tin.length === 10;
/** Plain-text summary of the fetched eTrade record, downloaded client-side (eTrade returns data, not a document). */
function downloadTinRecord(tin: string, data: CompanyRegistrationData) {
const lines = [
`TIN: ${tin}`,
`Company name: ${data.companyName}`,
`Licence number: ${data.licenceNumber}`,
`Status: ${data.statusDescription}`,
`Date registered: ${data.dateRegistered}`,
`Renewed from: ${data.renewedFrom}`,
`Renewal date: ${data.renewalDate}`,
`Renewed to: ${data.renewedTo}`,
`Address: ${[data.region, data.zone, data.woreda, data.kebele, data.houseNo].filter(Boolean).join(", ")}`,
`Manager: ${data.managerName}`,
];
const blob = new Blob([lines.join("\n")], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `tin-${tin}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export default function ETradeInfo({
tin,
register,
@@ -123,6 +148,17 @@ export default function ETradeInfo({
{isLoading ? "Getting..." : "Get Data"}
</Button>
)}
{status === "verified" && mutation.data && !mutation.data.tinTaken && (
<Button
variant="light"
color="edr-green"
onClick={() => downloadTinRecord(tin, mutation.data!)}
leftSection={<Download size={16} />}
mt="24px"
>
Download
</Button>
)}
</Group>
{notFound && (

View File

@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { AlertTriangle, ArrowRight, Clock } from "lucide-react";
import { AlertTriangle, ArrowRight, CheckCircle2, Clock } from "lucide-react";
import { api } from "@/services/api";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
@@ -150,18 +150,46 @@ export default function OnboardingResumeBanner({
/**
* Post-onboarding review banner. Surfaces (in priority order):
* 0. The account is suspended/blacklisted — hard lock.
* 1. A pending profile-edit review — the whole account is locked until an admin
* approves the submitted changes.
* 2. A rejected profile-edit review — links to Settings to amend & resubmit.
* 3. Per-operational-profile approval — bookings unlock as each role clears.
* 2. Backoffice requested changes — soft: same edit-and-resubmit call to
* action as a rejection, but the edit appends to the same request.
* 3. A rejected profile-edit review — links to Settings to amend & resubmit
* (starts a fresh request).
* 4/5. Company- and per-operational-profile approval — bookings unlock as
* each role clears.
* Self-hides when there's nothing outstanding.
*/
export function AccountReviewBanner() {
const { company, reviewStatus, reviewNote } = useAuth();
const { company, companyStatus, reviewStatus, reviewNote } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
// 0. Account suspended/blacklisted — the hardest lock, takes priority over
// everything else since nothing below matters if the account is shut down.
if (companyStatus === "suspended" || companyStatus === "blacklisted") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-red-900">
Your account has been suspended
</span>
<span className="text-xs text-red-800">
Contact EDR support to resolve this before you can continue
working.
</span>
</span>
</div>
</div>
);
}
// 1. Profile-edit review pending — the account-wide lock.
if (reviewStatus === "pending") {
return (
@@ -184,7 +212,41 @@ export function AccountReviewBanner() {
);
}
// 2. Profile-edit review rejected — prompt to fix & resubmit.
// 2. Backoffice asked for specific changes — soft: same edit-and-resubmit
// call to action as a rejection, but the copy stays collaborative since
// the edit appends to this same request instead of starting over.
if (reviewStatus === "changes_requested") {
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<AlertTriangle size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Changes requested on your submission
</span>
<span className="text-xs text-amber-800">
{reviewNote
? `Reviewer note: ${reviewNote}`
: "Please update the requested details and resubmit for review."}
</span>
</span>
</div>
<Link
to="/settings"
className="inline-flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-transform hover:scale-[1.02]"
>
Review &amp; resubmit
<ArrowRight size={16} />
</Link>
</div>
</div>
);
}
// 3. Profile-edit review rejected — prompt to fix & resubmit.
if (reviewStatus === "rejected") {
return (
<div className="border-b border-red-200 bg-red-50 px-6 py-3">
@@ -216,8 +278,45 @@ export function AccountReviewBanner() {
);
}
// 3. Per-operational-profile approval (existing behaviour).
if (profiles.length === 0 || pending.length === 0) return null;
// 4. Company approved and awaiting its first operational profile — nothing
// profile-specific to report yet, but the account itself is pending.
if (profiles.length === 0) {
if (companyStatus === "pending") {
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="text-sm font-semibold text-amber-900">
Your account is pending approval
</span>
</div>
</div>
);
}
return null;
}
// 5. Per-operational-profile approval (existing behaviour).
if (pending.length === 0) {
// Nothing outstanding — a quiet confirmation that the account is live.
if (companyStatus === "active") {
return (
<div className="border-b border-emerald-200 bg-emerald-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
<CheckCircle2 size={18} />
</span>
<span className="text-sm font-semibold text-emerald-900">
Your account is approved
</span>
</div>
</div>
);
}
return null;
}
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))

View File

@@ -130,6 +130,8 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
CUSTOMER_TRUCKS_BULK: (id: string) =>
`/api/bookings/${id}/customer-trucks/bulk`,
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
},

View File

@@ -1,3 +1,6 @@
// Must stay the first import: pins all date/time display to EAT before any
// module can create a formatter in the PC's local timezone.
import "@edr/ui-common/display-timezone";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";

View File

@@ -1,5 +1,4 @@
import { Box, Group, Text } from "@mantine/core";
import { format } from "date-fns";
import { memo } from "react";
import { STATUS_CONFIG, cv } from "../constants";
@@ -56,7 +55,10 @@ export const ActivityRow = memo(function ActivityRow({
</Text>
</Box>
<Text fz={11} c="edr-muted" className="shrink-0">
{format(new Date(booking.createdAt), "MMM d")}
{new Date(booking.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</Text>
</Group>
);

View File

@@ -294,6 +294,26 @@ export default function SettingsPage() {
Attorney stay editable.
</Alert>
)}
{reviewStatus === "changes_requested" && (
<Alert
color="yellow"
variant="light"
icon={<AlertTriangle size={18} />}
title="Changes requested on your submission"
>
<Stack gap={4}>
{profile.reviewNote && (
<Text size="sm">
<strong>Reviewer note:</strong> {profile.reviewNote}
</Text>
)}
<Text size="sm">
Please update the requested details below and save again to
resubmit for review.
</Text>
</Stack>
</Alert>
)}
{reviewStatus === "rejected" && (
<Alert
color="red"

View File

@@ -106,7 +106,8 @@ function Countdown({
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
})}{" "}
EAT
</Text>
{onPay && (
<Button

View File

@@ -4,6 +4,7 @@ import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucid
import { useMutation } from "@tanstack/react-query";
import { client } from "@/utils/api";
import { URL_CONSTANTS } from "@/constants/URLS";
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
interface BulkTruckUploadModalProps {
@@ -32,7 +33,7 @@ export function BulkTruckUploadModal({
const uploadMutation = useMutation({
mutationFn: async () => {
const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
trucks: parsed,
});
return data;

View File

@@ -68,6 +68,7 @@ function formatPriceUnit(unit: string): string {
const map: Record<string, string> = {
PER_CONTAINER: "per container",
PER_TON: "per ton",
PER_ITEM: "per item",
PER_WAGON: "per wagon",
PER_KM: "per km",
FLAT: "flat",

View File

@@ -10,7 +10,6 @@ import {
Text,
Textarea,
} from "@mantine/core";
import { format } from "date-fns";
import {
Calendar,
CheckCircle2,
@@ -239,7 +238,12 @@ export function Step8Review({
values.destinationYard;
const scheduleLabel = values.scheduledDate
? format(new Date(values.scheduledDate), "EEEE, MMM d, yyyy")
? new Date(values.scheduledDate).toLocaleDateString("en-US", {
weekday: "long",
month: "short",
day: "numeric",
year: "numeric",
})
: "—";
const directionLabel = direction

View File

@@ -94,10 +94,12 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
* contract/booking creation; `rejected` surfaces the note for reapply.
* contract/booking creation; `rejected`/`changes_requested` both surface the
* note for reapply — `changes_requested` just means the edit appends to the
* same request instead of starting a fresh one.
*/
review?: {
status: "pending" | "rejected";
status: "pending" | "rejected" | "changes_requested";
note: string | null;
} | null;
}
@@ -106,7 +108,7 @@ export interface CompanyInfoResponse {
export interface ChangeRequestResponse {
id: string;
companyId: string;
status: "pending" | "approved" | "rejected";
status: "pending" | "approved" | "rejected" | "changes_requested";
snapshot: Record<string, any>;
documentFileIds: string[];
note: string | null;

View File

@@ -51,10 +51,12 @@ export interface ProfileResponse {
profileId: string;
/**
* Open profile-edit review. `pending` → the settings page is read-only until an
* admin decides; `rejected` → the note explains why and the forms prefill the
* declined values so the customer can amend & resubmit.
* admin decides; `rejected`/`changes_requested` → the note explains why and
* the forms prefill the declined values so the customer can amend & resubmit
* (`changes_requested` appends that edit to this same request instead of
* starting a fresh one).
*/
reviewStatus?: "pending" | "rejected" | null;
reviewStatus?: "pending" | "rejected" | "changes_requested" | null;
reviewNote?: string | null;
pendingChanges?: Record<string, any> | null;
}