Files
edr-platform/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx
2026-08-18 12:44:02 +00:00

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;
}