mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
452 lines
9.4 KiB
TypeScript
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;
|
|
}
|