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 = { active: "edr-green", pending: "yellow", rejected: "red", suspended: "orange", blacklisted: "red", }; const COMPANY_TYPE_COLOR: Record = { customer: "edr-green", freight_forwarder: "blue", dj_freight_forwarder: "indigo", transporter: "grape", }; const PROFILE_TYPE_COLOR: Record = { importer: "teal", exporter: "cyan", freight_forwarder: "blue", dj_freight_forwarder: "indigo", transporter: "grape", transit_agent: "lime", }; export function CompanyStatusBadge({ status }: { status: CompanyStatus }) { return ( {status} ); } export function CompanyTypeBadge({ type }: { type: CompanyType }) { return ( {humanize(type)} ); } const NATIONALITY_COLOR: Record = { ethiopian: "edr-green", foreign: "blue", }; export function CompanyNationalityBadge({ nationality, }: { nationality?: CompanyNationality | null; }) { if (!nationality) return null; return ( {humanize(nationality)} ); } /** * 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 ( {cooperative ? "Manual entry · co-operative" : "Manual entry · investment licence"} ); } /** * 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 ( No profiles ); } const shown = profiles.slice(0, max); const extra = profiles.length - shown.length; return ( {shown.map((profile) => ( {humanize(profile.type)} {profile.reference ? ` · ${profile.reference}` : ""} ))} {extra > 0 ? ( +{extra} ) : null} ); } export function ProfileTypeBadge({ type }: { type: ProfileType }) { return ( {humanize(type)} ); } export function ProfileStatusBadge({ status }: { status: ProfileStatus }) { return ( {status} ); } const BOOKING_STATUS_COLOR: Record = { 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 ( {humanize(status)} ); } const PAYMENT_STATUS_COLOR: Record = { "action-required": "orange", processing: "yellow", success: "edr-green", failed: "red", canceled: "gray", refunded: "grape", }; export function PaymentStatusBadge({ status, }: { status: CustomerPaymentStatus; }) { return ( {humanize(status)} ); } const INVOICE_STATUS_COLOR: Record = { 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 ( {humanize(status)} ); } /** * 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 = { 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 && ( setDecision(null)} title={active.title} centered radius="lg" > {active.intro}