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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* DKV Fleet Module API client.
|
||||||
|
* Consumes the /dkv/* REST surface built in Plan 04.
|
||||||
|
* All calls use credentials: 'include' for cookie-based auth.
|
||||||
|
*
|
||||||
|
* Security (T-07-12): DkvConfig never exposes the password —
|
||||||
|
* only hasPassword (boolean) is returned by the server.
|
||||||
|
* The password field is only sent when the user types a new one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// --- Types mirroring the Plan 04 backend contract ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DKV module inbox configuration.
|
||||||
|
* Note: password is NEVER returned (T-07-12). Only hasPassword is exposed.
|
||||||
|
*/
|
||||||
|
export interface DkvConfig {
|
||||||
|
protocol: 'imap' | 'exchange';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
encryption: 'none' | 'starttls' | 'ssl-tls';
|
||||||
|
folder: string;
|
||||||
|
senderFilter?: string | null;
|
||||||
|
pollIntervalMin: number;
|
||||||
|
isActive: boolean;
|
||||||
|
exportRecipient: string;
|
||||||
|
vehicleFormatString: string;
|
||||||
|
username?: string | null;
|
||||||
|
/** true if a password is stored server-side — never the actual secret */
|
||||||
|
hasPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in the DKV invoice processing history.
|
||||||
|
* Mirrors DkvInvoiceHistory Prisma model.
|
||||||
|
*/
|
||||||
|
export interface DkvHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
datumZeit: string;
|
||||||
|
rechnungsnummer: string;
|
||||||
|
anzahlFahrzeuge: number;
|
||||||
|
anzahlTransaktionen: number;
|
||||||
|
status: 'Verarbeitet' | 'Fehler' | 'Versand fehlgeschlagen';
|
||||||
|
errorMessage?: string | null;
|
||||||
|
exportFilename?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle master record for DKV→driver mapping (DKV-03).
|
||||||
|
*/
|
||||||
|
export interface DkvVehicle {
|
||||||
|
id: string;
|
||||||
|
kennzeichen: string;
|
||||||
|
marke: string;
|
||||||
|
modell: string;
|
||||||
|
fahrer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for creating a new vehicle.
|
||||||
|
*/
|
||||||
|
export interface CreateVehiclePayload {
|
||||||
|
kennzeichen: string;
|
||||||
|
marke: string;
|
||||||
|
modell: string;
|
||||||
|
fahrer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for updating an existing vehicle. All fields optional.
|
||||||
|
*/
|
||||||
|
export interface UpdateVehiclePayload {
|
||||||
|
kennzeichen?: string;
|
||||||
|
marke?: string;
|
||||||
|
modell?: string;
|
||||||
|
fahrer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated history response from GET /dkv/history.
|
||||||
|
*/
|
||||||
|
export interface DkvHistoryPage {
|
||||||
|
items: DkvHistoryEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API functions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current DKV module inbox configuration.
|
||||||
|
* GET /dkv/config
|
||||||
|
*/
|
||||||
|
export async function fetchConfig(): Promise<DkvConfig> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/config`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch DKV config');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the DKV module inbox configuration.
|
||||||
|
* PUT /dkv/config
|
||||||
|
* Only include `password` in payload when the user has entered a new one.
|
||||||
|
*/
|
||||||
|
export async function saveConfig(
|
||||||
|
payload: Partial<DkvConfig> & { password?: string },
|
||||||
|
): Promise<DkvConfig> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to save DKV config');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test the inbox connection with the current (or pending) config.
|
||||||
|
* POST /dkv/test-connection
|
||||||
|
* Returns { success: boolean, message?: string }
|
||||||
|
*/
|
||||||
|
export async function testConnection(
|
||||||
|
payload: Partial<DkvConfig> & { password?: string },
|
||||||
|
): Promise<{ success: boolean; message?: string }> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/test-connection`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to test DKV connection');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate inbox check (manual poll).
|
||||||
|
* POST /dkv/check-now
|
||||||
|
*/
|
||||||
|
export async function checkNow(): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/check-now`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to trigger DKV inbox check');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch paginated invoice processing history.
|
||||||
|
* GET /dkv/history?page=&limit=
|
||||||
|
*/
|
||||||
|
export async function fetchHistory(
|
||||||
|
page = 1,
|
||||||
|
limit = 25,
|
||||||
|
): Promise<DkvHistoryPage> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/dkv/history?${params.toString()}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch DKV history');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all vehicles from the DKV vehicle master list.
|
||||||
|
* GET /dkv/vehicles
|
||||||
|
*/
|
||||||
|
export async function fetchVehicles(): Promise<DkvVehicle[]> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/vehicles`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch DKV vehicles');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new vehicle entry.
|
||||||
|
* POST /dkv/vehicles
|
||||||
|
*/
|
||||||
|
export async function createVehicle(
|
||||||
|
payload: CreateVehiclePayload,
|
||||||
|
): Promise<DkvVehicle> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/vehicles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to create DKV vehicle');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing vehicle.
|
||||||
|
* PUT /dkv/vehicles/:id
|
||||||
|
*/
|
||||||
|
export async function updateVehicle(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateVehiclePayload,
|
||||||
|
): Promise<DkvVehicle> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/vehicles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to update DKV vehicle');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a vehicle from the master list.
|
||||||
|
* DELETE /dkv/vehicles/:id
|
||||||
|
*/
|
||||||
|
export async function deleteVehicle(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/dkv/vehicles/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to delete DKV vehicle');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import vehicles from a CSV file.
|
||||||
|
* POST /dkv/vehicles/import (multipart/form-data)
|
||||||
|
* mode: 'merge' | 'replace'
|
||||||
|
* Do NOT set Content-Type manually — browser sets it with boundary.
|
||||||
|
*/
|
||||||
|
export async function importVehiclesCsv(
|
||||||
|
file: File,
|
||||||
|
mode: 'merge' | 'replace',
|
||||||
|
): Promise<{ count: number }> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('mode', mode);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/dkv/vehicles/import`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: form,
|
||||||
|
// Content-Type is NOT set manually — fetch sets multipart/form-data with boundary
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to import DKV vehicles CSV');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full download URL for an export file.
|
||||||
|
* Pure helper — no fetch call. Server enforces the DKV_*.xlsx whitelist (T-07-13).
|
||||||
|
*/
|
||||||
|
export function exportFileUrl(filename: string): string {
|
||||||
|
return `${API_URL}/dkv/exports/${encodeURIComponent(filename)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user