mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat(eims): surface filing state in backoffice and alert on failures
Two gaps that only bite in production: nobody could see an invoice's filing state, and a blocked chain was visible only in the logs. A failed filing now notifies the staff who can act on it. An ambiguous result is HIGH priority because it blocks every further invoice for the system number until someone resolves it, and nothing else would surface that -- the sweep just goes quiet. A deterministic rejection affects one invoice, so it is normal priority. The alert never throws: it must not mask the filing outcome. The backoffice invoice detail page gains an EIMS card showing status, IRN, counter, submitted and acknowledged timestamps, and the gateway's own error message, with actions gated on invoices:eims_register. FAILED offers "File again" -- the reservation model already allows re-registering a rejected invoice, so retry needed no new endpoint. UNKNOWN offers no re-file button at all, since resubmitting risks a duplicate registration, and instead explains that a supervisor must record the IRN or discard the attempt. Also aligns the migration class name with its renamed file. The DDL is idempotent, so re-applying under the new name is a no-op against the columns; it leaves one superseded row in freight.migrations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, 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 { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
|
||||
NOT_SUBMITTED: "gray",
|
||||
SUBMITTING: "yellow",
|
||||
REGISTERED: "edr-green",
|
||||
FAILED: "red",
|
||||
UNKNOWN: "orange",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
|
||||
NOT_SUBMITTED: "Not filed",
|
||||
SUBMITTING: "Filing…",
|
||||
REGISTERED: "Filed",
|
||||
FAILED: "Rejected",
|
||||
UNKNOWN: "Unacknowledged",
|
||||
};
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text" style={{ wordBreak: "break-all" }}>
|
||||
{value === null || value === undefined || value === "" ? "—" : value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MoR EIMS filing state for one invoice, with the manual actions.
|
||||
*
|
||||
* Filing normally happens on the API's cron sweep, not here — these controls exist for controlled
|
||||
* testing and for the exceptional cases the sweep deliberately refuses: a rejected invoice that
|
||||
* needs re-filing, and an unacknowledged one that has blocked all further filing.
|
||||
*/
|
||||
export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
|
||||
|
||||
const { data: eims, isLoading } = useQuery(
|
||||
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
|
||||
);
|
||||
|
||||
const register = useMutation(
|
||||
api.invoices.eimsRegister.mutationOptions({
|
||||
onSuccess: (result) =>
|
||||
toast({
|
||||
title: result.eimsIrn ? "Filed with MoR" : "Filing finished",
|
||||
description: result.eimsIrn ? `IRN ${result.eimsIrn}` : `Status ${result.eimsStatus}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const verify = useMutation(
|
||||
api.invoices.eimsVerify.mutationOptions({
|
||||
onSuccess: (result) =>
|
||||
toast({
|
||||
title: "MoR confirmed the filing",
|
||||
description: `Document ${result.body?.DocumentDetails?.DocumentNumber ?? "—"}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLoading || !eims) return null;
|
||||
|
||||
const status = eims.eimsStatus;
|
||||
const busy = register.isPending || verify.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
MoR e-invoicing
|
||||
</Text>
|
||||
<Badge color={STATUS_COLOR[status] ?? "gray"} variant="light" size="sm" radius="md" fw={600}>
|
||||
{STATUS_LABEL[status] ?? status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<Field label="IRN" value={eims.eimsIrn} />
|
||||
<Field label="Invoice counter" value={eims.eimsInvoiceCounter} />
|
||||
<Field
|
||||
label="Submitted"
|
||||
value={eims.eimsSubmittedAt ? new Date(eims.eimsSubmittedAt).toLocaleString() : null}
|
||||
/>
|
||||
<Field label="Acknowledged" value={eims.eimsAckDate} />
|
||||
</SimpleGrid>
|
||||
|
||||
{status === "UNKNOWN" && (
|
||||
<Alert color="orange" icon={<AlertTriangle size={16} />} title="All filing is blocked">
|
||||
This invoice was sent but never acknowledged, so its IRN is unknown and no further
|
||||
invoice can be filed. Confirm its status with MoR, then have a supervisor record the IRN
|
||||
or discard the attempt.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{eims.eimsLastError && (
|
||||
<Alert
|
||||
color={status === "FAILED" ? "red" : "orange"}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title={`MoR reported: ${eims.eimsLastError.kind}`}
|
||||
>
|
||||
{eims.eimsLastError.message}
|
||||
</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>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default EimsFilingCard;
|
||||
Reference in New Issue
Block a user