mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge pull request #1057 from Tria-plc/freight/chore/payment-test
Freight/chore/payment test
This commit is contained in:
@@ -2,7 +2,6 @@ import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
@@ -27,11 +26,11 @@ import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import type { Company } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
@@ -69,7 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
export function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
@@ -160,7 +159,7 @@ function FaydaIdentityDiff({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
export function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
@@ -201,8 +200,9 @@ function DiffRow({
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject /
|
||||
* Request changes actions. Past decisions live in the History tab's unified
|
||||
* timeline (see {@link CompanyTimeline}), not here.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const { user } = useAuth();
|
||||
@@ -216,16 +216,21 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
const requestChanges = useMutation(
|
||||
api.customers.requestChangeRequestChanges.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [actionTarget, setActionTarget] = useState<{
|
||||
id: string;
|
||||
kind: "reject" | "request-changes";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
if (!pending) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
@@ -237,13 +242,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
const confirmAction = () => {
|
||||
if (!actionTarget) return;
|
||||
const mutation = actionTarget.kind === "reject" ? reject : requestChanges;
|
||||
mutation.mutate(
|
||||
{ id: actionTarget.id, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setActionTarget(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
@@ -270,6 +276,18 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{pending.note && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
>
|
||||
Changes were requested on an earlier round of this same
|
||||
submission: <strong>{pending.note}</strong> — check whether
|
||||
this resubmission actually addresses it before approving.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
@@ -418,12 +436,22 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setActionTarget({ id: pending.id, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
@@ -437,51 +465,31 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
opened={actionTarget !== null}
|
||||
onClose={() => setActionTarget(null)}
|
||||
title={
|
||||
actionTarget?.kind === "reject" ? "Reject changes" : "Request changes"
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
<Alert
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
>
|
||||
{actionTarget?.kind === "reject"
|
||||
? "The customer will see this note and can amend and resubmit."
|
||||
: "The customer will see this note and can keep editing this same request — no need to start over."}
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
label={
|
||||
actionTarget?.kind === "reject"
|
||||
? "Reason for rejection"
|
||||
: "What needs to change"
|
||||
}
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
@@ -492,18 +500,20 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
onClick={() => setActionTarget(null)}
|
||||
disabled={reject.isPending || requestChanges.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
color={actionTarget?.kind === "reject" ? "red" : "yellow"}
|
||||
loading={reject.isPending || requestChanges.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
onClick={confirmAction}
|
||||
>
|
||||
Reject changes
|
||||
{actionTarget?.kind === "reject"
|
||||
? "Reject changes"
|
||||
: "Request changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilePlus2, FileX2, History } from "lucide-react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fetchViewableFile } from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
DocumentChangeIntent,
|
||||
LicenseChangeIntent,
|
||||
} from "@/types/customer";
|
||||
import { DiffRow, FIELD_LABELS, currentValue } from "./ChangeRequestReview";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
interface DocDiff {
|
||||
key: string;
|
||||
label: string;
|
||||
fromFile: { id: string; name: string } | null;
|
||||
toFile: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
interface FieldDiff {
|
||||
key: string;
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string;
|
||||
kind: "approved" | "rejected" | "changes_requested" | "revision";
|
||||
at: string;
|
||||
note?: string | null;
|
||||
summary?: string;
|
||||
fieldDiffs: FieldDiff[];
|
||||
docDiffs: DocDiff[];
|
||||
}
|
||||
|
||||
const KIND_BADGE: Record<TimelineEntry["kind"], { label: string; color: string }> = {
|
||||
approved: { label: "Approved", color: "edr-green" },
|
||||
rejected: { label: "Rejected", color: "red" },
|
||||
changes_requested: { label: "Changes requested", color: "yellow" },
|
||||
revision: { label: "Recorded", color: "blue" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pair adjacent remove-then-add intents into one before/after doc diff — a
|
||||
* "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together
|
||||
* (see `replaceProfileLicenseFile` and friends), and later merges only ever
|
||||
* append after that pair, so adjacency survives. A remove or add with no
|
||||
* adjacent partner stands alone.
|
||||
*/
|
||||
function pairIntents(
|
||||
intents: (LicenseChangeIntent | DocumentChangeIntent)[],
|
||||
labelFor: (intent: LicenseChangeIntent | DocumentChangeIntent) => string,
|
||||
): DocDiff[] {
|
||||
const diffs: DocDiff[] = [];
|
||||
let i = 0;
|
||||
while (i < intents.length) {
|
||||
const current = intents[i];
|
||||
const next = intents[i + 1];
|
||||
if (current.op === "remove" && next?.op === "add") {
|
||||
diffs.push({
|
||||
key: `${current.fileId}-${next.fileId}`,
|
||||
label: labelFor(next),
|
||||
fromFile: { id: current.fileId, name: current.fileName ?? "Document" },
|
||||
toFile: { id: next.fileId, name: next.fileName ?? "Document" },
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
diffs.push({
|
||||
key: `${current.fileId}-${i}`,
|
||||
label: labelFor(current),
|
||||
fromFile:
|
||||
current.op === "remove"
|
||||
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||
: null,
|
||||
toFile:
|
||||
current.op === "add"
|
||||
? { id: current.fileId, name: current.fileName ?? "Document" }
|
||||
: null,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical field diffs on a change request only ever recorded the proposed
|
||||
* ("to") value — there is no stored "before" snapshot — so `from` reads the
|
||||
* CURRENT company value. That's exact for the most recent entry; for an older
|
||||
* one it can drift if the field changed again since. A real limitation of the
|
||||
* data model, not something this view can reconstruct.
|
||||
*/
|
||||
function fromChangeRequest(
|
||||
r: CompanyChangeRequest,
|
||||
company: Company,
|
||||
): TimelineEntry {
|
||||
const proposedKeys = Object.keys(r.snapshot ?? {}).filter(
|
||||
(k) => k !== "faydaIdentity",
|
||||
);
|
||||
const fieldDiffs: FieldDiff[] = proposedKeys.map((key) => ({
|
||||
key,
|
||||
label: FIELD_LABELS[key] ?? humanize(key),
|
||||
from: currentValue(company, key),
|
||||
to:
|
||||
r.snapshot[key] === null || r.snapshot[key] === undefined || r.snapshot[key] === ""
|
||||
? "—"
|
||||
: String(r.snapshot[key]),
|
||||
}));
|
||||
|
||||
const docDiffs: DocDiff[] = [
|
||||
...pairIntents(r.licenseChanges, () => "Business license"),
|
||||
...pairIntents(r.documentChanges, (c) =>
|
||||
humanize((c as DocumentChangeIntent).code),
|
||||
),
|
||||
...r.documentFileIds.map((fileId, i) => ({
|
||||
key: fileId,
|
||||
label: "Document",
|
||||
fromFile: null,
|
||||
toFile: { id: fileId, name: `Document ${i + 1}` },
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.status as TimelineEntry["kind"],
|
||||
at: r.reviewedAt ?? r.updatedAt,
|
||||
note: r.note,
|
||||
fieldDiffs,
|
||||
docDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
function fromRevision(rev: CompanyRevision): TimelineEntry {
|
||||
const isDocChange = (c: CompanyRevisionChange) => c.field.startsWith("document:");
|
||||
const fieldDiffs: FieldDiff[] = rev.changes
|
||||
.filter((c) => !isDocChange(c))
|
||||
.map((c) => ({ key: c.field, label: c.label, from: c.from ?? "—", to: c.to ?? "—" }));
|
||||
const docDiffs: DocDiff[] = rev.changes
|
||||
.filter(isDocChange)
|
||||
.map((c) => ({
|
||||
key: c.field,
|
||||
label: c.label,
|
||||
fromFile: c.fromFileId ? { id: c.fromFileId, name: c.from ?? "Document" } : null,
|
||||
toFile: c.toFileId ? { id: c.toFileId, name: c.to ?? "Document" } : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: rev.id,
|
||||
kind: "revision",
|
||||
at: rev.createdAt,
|
||||
summary: rev.summary,
|
||||
fieldDiffs,
|
||||
docDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One combined, chronological timeline of everything that's happened to a
|
||||
* company's record: onboarding-phase edits (no approval gate, from
|
||||
* `CompanyRevision`) and post-approval settings changes (reviewed via
|
||||
* `CompanyChangeRequest`) used to live in two separate, differently-shaped
|
||||
* lists — merged here into one sorted feed so "what changed and when" has a
|
||||
* single answer instead of two places to check.
|
||||
*/
|
||||
export function CompanyTimeline({ company }: { company: Company }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
const changeRequestsQuery = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
const revisionsQuery = useQuery(
|
||||
api.customers.revisions.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
|
||||
const entries: TimelineEntry[] = [
|
||||
...(changeRequestsQuery.data ?? [])
|
||||
.filter((r) => r.status !== "pending")
|
||||
.map((r) => fromChangeRequest(r, company)),
|
||||
...(revisionsQuery.data ?? []).map(fromRevision),
|
||||
].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
|
||||
const openFile = (file: { id: string; name: string }) =>
|
||||
void fetchViewableFile(file.id, file.name).then(view);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack align="center" gap={6} py="xl">
|
||||
<History size={24} className="text-edr-muted" />
|
||||
<Text size="sm" c="dimmed">
|
||||
No changes recorded yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{entries.map((entry) => {
|
||||
const badge = KIND_BADGE[entry.kind];
|
||||
return (
|
||||
<Card key={entry.id} withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm">
|
||||
<Badge color={badge.color} variant="light" radius="md">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
{entry.summary && (
|
||||
<Text size="sm" c="edr-text" tt="capitalize">
|
||||
{entry.summary}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(entry.at)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{entry.note && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">
|
||||
<strong>Note:</strong> {entry.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{entry.fieldDiffs.map((f) => (
|
||||
<DiffRow key={f.key} label={f.label} from={f.from} to={f.to} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{entry.docDiffs.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
{entry.docDiffs.map((d) => (
|
||||
<Group key={d.key} gap={8} wrap="nowrap">
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{d.label}
|
||||
</Text>
|
||||
{d.fromFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FileX2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
td="line-through"
|
||||
onClick={() => openFile(d.fromFile!)}
|
||||
>
|
||||
{d.fromFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
{d.fromFile && d.toFile && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
{d.toFile && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<FilePlus2 size={14} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => openFile(d.toFile!)}
|
||||
>
|
||||
{d.toFile.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{entry.fieldDiffs.length === 0 && entry.docDiffs.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details recorded for this entry.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
type RequestDocumentChangeModalProps,
|
||||
|
||||
@@ -42,6 +42,8 @@ export const QUERY_KEYS = {
|
||||
["customers", "detail", id, "reset-target"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
revisions: (id: string) =>
|
||||
["customers", "detail", id, "revisions"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
|
||||
@@ -78,10 +78,13 @@ export const URL_CONSTANTS = {
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
CHANGE_REQUESTS: (companyId: string) =>
|
||||
`/companies/${companyId}/change-requests`,
|
||||
REVISIONS: (companyId: string) => `/companies/${companyId}/revisions`,
|
||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
CHANGE_REQUEST_REQUEST_CHANGES: (id: string) =>
|
||||
`/companies/change-requests/${id}/request-changes`,
|
||||
DOCUMENT_REQUEST_CHANGE: (fileId: string) =>
|
||||
`/companies/documents/${fileId}/request-change`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTimeline,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
@@ -64,6 +66,7 @@ import {
|
||||
} from "@/services/files.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
Company,
|
||||
CompanyProfile,
|
||||
CustomerBooking,
|
||||
CustomerDocument,
|
||||
@@ -78,6 +81,32 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/** Plain-text summary of the company's eTrade-sourced record, downloaded client-side (eTrade returns data, not a document). */
|
||||
function downloadTinRecord(company: Company) {
|
||||
const lines = [
|
||||
`TIN: ${company.tin}`,
|
||||
`Company name: ${company.name}`,
|
||||
`Licence number: ${company.licenceNumber ?? ""}`,
|
||||
`Status: ${company.statusDescription ?? ""}`,
|
||||
`Date registered: ${company.dateRegistered ?? ""}`,
|
||||
`Renewed from: ${company.renewedFrom ?? ""}`,
|
||||
`Renewal date: ${company.renewalDate ?? ""}`,
|
||||
`Renewed to: ${company.renewedTo ?? ""}`,
|
||||
`Address: ${[company.region, company.zone, company.woreda, company.kebele, company.houseNo].filter(Boolean).join(", ")}`,
|
||||
];
|
||||
const blob = new Blob([lines.join("\n")], {
|
||||
type: "text/plain;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `tin-${company.tin}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -689,6 +718,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* OVERVIEW */}
|
||||
@@ -757,6 +789,18 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="TIN" value={company.tin} />
|
||||
<InfoField label="VAT number" value={company.vatNumber} />
|
||||
<InfoField label="FAN number" value={company.fanNumber} />
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Approved on"
|
||||
value={
|
||||
company.approvedAt
|
||||
? formatDate(company.approvedAt)
|
||||
: "Not yet approved"
|
||||
}
|
||||
/>
|
||||
<InfoField
|
||||
label="Owner identity"
|
||||
value={
|
||||
@@ -800,18 +844,29 @@ export default function CustomerDetailPage() {
|
||||
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text fw={600} c="edr-text">
|
||||
eTrade registration
|
||||
</Text>
|
||||
{hasEtradeRecord ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="gray" variant="light">
|
||||
No eTrade record
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
aria-label="Download TIN record"
|
||||
onClick={() => downloadTinRecord(company)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
{hasEtradeRecord ? (
|
||||
@@ -1235,6 +1290,11 @@ export default function CustomerDetailPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<RequestDocumentChangeModal
|
||||
|
||||
@@ -245,6 +245,16 @@ export default function CustomersPage() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
header: "Approved",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,7 +69,11 @@ export interface CompanyProfile {
|
||||
}
|
||||
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||
export type ChangeRequestStatus =
|
||||
| "pending"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "changes_requested";
|
||||
|
||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||
export interface LicenseChangeIntent {
|
||||
@@ -110,6 +114,33 @@ export interface CompanyChangeRequest {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One field/document change recorded on a company revision. A document change
|
||||
* carries `fromFileId`/`toFileId` alongside the display names, so the
|
||||
* previous and current file can both be opened, not just named.
|
||||
*/
|
||||
export interface CompanyRevisionChange {
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
fromFileId?: string | null;
|
||||
toFileId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding-phase edit history — records what changed on a company record
|
||||
* before it reached Active, the write path that has no approval gate.
|
||||
*/
|
||||
export interface CompanyRevision {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The channel a customer's password-reset link is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
@@ -215,6 +246,7 @@ export interface Company {
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user