feat(07-05): dkv-api client + Surface A — history table + export list + check-now
- 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user