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
+261
View File
@@ -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)}`;
}