mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 02:43:38 +00:00
feat(freight-backoffice): EIMS cancel/receipt/memo actions in filing card
Adds cancel, sales/withholding receipt filing + listing + PDF download, and credit/debit memo issuance to EimsFilingCard, each gated on its own permission. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,30 @@
|
||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { EimsInvoiceStatus } from "@/types/eims";
|
||||
import { eimsService } from "@/services/eims.service";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
CANCELLED: "gray",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
|
||||
);
|
||||
}
|
||||
|
||||
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
|
||||
const CANCEL_REASON_CODES = [
|
||||
{ value: "1", label: "1 — Duplicate" },
|
||||
{ value: "2", label: "2 — Buyer request" },
|
||||
{ value: "3", label: "3 — Data entry error" },
|
||||
{ value: "6", label: "6 — Calculation error" },
|
||||
];
|
||||
|
||||
function CancelModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [remark, setRemark] = useState("");
|
||||
|
||||
const cancel = useMutation(
|
||||
api.invoices.eimsCancel.mutationOptions({
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
toast({ title: "Cancelled with MoR" });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Cancels this invoice's registered document at MoR. Irreversible — an already-cancelled
|
||||
invoice refuses a second attempt.
|
||||
</Text>
|
||||
<Select
|
||||
label="Reason code"
|
||||
withAsterisk
|
||||
data={CANCEL_REASON_CODES}
|
||||
value={reasonCode}
|
||||
onChange={setReasonCode}
|
||||
placeholder="Select a reason"
|
||||
/>
|
||||
<Textarea
|
||||
label="Remark"
|
||||
placeholder="Optional note"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancel.isPending}
|
||||
disabled={!reasonCode}
|
||||
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SalesReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
|
||||
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Mode of payment"
|
||||
withAsterisk
|
||||
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
|
||||
value={modeOfPayment}
|
||||
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
|
||||
placeholder="Select"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Collected amount"
|
||||
placeholder="Defaults to the invoice's paid amount"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={collectedAmount}
|
||||
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Payment received"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!modeOfPayment}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
modeOfPayment: modeOfPayment!,
|
||||
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function WithholdingReceiptModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState("TWHT");
|
||||
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
|
||||
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
|
||||
onSuccess: (receipt) => {
|
||||
onClose();
|
||||
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const valid = preTaxAmount !== "" && withholdingAmount !== "";
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
|
||||
<NumberInput
|
||||
label="Pre-tax amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={preTaxAmount}
|
||||
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Withholding amount"
|
||||
withAsterisk
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={withholdingAmount}
|
||||
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
<TextInput
|
||||
label="Reason"
|
||||
placeholder='Defaults to "Withholding"'
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={register.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
register.mutate({
|
||||
id: invoiceId,
|
||||
type,
|
||||
preTaxAmount: preTaxAmount as number,
|
||||
withholdingAmount: withholdingAmount as number,
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
File with MoR
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoModal({
|
||||
invoiceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
invoiceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [type, setType] = useState<"CRE" | "DEB">("CRE");
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const issue = useMutation(
|
||||
api.invoices.issueMemo.mutationOptions({
|
||||
onSuccess: (memo) => {
|
||||
onClose();
|
||||
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
|
||||
},
|
||||
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
|
||||
MoR is a separate step — it does not happen automatically here.
|
||||
</Text>
|
||||
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
|
||||
<Stack gap="xs" mt="xs">
|
||||
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
|
||||
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
withAsterisk
|
||||
placeholder="Why this memo is being issued"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={issue.isPending}
|
||||
disabled={!reason.trim()}
|
||||
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
|
||||
>
|
||||
Issue memo
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
|
||||
const [salesOpen, setSalesOpen] = useState(false);
|
||||
const [withholdingOpen, setWithholdingOpen] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const download = async (receiptId: string, receiptNumber: string) => {
|
||||
setDownloadingId(receiptId);
|
||||
try {
|
||||
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
|
||||
openPdfBlob(data, `${receiptNumber}.pdf`);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Could not download receipt",
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Receipts
|
||||
</Text>
|
||||
{canFile && (
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
|
||||
File sales receipt
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
|
||||
File withholding receipt
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{receipts && receipts.length > 0 ? (
|
||||
<Table striped withTableBorder={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>RRN</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{receipts.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.kind}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
|
||||
{STATUS_LABEL[r.status] ?? r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<Download size={14} />}
|
||||
loading={downloadingId === r.id}
|
||||
onClick={() => void download(r.id, r.receiptNumber)}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No receipts filed yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
|
||||
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||
*
|
||||
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
|
||||
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
|
||||
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
|
||||
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [memoOpen, setMemoOpen] = useState(false);
|
||||
|
||||
const { data: eims, isLoading } = useQuery(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{canFile && (
|
||||
<Group gap="sm">
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{status === "CANCELLED" && (
|
||||
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
|
||||
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
|
||||
{eims.eimsCancellationRemark}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
{canFile && (
|
||||
<>
|
||||
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
|
||||
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={register.isPending}
|
||||
disabled={busy}
|
||||
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
|
||||
onClick={() => register.mutate({ id: invoiceId })}
|
||||
>
|
||||
{status === "FAILED" ? "File again" : "File with MoR"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{eims.eimsIrn && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
loading={verify.isPending}
|
||||
disabled={busy}
|
||||
leftSection={<ShieldCheck size={14} />}
|
||||
onClick={() => verify.mutate({ id: invoiceId })}
|
||||
>
|
||||
Verify with MoR
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel with MoR
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canIssueMemo && status === "REGISTERED" && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => setMemoOpen(true)}
|
||||
>
|
||||
Issue credit/debit memo
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
|
||||
</Stack>
|
||||
|
||||
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
|
||||
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user