import { useCallback, useEffect, useState } from 'react'; import Notice from '../components/Notice'; import { ApiError, api, uploadFile } from '../lib/api'; import { PLAN_LABEL, formatDate } from '../lib/format'; import type { ImportPlan, ImportRecord } from '../lib/types'; interface UploadResult { importId: number; columns: string[]; rowCount: number; sample: Record[]; } interface MappingForm { playerKey: string; playerName: string; product: string; orderId: string; purchasedAt: string; createMissingPlayers: boolean; } const EMPTY_MAPPING: MappingForm = { playerKey: '', playerName: '', product: '', orderId: '', purchasedAt: '', createMissingPlayers: false, }; // Trifft die üblichen Spaltennamen, damit das Formular meist schon passt. const GUESSES: Record, string[]> = { playerKey: ['steamid', 'steam_id', 'steam', 'identifier', 'user', 'kunde'], playerName: ['name', 'username', 'displayname', 'spieler'], product: ['product', 'produkt', 'item', 'artikel', 'package', 'paket'], orderId: ['order', 'bestell', 'transaction', 'id', 'purchase'], purchasedAt: ['date', 'datum', 'created', 'time', 'kauf'], }; function guessMapping(columns: string[]): MappingForm { const result = { ...EMPTY_MAPPING }; for (const [field, needles] of Object.entries(GUESSES) as [ keyof typeof GUESSES, string[], ][]) { const hit = columns.find((column) => needles.some((needle) => column.toLowerCase().includes(needle)), ); if (hit) result[field] = hit; } return result; } export default function Import() { const [upload, setUpload] = useState(null); const [mapping, setMapping] = useState(EMPTY_MAPPING); const [preview, setPreview] = useState<{ summary: Record; import: ImportRecord; } | null>(null); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); const [busy, setBusy] = useState(false); const [open, setOpen] = useState([]); const loadOpen = useCallback(async () => { try { const result = await api<{ items: ImportRecord[] }>('/api/imports'); setOpen(result.items.filter((entry) => entry.status === 'PENDING')); } catch { // Die Liste ist Beiwerk; ein Fehler hier darf den Upload nicht blockieren. } }, []); useEffect(() => { void loadOpen(); }, [loadOpen]); const reset = () => { setUpload(null); setPreview(null); setMapping(EMPTY_MAPPING); setError(null); void loadOpen(); }; /** Einen bereits angelegten Import (etwa aus dem GExtension-Abgleich) oeffnen. */ const openExisting = async (record: ImportRecord) => { setBusy(true); setError(null); try { const full = await api(`/api/imports/${record.id}`); const rows = full.rows ?? []; const summary = { BOOK: 0, CREATE_PLAYER_AND_BOOK: 0, SKIP_DUPLICATE: 0, SKIP_IGNORED: 0, SKIP_UNMAPPED: 0, SKIP_NO_PLAYER: 0, } as Record; for (const row of rows) summary[row.plan] += 1; setUpload({ importId: full.id, columns: [], rowCount: full.rowCount, sample: [] }); setPreview({ summary, import: full }); } catch (err) { setError(err instanceof ApiError ? err.message : 'Import konnte nicht geoeffnet werden'); } finally { setBusy(false); } }; const handleUpload = async (file: File) => { setBusy(true); setError(null); setPreview(null); try { const result = await uploadFile('/api/imports', file); setUpload(result); setMapping(guessMapping(result.columns)); } catch (err) { setError(err instanceof ApiError ? err.message : 'Upload fehlgeschlagen'); } finally { setBusy(false); } }; const handlePreview = async (event: React.FormEvent) => { event.preventDefault(); if (!upload) return; setBusy(true); setError(null); try { const body = { playerKey: mapping.playerKey, product: mapping.product, createMissingPlayers: mapping.createMissingPlayers, ...(mapping.playerName ? { playerName: mapping.playerName } : {}), ...(mapping.orderId ? { orderId: mapping.orderId } : {}), ...(mapping.purchasedAt ? { purchasedAt: mapping.purchasedAt } : {}), }; const result = await api<{ summary: Record; import: ImportRecord }>( `/api/imports/${upload.importId}/mapping`, { method: 'POST', body }, ); setPreview(result); } catch (err) { setError(err instanceof ApiError ? err.message : 'Vorschau fehlgeschlagen'); } finally { setBusy(false); } }; const handleCommit = async () => { if (!upload) return; setBusy(true); setError(null); try { const result = await api<{ booked: number; skipped: number; createdPlayers: number }>( `/api/imports/${upload.importId}/commit`, { method: 'POST', body: {} }, ); const teile = [`${result.booked} Buchungen angelegt`]; if (result.createdPlayers > 0) teile.push(`${result.createdPlayers} Spieler neu erfasst`); if (result.skipped > 0) teile.push(`${result.skipped} bereits gebucht, übersprungen`); setFlash(teile.join(', ') + '.'); reset(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Buchen fehlgeschlagen'); } finally { setBusy(false); } }; const handleDiscard = async () => { if (!upload) return; try { await api(`/api/imports/${upload.importId}/discard`, { method: 'POST', body: {} }); } finally { reset(); } }; const bookable = preview ? preview.summary.BOOK + preview.summary.CREATE_PLAYER_AND_BOOK : 0; return (

Import

{error && setError(null)}>{error}} {flash && setFlash(null)}>{flash}} {!upload && open.length > 0 && (

Wartet auf Bestätigung ({open.length})

Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben.
    {open.map((record) => (
  • {record.filename}

    {record.rowCount} {record.rowCount === 1 ? 'Zeile' : 'Zeilen'} · angelegt{' '} {formatDate(record.createdAt)} {record.importedBy && ` von ${record.importedBy.username}`}

  • ))}
)} {!upload && (
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau gezeigt — gebucht wird erst nach Bestätigung. Bereits importierte Bestell-IDs werden automatisch übersprungen.
)} {upload && !preview && (

Spalten zuordnen

{upload.rowCount} Zeilen aus {upload.columns.length} Spalten gelesen.

setMapping({ ...mapping, playerKey: value })} /> setMapping({ ...mapping, product: value })} /> setMapping({ ...mapping, playerName: value })} /> setMapping({ ...mapping, orderId: value })} /> setMapping({ ...mapping, purchasedAt: value })} />
{!mapping.orderId && ( Ohne Bestell-ID kann ein zweiter Import dieselben Käufe erneut buchen. )}

Beispielzeilen

{upload.columns.map((column) => ( ))} {upload.sample.map((row, index) => ( {upload.columns.map((column) => ( ))} ))}
{column}
{row[column] ?? ''}
)} {preview && (

Vorschau

{(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => (
{preview.summary[plan] ?? 0} {PLAN_LABEL[plan]}
))}
{bookable === 0 ? ( Keine Zeile würde gebucht. Prüfe die Spaltenzuordnung oder die Produkt-Mappings in der Verwaltung. ) : ( {bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein CC-Guthaben und ein bezahltes Redesign-Guthaben. )}
{(preview.import.rows ?? []).map((row) => ( ))}
# Spieler Produkt Bestell-ID Datum Ergebnis
{row.lineNo} {row.playerName || row.playerKey || '—'} {row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && ( neu )} {row.productName ?? '—'} {row.externalId ?? '—'} {formatDate(row.purchasedAt)} {PLAN_LABEL[row.plan]}
)}
); } function MappingSelect({ label, value, columns, required, onChange, }: { label: string; value: string; columns: string[]; required?: boolean; onChange: (value: string) => void; }) { return (
); }