feat(07-05): dkv-api client + Surface A — history table + export list + check-now
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 43s
Tessera CI/CD / Tests (push) Waiting to run

- dkv-api.ts: typed fetch client for all /dkv/* routes (credentials: include)
- StatusBadge: OKLCH inline styles per status (T-07-14: React text nodes only)
- InvoiceHistoryTable: 6-column table, 5 skeleton rows, pagination >25, refreshKey
- ExportFileList: up to 10 unique export filenames derived from history (T-07-13)
- page.tsx: Jetzt prüfen trigger, error banner, refreshKey lift, DKV-04/DKV-01
This commit is contained in:
2026-06-27 00:36:28 +02:00
parent 316f8353b7
commit 2fe7bca6e8
5 changed files with 751 additions and 0 deletions
@@ -0,0 +1,72 @@
'use client';
import { useTranslations } from 'next-intl';
import { type DkvHistoryEntry, exportFileUrl } from '@/lib/dkv-api';
/** Inline SVG download arrow (16×16, currentColor). No external icon package. */
function DownloadArrowIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 2v8M5 7l3 3 3-3M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" />
</svg>
);
}
interface ExportFileListProps {
/** History entries from InvoiceHistoryTable — used to derive export filenames */
historyItems: DkvHistoryEntry[];
}
/**
* ExportFileList — shows the last 10 unique export files (newest first).
* T-07-13: URLs built via exportFileUrl() + encodeURIComponent. No client-built raw paths.
* T-07-14: Filenames rendered through React text nodes (auto-escaped).
* Max 10 entries per 07-UI-SPEC D-15 / CONTEXT D-15.
*/
export function ExportFileList({ historyItems }: ExportFileListProps) {
const t = useTranslations('dkvFleet');
// Derive unique filenames from history, newest first, deduplicated, max 10
const filenames = Array.from(
new Set(
historyItems
.filter((h) => h.exportFilename)
.map((h) => h.exportFilename as string),
),
).slice(0, 10);
if (filenames.length === 0) {
return (
<p className="text-sm text-muted-foreground">{t('emptyExports')}</p>
);
}
return (
<ul className="space-y-1">
{filenames.map((filename) => (
<li key={filename} className="flex items-center gap-2">
<span className="text-muted-foreground flex-shrink-0">
<DownloadArrowIcon />
</span>
<a
href={exportFileUrl(filename)}
download
className="text-sm text-primary hover:underline"
>
{filename}
</a>
</li>
))}
</ul>
);
}
@@ -0,0 +1,212 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { type DkvHistoryEntry, fetchHistory, exportFileUrl } from '@/lib/dkv-api';
import { StatusBadge } from './StatusBadge';
/** Inline SVG download icon (16×16, currentColor). T-07-14: no external icon package. */
function DownloadIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 2v8M5 7l3 3 3-3M2 11v2a1 1 0 001 1h10a1 1 0 001-1v-2" />
</svg>
);
}
/** Formats an ISO date string as "26.06.2026, 14:32 Uhr" */
function formatDateTime(isoString: string): string {
try {
const d = new Date(isoString);
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
return `${day}.${month}.${year}, ${hours}:${minutes} Uhr`;
} catch {
return isoString;
}
}
interface InvoiceHistoryTableProps {
/** Incremented by parent to trigger a refetch */
refreshKey?: number;
/** Called with loaded items so parent can derive ExportFileList data */
onItemsLoaded?: (items: DkvHistoryEntry[]) => void;
}
/**
* InvoiceHistoryTable — displays the DKV invoice processing history.
* Columns per 07-UI-SPEC: DATUM/ZEIT, RECHNUNGSNUMMER, FAHRZEUGE, TRANSAKTIONEN, STATUS, EXPORTDATEI.
* T-07-14: All backend strings rendered through React text nodes (auto-escaped).
* T-07-13: Download links use exportFileUrl() + encodeURIComponent (no client-built paths).
*/
export function InvoiceHistoryTable({
refreshKey = 0,
onItemsLoaded,
}: InvoiceHistoryTableProps) {
const t = useTranslations('dkvFleet');
const [items, setItems] = useState<DkvHistoryEntry[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async (p: number) => {
setIsLoading(true);
try {
const data = await fetchHistory(p, 25);
setItems(data.items);
setTotal(data.total);
setPage(data.page);
onItemsLoaded?.(data.items);
} catch {
// Keep existing items on error; parent shows error banner
} finally {
setIsLoading(false);
}
}, [onItemsLoaded]);
useEffect(() => {
load(1);
}, [load, refreshKey]);
const totalPages = Math.ceil(total / 25);
const theadCls =
'px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground border-b border-border text-left';
return (
<div className="rounded border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr>
<th className={theadCls} style={{ width: 160 }}>
{t('col.dateTime')}
</th>
<th className={theadCls} style={{ width: 200 }}>
{t('col.invoiceNumber')}
</th>
<th
className={`${theadCls} text-right`}
style={{ width: 100 }}
>
{t('col.vehicles')}
</th>
<th
className={`${theadCls} text-right`}
style={{ width: 120 }}
>
{t('col.transactions')}
</th>
<th className={theadCls} style={{ width: 180 }}>
{t('col.status')}
</th>
<th className={theadCls}>
{t('col.exportFile')}
</th>
</tr>
</thead>
<tbody>
{isLoading ? (
// 5 skeleton rows per UI-SPEC
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-0">
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 rounded bg-muted animate-pulse" />
</td>
))}
</tr>
))
) : items.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center">
<p className="font-medium text-foreground">{t('emptyHistory')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('emptyHistoryBody')}
</p>
</td>
</tr>
) : (
items.map((row) => (
<tr
key={row.id}
className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors"
>
<td className="px-4 py-3 text-sm">
{formatDateTime(row.datumZeit)}
</td>
<td className="px-4 py-3 text-sm">{row.rechnungsnummer}</td>
<td className="px-4 py-3 text-sm text-right">
{row.anzahlFahrzeuge}
</td>
<td className="px-4 py-3 text-sm text-right">
{row.anzahlTransaktionen}
</td>
<td className="px-4 py-3">
<StatusBadge
status={row.status}
errorMessage={row.errorMessage}
/>
</td>
<td className="px-4 py-3">
{row.exportFilename ? (
<a
href={exportFileUrl(row.exportFilename)}
download
className="inline-flex items-center gap-1 text-primary text-sm hover:underline"
>
<DownloadIcon />
{row.exportFilename}
</a>
) : (
<span className="text-muted-foreground">–</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
{/* Pagination — only when total > 25 per UI-SPEC */}
{total > 25 && !isLoading && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground">
Zeige {(page - 1) * 25 + 1}–{Math.min(page * 25, total)} von {total}
</span>
<div className="flex gap-2">
<button
type="button"
onClick={() => load(page - 1)}
disabled={page <= 1}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
← Zurück
</button>
<button
type="button"
onClick={() => load(page + 1)}
disabled={page >= totalPages}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter →
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,67 @@
'use client';
import { useTranslations } from 'next-intl';
/**
* Status values for DKV invoice processing history.
* Mirrors the backend DkvInvoiceHistory.status enum.
*/
export type DkvStatus = 'Verarbeitet' | 'Fehler' | 'Versand fehlgeschlagen';
interface StatusBadgeProps {
status: DkvStatus;
errorMessage?: string | null;
}
/**
* Color mapping per 07-UI-SPEC "StatusBadge Component".
* Inline OKLCH styles — NOT in globals.css for v1.
* T-07-14: All strings go through React text nodes (auto-escaped). No dangerouslySetInnerHTML.
*/
const STATUS_STYLES: Record<
DkvStatus,
{ color: string; background: string }
> = {
Verarbeitet: {
color: 'oklch(0.40 0.15 148)',
background: 'oklch(0.40 0.15 148 / 0.10)',
},
Fehler: {
color: 'var(--destructive)',
background: 'oklch(0.55 0.2 27 / 0.10)',
},
'Versand fehlgeschlagen': {
color: 'oklch(0.50 0.13 70)',
background: 'oklch(0.50 0.13 70 / 0.10)',
},
};
/**
* StatusBadge — displays the processing status of a DKV invoice history row.
* Shape: rounded-full pill per 07-UI-SPEC.
* title attribute provides tooltip for error/send-failure states (T-07-14: no raw HTML).
*/
export function StatusBadge({ status, errorMessage }: StatusBadgeProps) {
const t = useTranslations('dkvFleet');
const styles = STATUS_STYLES[status];
const labelMap: Record<DkvStatus, string> = {
Verarbeitet: t('status.processed'),
Fehler: t('status.error'),
'Versand fehlgeschlagen': t('status.sendFailed'),
};
const showTooltip =
(status === 'Fehler' || status === 'Versand fehlgeschlagen') &&
errorMessage;
return (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: styles.color, background: styles.background }}
title={showTooltip ? errorMessage : undefined}
>
{labelMap[status]}
</span>
);
}
@@ -0,0 +1,139 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import { checkNow, type DkvHistoryEntry } from '@/lib/dkv-api';
import { InvoiceHistoryTable } from './components/InvoiceHistoryTable';
import { ExportFileList } from './components/ExportFileList';
/**
* Inline spinner SVG per 07-UI-SPEC (20×20, animate-spin, stroke="currentColor").
*/
function SpinnerIcon() {
return (
<svg
className="mr-2 h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
);
}
/**
* DKV Flotte module main page (Surface A from 07-UI-SPEC).
* Shows processing history (InvoiceHistoryTable) + export file list (ExportFileList).
* "Jetzt prüfen" triggers an immediate inbox poll, then refetches history.
*/
export default function DkvFleetPage() {
const t = useTranslations('dkvFleet');
const [isChecking, setIsChecking] = useState(false);
const [pollError, setPollError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const [historyItems, setHistoryItems] = useState<DkvHistoryEntry[]>([]);
const handleCheckNow = async () => {
setIsChecking(true);
setPollError(null);
try {
await checkNow();
setLastChecked(new Date());
// Increment refreshKey to trigger InvoiceHistoryTable refetch
setRefreshKey((k) => k + 1);
} catch {
setPollError(t('errors.pollFailed'));
} finally {
setIsChecking(false);
}
};
const handleItemsLoaded = useCallback((items: DkvHistoryEntry[]) => {
setHistoryItems(items);
}, []);
const formatLastChecked = (date: Date): string => {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}.${month}.${year}, ${hours}:${minutes}`;
};
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
{/* Page Header Row */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t('pageTitle')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{lastChecked
? t('lastChecked', { time: formatLastChecked(lastChecked) })
: t('neverChecked')}
</p>
</div>
{/* "Jetzt prüfen" button per 07-UI-SPEC button states */}
<button
type="button"
onClick={handleCheckNow}
disabled={isChecking}
className={`rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 flex items-center${
isChecking ? ' opacity-80 cursor-not-allowed' : ''
}`}
>
{isChecking ? (
<>
<SpinnerIcon />
{t('checking')}
</>
) : (
t('checkNow')
)}
</button>
</div>
{/* Error banner — conditional per UI-SPEC */}
{pollError && (
<div className="flex items-start justify-between rounded border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<span>{pollError}</span>
<button
type="button"
onClick={() => setPollError(null)}
className="ml-4 flex-shrink-0 font-medium hover:opacity-70"
aria-label="Schließen"
>
×
</button>
</div>
)}
{/* Invoice Processing History */}
<section>
<h2 className="text-lg font-semibold mb-4">{t('historyTitle')}</h2>
<InvoiceHistoryTable
refreshKey={refreshKey}
onItemsLoaded={handleItemsLoaded}
/>
</section>
{/* Last Export Files */}
<section>
<h2 className="text-sm font-semibold mb-2 mt-6">
{t('exportFilesTitle')}
</h2>
<ExportFileList historyItems={historyItems} />
</section>
</div>
);
}