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