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:
Hagernesh
2026-08-08 04:05:45 +00:00
parent b72e44a7a5
commit 6b1ffa831f
12 changed files with 396 additions and 3 deletions

View File

@@ -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;