Files
edr-platform/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx

452 lines
9.4 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 { api } from "@/services/api";
import type {
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>
);
}
/**
* Profile chips for a company row: one chip per role (Importer / Exporter / …)
* carrying its reference code. 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={PROFILE_TYPE_COLOR[profile.type] ?? "gray"}
variant="light"
size="sm"
radius="md"
fw={600}
style={badgeStyle}
>
{humanize(profile.type)} · {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",
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 → approve (override) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
* Rejecting captures a note the customer sees so they can fix and reapply.
*/
export function ProfileApprovalActions({
profileId,
status,
}: {
profileId: string;
status: ProfileStatus;
}) {
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const [rejectOpen, setRejectOpen] = useState(false);
const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
const confirmReject = () => {
mutate(
{ profileId, status: "rejected", note: note.trim() },
{ onSuccess: () => setRejectOpen(false) },
);
};
const rejectModal = (
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject profile"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can
amend and resubmit the role for approval.
</Text>
<Textarea
label="Reason for rejection"
placeholder="e.g. The uploaded business license is expired."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectOpen(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
color="red"
loading={isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
>
Reject profile
</Button>
</Group>
</Stack>
</Modal>
);
if (status === "pending") {
return (
<>
{rejectModal}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
</Group>
</>
);
}
if (status === "rejected") {
return (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
);
}
if (status === "active") {
return (
<Button
size="xs"
variant="light"
color="orange"
radius="md"
loading={isPending}
onClick={() => act("suspended")}
>
Suspend
</Button>
);
}
if (status === "suspended") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Reactivate
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Blacklist
</Button>
</Group>
);
}
if (status === "blacklisted") {
return (
<Button
size="xs"
variant="light"
color="gray"
radius="md"
loading={isPending}
onClick={() => act("pending")}
>
Reinstate
</Button>
);
}
return null;
}