diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/components/ExportFileList.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/components/ExportFileList.tsx new file mode 100644 index 0000000..519ac4c --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/components/ExportFileList.tsx @@ -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 ( + + ); +} + +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 ( +

{t('emptyExports')}

+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx new file mode 100644 index 0000000..bf21fca --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/components/InvoiceHistoryTable.tsx @@ -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 ( + + ); +} + +/** 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([]); + 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 ( +
+ + + + + + + + + + + + + {isLoading ? ( + // 5 skeleton rows per UI-SPEC + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 6 }).map((_, j) => ( + + ))} + + )) + ) : items.length === 0 ? ( + + + + ) : ( + items.map((row) => ( + + + + + + + + + )) + )} + +
+ {t('col.dateTime')} + + {t('col.invoiceNumber')} + + {t('col.vehicles')} + + {t('col.transactions')} + + {t('col.status')} + + {t('col.exportFile')} +
+
+
+

{t('emptyHistory')}

+

+ {t('emptyHistoryBody')} +

+
+ {formatDateTime(row.datumZeit)} + {row.rechnungsnummer} + {row.anzahlFahrzeuge} + + {row.anzahlTransaktionen} + + + + {row.exportFilename ? ( + + + {row.exportFilename} + + ) : ( + – + )} +
+ + {/* Pagination — only when total > 25 per UI-SPEC */} + {total > 25 && !isLoading && ( +
+ + Zeige {(page - 1) * 25 + 1}–{Math.min(page * 25, total)} von {total} + +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/components/StatusBadge.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/components/StatusBadge.tsx new file mode 100644 index 0000000..0c2618a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/components/StatusBadge.tsx @@ -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 = { + Verarbeitet: t('status.processed'), + Fehler: t('status.error'), + 'Versand fehlgeschlagen': t('status.sendFailed'), + }; + + const showTooltip = + (status === 'Fehler' || status === 'Versand fehlgeschlagen') && + errorMessage; + + return ( + + {labelMap[status]} + + ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx new file mode 100644 index 0000000..521027d --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/page.tsx @@ -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 ( + + ); +} + +/** + * 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(null); + const [refreshKey, setRefreshKey] = useState(0); + const [lastChecked, setLastChecked] = useState(null); + const [historyItems, setHistoryItems] = useState([]); + + 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 ( +
+ {/* Page Header Row */} +
+
+

+ {t('pageTitle')} +

+

+ {lastChecked + ? t('lastChecked', { time: formatLastChecked(lastChecked) }) + : t('neverChecked')} +

+
+ + {/* "Jetzt prüfen" button per 07-UI-SPEC button states */} + +
+ + {/* Error banner — conditional per UI-SPEC */} + {pollError && ( +
+ {pollError} + +
+ )} + + {/* Invoice Processing History */} +
+

{t('historyTitle')}

+ +
+ + {/* Last Export Files */} +
+

+ {t('exportFilesTitle')} +

+ +
+
+ ); +} diff --git a/apps/web/src/lib/dkv-api.ts b/apps/web/src/lib/dkv-api.ts new file mode 100644 index 0000000..9b999c1 --- /dev/null +++ b/apps/web/src/lib/dkv-api.ts @@ -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 { + 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 & { password?: string }, +): Promise { + 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 & { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)}`; +}