mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
631 lines
15 KiB
TypeScript
631 lines
15 KiB
TypeScript
import type { Freight } from "@edr/types";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
import { api } from "@/services/api";
|
|
|
|
import type {
|
|
CompanyNationality,
|
|
CompanyProfile,
|
|
CompanyStatus,
|
|
CompanyType,
|
|
CustomerBookingStatus,
|
|
CustomerPaymentStatus,
|
|
ProfileStatus,
|
|
ProfileType,
|
|
} from "@/types/customer";
|
|
|
|
import { humanize } from "./format";
|
|
|
|
const badgeStyle = {
|
|
fontSize: "0.7rem",
|
|
letterSpacing: "0.04em",
|
|
whiteSpace: "nowrap" as const,
|
|
};
|
|
|
|
/** Shared status palette — active/paid green, pending amber, terminal red. */
|
|
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
|
active: "edr-green",
|
|
pending: "yellow",
|
|
rejected: "red",
|
|
suspended: "orange",
|
|
blacklisted: "red",
|
|
};
|
|
|
|
const COMPANY_TYPE_COLOR: Record<CompanyType, string> = {
|
|
customer: "edr-green",
|
|
freight_forwarder: "blue",
|
|
dj_freight_forwarder: "indigo",
|
|
transporter: "grape",
|
|
};
|
|
|
|
const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
|
|
importer: "teal",
|
|
exporter: "cyan",
|
|
freight_forwarder: "blue",
|
|
dj_freight_forwarder: "indigo",
|
|
transporter: "grape",
|
|
};
|
|
|
|
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
|
|
return (
|
|
<Badge
|
|
color={STATUS_COLOR[status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
tt="capitalize"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{status}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function CompanyTypeBadge({ type }: { type: CompanyType }) {
|
|
return (
|
|
<Badge
|
|
color={COMPANY_TYPE_COLOR[type] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(type)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const NATIONALITY_COLOR: Record<CompanyNationality, string> = {
|
|
ethiopian: "edr-green",
|
|
foreign: "blue",
|
|
};
|
|
|
|
export function CompanyNationalityBadge({
|
|
nationality,
|
|
}: {
|
|
nationality?: CompanyNationality | null;
|
|
}) {
|
|
if (!nationality) return null;
|
|
return (
|
|
<Badge
|
|
color={NATIONALITY_COLOR[nationality] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(nationality)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The company's registration was typed, not fetched from eTrade — nothing in it
|
|
* has been checked against a licence. Loud on purpose: it is the one thing a
|
|
* reviewer must not miss about this customer. Two kinds of company land here
|
|
* for different reasons, and the badge names which.
|
|
*/
|
|
export function ManualRegistrationBadge({
|
|
cooperative,
|
|
investorLicence,
|
|
}: {
|
|
cooperative?: boolean | null;
|
|
investorLicence?: boolean | null;
|
|
}) {
|
|
if (!cooperative && !investorLicence) return null;
|
|
return (
|
|
<Badge
|
|
color="orange"
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{cooperative
|
|
? "Manual entry · co-operative"
|
|
: "Manual entry · investment licence"}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
|
|
* carrying its reference code, colored by the profile's status (green active,
|
|
* amber pending, red rejected/blacklisted). Caps at three (a company has at
|
|
* most three profiles); any extra collapse into a `+N` chip.
|
|
*/
|
|
export function ProfileChips({
|
|
profiles,
|
|
max = 3,
|
|
}: {
|
|
profiles: CompanyProfile[];
|
|
max?: number;
|
|
}) {
|
|
if (!profiles.length) {
|
|
return (
|
|
<Badge
|
|
color="gray"
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
style={badgeStyle}
|
|
>
|
|
No profiles
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const shown = profiles.slice(0, max);
|
|
const extra = profiles.length - shown.length;
|
|
|
|
return (
|
|
<Group gap={6} wrap="wrap">
|
|
{shown.map((profile) => (
|
|
<Tooltip
|
|
key={profile.id}
|
|
label={`${humanize(profile.type)} · ${humanize(profile.status)}`}
|
|
withArrow
|
|
>
|
|
<Badge
|
|
color={STATUS_COLOR[profile.status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(profile.type)}
|
|
{profile.reference ? ` · ${profile.reference}` : ""}
|
|
</Badge>
|
|
</Tooltip>
|
|
))}
|
|
{extra > 0 ? (
|
|
<Badge
|
|
color="gray"
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
style={badgeStyle}
|
|
>
|
|
+{extra}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
export function ProfileTypeBadge({ type }: { type: ProfileType }) {
|
|
return (
|
|
<Badge
|
|
color={PROFILE_TYPE_COLOR[type] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(type)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function ProfileStatusBadge({ status }: { status: ProfileStatus }) {
|
|
return (
|
|
<Badge
|
|
color={STATUS_COLOR[status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
tt="capitalize"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{status}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
|
DRAFT: "gray",
|
|
SUBMITTED: "yellow",
|
|
PENDING_APPROVAL: "yellow",
|
|
APPROVED: "cyan",
|
|
PAID: "edr-green",
|
|
IN_TRANSIT: "blue",
|
|
ARRIVED: "teal",
|
|
COMPLETED: "indigo",
|
|
REJECTED: "red",
|
|
CANCELLED: "red",
|
|
};
|
|
|
|
export function BookingStatusBadge({
|
|
status,
|
|
}: {
|
|
status: CustomerBookingStatus;
|
|
}) {
|
|
return (
|
|
<Badge
|
|
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
tt="uppercase"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(status)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
|
"action-required": "orange",
|
|
processing: "yellow",
|
|
success: "edr-green",
|
|
failed: "red",
|
|
canceled: "gray",
|
|
refunded: "grape",
|
|
};
|
|
|
|
export function PaymentStatusBadge({
|
|
status,
|
|
}: {
|
|
status: CustomerPaymentStatus;
|
|
}) {
|
|
return (
|
|
<Badge
|
|
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
tt="capitalize"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(status)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
|
DRAFT: "gray",
|
|
ISSUED: "cyan",
|
|
PENDING: "yellow",
|
|
PAYMENT_PROCESSING: "indigo",
|
|
PARTIALLY_PAID: "orange",
|
|
PAID: "edr-green",
|
|
OVERDUE: "red",
|
|
CANCELLED: "gray",
|
|
REFUNDED: "grape",
|
|
EXPIRED: "red",
|
|
};
|
|
|
|
export function InvoiceStatusBadge({
|
|
status,
|
|
}: {
|
|
status: Freight.InvoiceStatus;
|
|
}) {
|
|
return (
|
|
<Badge
|
|
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
|
|
variant="light"
|
|
size="sm"
|
|
radius="md"
|
|
tt="capitalize"
|
|
fw={600}
|
|
style={badgeStyle}
|
|
>
|
|
{humanize(status)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inline approval action buttons for a profile row.
|
|
* Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) |
|
|
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
|
* Rejecting captures a note the customer sees so they can fix and reapply — a
|
|
* rejected role is theirs to resubmit, so it cannot be approved from here until
|
|
* they do (the API refuses it); undoing the rejection is the only way back.
|
|
*
|
|
* `locked` (customer hasn't submitted onboarding) withholds the review decision
|
|
* only — there's no application to judge yet, and the API rejects the call
|
|
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
|
* an already-active profile is still managable.
|
|
*/
|
|
/**
|
|
* Which permission each status write needs. Mirrors `STATUS_PERM` in the API's
|
|
* `companies.controller.ts` — approving is a different authority from
|
|
* suspending, and both go through the same endpoint. Keep the two in step.
|
|
*/
|
|
const STATUS_PERM: Record<ProfileStatus, string> = {
|
|
active: FREIGHT_PERMS.customers.verify,
|
|
pending: FREIGHT_PERMS.customers.verify,
|
|
rejected: FREIGHT_PERMS.customers.verify,
|
|
suspended: FREIGHT_PERMS.customers.deactivate,
|
|
blacklisted: FREIGHT_PERMS.customers.deactivate,
|
|
};
|
|
|
|
export function ProfileApprovalActions({
|
|
profileId,
|
|
status,
|
|
locked = false,
|
|
}: {
|
|
profileId: string;
|
|
status: ProfileStatus;
|
|
locked?: boolean;
|
|
}) {
|
|
const { user } = useAuth();
|
|
/** The API rejects these anyway — hide rather than offer a button that 403s. */
|
|
const canSet = (next: ProfileStatus) =>
|
|
hasPermission(user, STATUS_PERM[next]);
|
|
const { mutate, isPending } = useMutation(
|
|
api.customers.setProfileStatus.mutationOptions(),
|
|
);
|
|
const [decision, setDecision] = useState<
|
|
"reject" | "suspend" | "reactivate" | null
|
|
>(null);
|
|
const [note, setNote] = useState("");
|
|
|
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
|
|
|
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
|
|
// all capture a required message through the same modal; the API refuses
|
|
// suspend/reactivate without one.
|
|
const DECISIONS = {
|
|
reject: {
|
|
title: "Reject profile",
|
|
intro:
|
|
"Tell the customer what needs fixing. They'll see this note and can " +
|
|
"amend and resubmit the role for approval.",
|
|
label: "Reason for rejection",
|
|
placeholder: "e.g. The uploaded business license is expired.",
|
|
confirmLabel: "Reject profile",
|
|
color: "red",
|
|
status: "rejected" as ProfileStatus,
|
|
},
|
|
suspend: {
|
|
title: "Suspend role",
|
|
intro:
|
|
"Explain why this role is being suspended. The customer will see this " +
|
|
"message and cannot operate under the role until it is reactivated.",
|
|
label: "Reason for suspension",
|
|
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
|
|
confirmLabel: "Suspend role",
|
|
color: "orange",
|
|
status: "suspended" as ProfileStatus,
|
|
},
|
|
reactivate: {
|
|
title: "Reactivate role",
|
|
intro:
|
|
"Explain why this role is being reactivated. The customer will see " +
|
|
"this message and can operate under the role again.",
|
|
label: "Reactivation message",
|
|
placeholder: "e.g. Outstanding payments have been settled.",
|
|
confirmLabel: "Reactivate role",
|
|
color: "edr-green",
|
|
status: "active" as ProfileStatus,
|
|
},
|
|
} as const;
|
|
|
|
const openDecision = (kind: keyof typeof DECISIONS) => {
|
|
setNote("");
|
|
setDecision(kind);
|
|
};
|
|
|
|
const active = decision ? DECISIONS[decision] : null;
|
|
|
|
const confirmDecision = () => {
|
|
if (!active) return;
|
|
mutate(
|
|
{ profileId, status: active.status, note: note.trim() },
|
|
{ onSuccess: () => setDecision(null) },
|
|
);
|
|
};
|
|
|
|
const decisionModal = active && (
|
|
<Modal
|
|
opened
|
|
onClose={() => setDecision(null)}
|
|
title={active.title}
|
|
centered
|
|
radius="lg"
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm" c="dimmed">
|
|
{active.intro}
|
|
</Text>
|
|
<Textarea
|
|
label={active.label}
|
|
placeholder={active.placeholder}
|
|
autosize
|
|
minRows={3}
|
|
value={note}
|
|
onChange={(e) => setNote(e.currentTarget.value)}
|
|
required
|
|
/>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button
|
|
variant="default"
|
|
onClick={() => setDecision(null)}
|
|
disabled={isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color={active.color}
|
|
loading={isPending}
|
|
disabled={note.trim().length === 0}
|
|
onClick={confirmDecision}
|
|
>
|
|
{active.confirmLabel}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
|
|
// Pending/rejected are the two states awaiting a reviewer's decision — the
|
|
// exact pair the API gates on until the customer submits.
|
|
if (locked && (status === "pending" || status === "rejected")) {
|
|
return (
|
|
<Tooltip label="Available once the customer submits their onboarding application">
|
|
<Text size="xs" c="dimmed" fs="italic">
|
|
Awaiting submission
|
|
</Text>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
if (status === "pending") {
|
|
if (!canSet("active") && !canSet("rejected")) return null;
|
|
return (
|
|
<>
|
|
{decisionModal}
|
|
<Group gap={6} wrap="nowrap">
|
|
{canSet("active") && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => act("active")}
|
|
>
|
|
Approve
|
|
</Button>
|
|
)}
|
|
{canSet("rejected") && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="red"
|
|
radius="md"
|
|
onClick={() => openDecision("reject")}
|
|
>
|
|
Reject
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (status === "rejected") {
|
|
// No Approve here: the role is waiting on the customer to fix what was
|
|
// flagged and resubmit it, and the API refuses rejected → active outright.
|
|
// All that's left is undoing a rejection that shouldn't have happened,
|
|
// which puts the role back in the queue rather than into service.
|
|
if (!canSet("pending")) return null;
|
|
return (
|
|
<Group gap={8} wrap="nowrap">
|
|
<Text size="xs" c="dimmed" fs="italic">
|
|
Awaiting customer resubmission
|
|
</Text>
|
|
<Tooltip
|
|
multiline
|
|
w={260}
|
|
label="Puts the role back in the pending queue and clears the rejection note. Use only if the rejection itself was a mistake — it does not approve the role."
|
|
>
|
|
<Button
|
|
size="xs"
|
|
variant="subtle"
|
|
color="gray"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => act("pending")}
|
|
>
|
|
Undo rejection
|
|
</Button>
|
|
</Tooltip>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (status === "active") {
|
|
if (!canSet("suspended")) return null;
|
|
return (
|
|
<>
|
|
{decisionModal}
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="orange"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => openDecision("suspend")}
|
|
>
|
|
Suspend
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (status === "suspended") {
|
|
if (!canSet("active") && !canSet("blacklisted")) return null;
|
|
return (
|
|
<Group gap={6} wrap="nowrap">
|
|
{decisionModal}
|
|
{canSet("active") && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => openDecision("reactivate")}
|
|
>
|
|
Reactivate
|
|
</Button>
|
|
)}
|
|
{canSet("blacklisted") && (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="red"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => act("blacklisted")}
|
|
>
|
|
Blacklist
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (status === "blacklisted") {
|
|
if (!canSet("pending")) return null;
|
|
return (
|
|
<Button
|
|
size="xs"
|
|
variant="light"
|
|
color="gray"
|
|
radius="md"
|
|
loading={isPending}
|
|
onClick={() => act("pending")}
|
|
>
|
|
Reinstate
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|