mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
The customers:* keys were seeded and present in the backoffice constants but
enforced nowhere except reset-password. Customer CRUD sat behind the coarse
edr_freight_app:admin umbrella, and every company read endpoint was unguarded.
Two routes could not be gated on the route alone, because the authority they
need depends on the request BODY, not the path:
- PATCH /companies/:id carries `status` (UpdateCompanyDto extends
PartialType(CreateCompanyDto)), so it both edits fields and blacklists.
- PATCH /company-profiles/:profileId/status is approve, reject, suspend and
blacklist on one route.
Both now take a one-of route guard and assert per-status against a shared
STATUS_PERM map: approving/reactivating needs customers:verify, suspending or
blacklisting needs customers:deactivate. PATCH /companies/:id additionally
requires customers:update when any non-status field is present, so a caller
holding only deactivate cannot rename a company. The backoffice mirrors the
same map so no button is offered that the server would reject.
GET /companies/:companyId/documents is left authenticated-only with the split
in the handler: it is dual-audience. The portal reads its own documents during
onboarding, and the contract-request detail page (gated on contracts:view)
reads the applicant's. Gating it on customers:view alone would have 403'd
customers on their own documents and blanked the contract reviewer's panel.
The two by-company customer-view reads take a one-of guard for the same reason
— otherwise a staffer granted only customers:view gets a detail page whose tabs
403 individually.
Frontend: the customers routes were sidebar-filtered but not wrapped in
RequirePermission, so direct URL navigation rendered them for anyone.
Verified: freight-api type-check clean; backoffice type-check unchanged from
HEAD (pre-existing errors only); 25 tests pass across the companies and
freight-permission suites. Not exercised against a running API.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
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 { 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 { 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 { user } = useAuth();
|
|
const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify);
|
|
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 documentChanges = pending?.documentChanges ?? [];
|
|
|
|
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>
|
|
)}
|
|
|
|
{documentChanges.length > 0 && (
|
|
<Stack gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
Document changes
|
|
</Text>
|
|
{documentChanges.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={() =>
|
|
void fetchViewableFile(
|
|
c.fileId,
|
|
c.fileName ?? humanize(c.code),
|
|
).then(view)
|
|
}
|
|
style={{
|
|
textDecoration:
|
|
c.op === "remove" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{c.fileName ?? humanize(c.code)}
|
|
</Anchor>
|
|
<Text size="xs" c="dimmed">
|
|
{humanize(c.code)}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{docCount > 0 && (
|
|
<Stack gap={8}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
Documents uploaded with this request
|
|
</Text>
|
|
{pending!.documentFileIds.map((fileId, i) => (
|
|
<Group key={fileId} gap={8} wrap="nowrap">
|
|
<FilePlus2 size={15} className="text-edr-muted" />
|
|
<Anchor
|
|
component="button"
|
|
type="button"
|
|
size="sm"
|
|
onClick={() =>
|
|
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
|
|
view,
|
|
)
|
|
}
|
|
>
|
|
Document {i + 1}
|
|
</Anchor>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{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={() =>
|
|
void fetchViewableFile(
|
|
c.fileId,
|
|
c.fileName ?? "License document",
|
|
).then(view)
|
|
}
|
|
style={{
|
|
textDecoration:
|
|
c.op === "remove" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{c.fileName ?? "License document"}
|
|
</Anchor>
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Reviewing the diff is `customers:view`; deciding on it is
|
|
`customers:verify`. Without it the request stays readable but
|
|
un-actionable. */}
|
|
{canReview && (
|
|
<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>
|
|
);
|
|
}
|