import { Alert, Anchor, Badge, Button, Card, Group, Modal, SimpleGrid, Stack, Text, Textarea, } from "@mantine/core"; import { useQuery, useMutation } from "@tanstack/react-query"; import { AlertTriangle, ClipboardCheck, Clock, FilePlus2, FileX2, } from "lucide-react"; import { useState } from "react"; import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; /** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ export const FIELD_LABELS: Record = { companyName: "Company name", companyEmail: "Company email", companyPhone: "Company phone", companyLocation: "Location", companyAddress: "Address", tin: "TIN", vatNumber: "VAT number", fanNumber: "FAN number", nationality: "Nationality", licenceNumber: "Licence number", contactPersonName: "Contact person", contactPersonPosition: "Contact position", contactPersonEmail: "Contact email", contactPersonPhone: "Contact phone", generalManagerName: "General manager", generalManagerEmail: "GM email", generalManagerPhone: "GM phone", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", poaLocation: "PoA location", poaAddress: "PoA address", region: "Region", zone: "Zone", woreda: "Woreda", kebele: "Kebele", houseNo: "House no.", statusDescription: "eTrade status", dateRegistered: "Date registered", renewedFrom: "Renewed from", renewalDate: "Renewal date", renewedTo: "Renewed to", etradePhone: "eTrade phone", ownerPassportNumber: "Owner passport number", }; /** Best-effort current value on the live company for a proposed field key. */ export function currentValue(company: Company, key: string): string { const c = company as unknown as Record; const attrs = (company.attributes ?? {}) as Record; const map: Record = { companyName: c.name, companyEmail: c.email, companyPhone: c.phone, companyLocation: c.country, companyAddress: c.address, tin: c.tin, vatNumber: c.vatNumber, fanNumber: c.fanNumber, nationality: c.nationality, contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, generalManagerName: c.generalManagerName ?? attrs.generalManagerName, generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, }; const v = key in map ? map[key] : (c[key] ?? attrs[key]); return v === null || v === undefined || v === "" ? "—" : String(v); } /** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */ function faydaIdentitySubject( snapshot: Record, ): "owner" | "poa" | null { if ("ownerFaydaSub" in snapshot) return "owner"; if ("poaFaydaSub" in snapshot) return "poa"; return null; } /** * `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object * (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic * `DiffRow` loop below can't render it — it would just stringify to * `[object Object]`. Render it as its own before/after block instead, using * the company's current `identity.owner`/`identity.poa` as the "before" side. */ function FaydaIdentityDiff({ company, snapshot, }: { company: Company; snapshot: Record; }) { const subject = faydaIdentitySubject(snapshot); if (!subject) return null; const current = subject === "owner" ? company.identity?.owner : company.identity?.poa; const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined; const verifiedAt = read("FaydaVerifiedAt"); const fields: { label: string; from?: string | null; to?: string }[] = [ { label: "Name", from: current?.name, to: read("Name") }, { label: "Email", from: current?.email, to: read("Email") }, { label: "Phone", from: current?.phone, to: read("Phone") }, { label: "Address", from: current?.address, to: read("Address") }, ].filter((f) => f.to !== undefined); return ( {subject === "owner" ? "Owner re-verification" : "PoA re-verification"} {verifiedAt && ( Verified {formatDate(verifiedAt)} )} {fields.length > 0 ? ( {fields.map((f) => ( ))} ) : ( Identity re-verified — no name/email/phone/address change. )} ); } export function DiffRow({ label, from, to, }: { label: string; from: string; to: string; }) { const changed = from !== to; return ( {label} {from} {changed && ( <> {to} )} ); } /** * Backoffice review surface for a customer's staged profile edits. Shows the * pending change request as a proposed-vs-current diff with Approve / Reject / * Request changes actions. Past decisions live in the History tab's unified * timeline (see {@link CompanyTimeline}), not here. */ export function ChangeRequestReview({ company }: { company: Company }) { const { user } = useAuth(); const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify); const query = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); const approve = useMutation( api.customers.approveChangeRequest.mutationOptions(), ); const reject = useMutation( api.customers.rejectChangeRequest.mutationOptions(), ); const requestChanges = useMutation( api.customers.requestChangeRequestChanges.mutationOptions(), ); const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; } | null>(null); const [note, setNote] = useState(""); const requests = query.data ?? []; const pending = requests.find((r) => r.status === "pending"); if (!pending) return null; const proposedKeys = pending ? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity") : ([] as string[]); const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as | Record | undefined; const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; const documentChanges = pending?.documentChanges ?? []; const confirmAction = () => { if (!actionTarget) return; const mutation = actionTarget.kind === "reject" ? reject : requestChanges; mutation.mutate( { id: actionTarget.id, note: note.trim() }, { onSuccess: () => { setActionTarget(null); setNote(""); }, }, ); }; return ( <> {pending && ( Profile changes awaiting review Pending Submitted {formatDate(pending.submittedAt ?? pending.createdAt)} {pending.note && ( } > Changes were requested on an earlier round of this same submission: {pending.note} — check whether this resubmission actually addresses it before approving. )} {proposedKeys.length > 0 ? ( {proposedKeys.map((key) => ( ))} ) : !faydaIdentitySnapshot ? ( No field changes — document uploads only. ) : null} {faydaIdentitySnapshot && ( )} {documentChanges.length > 0 && ( Document changes {documentChanges.map((c, i) => ( {c.op === "add" ? ( ) : ( )} {c.op === "add" ? "Add" : "Remove"} void fetchViewableFile( c.fileId, c.fileName ?? humanize(c.code), ).then(view) } style={{ textDecoration: c.op === "remove" ? "line-through" : undefined, }} > {c.fileName ?? humanize(c.code)} {humanize(c.code)} ))} )} {docCount > 0 && ( Documents uploaded with this request {pending!.documentFileIds.map((fileId, i) => ( void fetchViewableFile(fileId, `Document ${i + 1}`).then( view, ) } > Document {i + 1} ))} )} {licenseChanges.length > 0 && ( Business license changes {licenseChanges.map((c, i) => ( {c.op === "add" ? ( ) : ( )} {c.op === "add" ? "Add" : "Remove"} void fetchViewableFile( c.fileId, c.fileName ?? "License document", ).then(view) } style={{ textDecoration: c.op === "remove" ? "line-through" : undefined, }} > {c.fileName ?? "License document"} ))} )} {/* Reviewing the diff is `customers:view`; deciding on it is `customers:verify`. Without it the request stays readable but un-actionable. */} {canReview && ( )} )} setActionTarget(null)} title={ actionTarget?.kind === "reject" ? "Reject changes" : "Request changes" } centered radius="lg" > } > {actionTarget?.kind === "reject" ? "The customer will see this note and can amend and resubmit." : "The customer will see this note and can keep editing this same request — no need to start over."}