Files
CC-Manager/web/src/pages/Import.tsx
T
Harlekin 751f8bfe8e Schema-Aenderungen als eigene Migration nachziehen
Die erste Migration wurde nachtraeglich mehrfach erweitert, statt Aenderungen
anzuhaengen. Auf einer bestehenden Installation gilt sie als angewendet und
wird uebersprungen - die seither hinzugekommenen Tabellen fehlten dort also
schlicht, ohne dass beim Start etwas auffiel. Die Antragsseite lief dadurch
in einen internen Fehler.

Die neue Migration ergaenzt alles Fehlende und ist durchgehend mit
IF NOT EXISTS geschrieben, weil bestehende Datenbanken je nach Zeitpunkt der
Einrichtung unterschiedliche Staende haben. Geprueft gegen eine frische
Datenbank, gegen eine auf altem Stand und auf mehrfaches Anwenden.

Der Testharness wendet jetzt alle Migrationen der Reihe nach an statt nur der
ersten - sonst faellt so etwas beim naechsten Mal wieder nicht auf.

Ausserdem scheiterte das Buchen komplett, wenn eine Bestellung zwischenzeitlich
ueber einen anderen Import gebucht worden war: die eindeutige Bestell-ID kippte
die gesamte Transaktion. Solche Zeilen werden jetzt uebersprungen und gezaehlt,
die uebrigen laufen durch.

Dazu fehlten in der Oberflaeche die Wege zum Bearbeiten und Loeschen eines
Spielers, obwohl es die Endpunkte gab. Beides ist jetzt auf der Spielerseite
erreichbar; das Loeschen verlangt den Namen als Bestaetigung und benennt
vorher, was mit verschwindet.
2026-09-18 17:43:25 +02:00

465 lines
15 KiB
TypeScript

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<string, string>[];
}
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<keyof Omit<MappingForm, 'createMissingPlayers'>, 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<UploadResult | null>(null);
const [mapping, setMapping] = useState<MappingForm>(EMPTY_MAPPING);
const [preview, setPreview] = useState<{
summary: Record<ImportPlan, number>;
import: ImportRecord;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState<ImportRecord[]>([]);
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<ImportRecord>(`/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<ImportPlan, number>;
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<UploadResult>('/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<ImportPlan, number>; 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 (
<div className="page">
<div className="page-head">
<h1>Import</h1>
</div>
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{!upload && open.length > 0 && (
<section className="block block-flag">
<h2>Wartet auf Bestätigung ({open.length})</h2>
<Notice kind="warn">
Diese Importe sind angelegt, aber noch nicht gebucht. Der GExtension-Abgleich landet
ebenfalls hier — erst nach dem Bestätigen entstehen Guthaben.
</Notice>
<ul className="cc-list">
{open.map((record) => (
<li key={record.id} className="cc-item">
<div>
<strong>{record.filename}</strong>
<p className="muted small">
{record.rowCount} {record.rowCount === 1 ? 'Zeile' : 'Zeilen'} · angelegt{' '}
{formatDate(record.createdAt)}
{record.importedBy && ` von ${record.importedBy.username}`}
</p>
</div>
<div className="cc-actions">
<button
type="button"
className="btn btn-small btn-primary"
disabled={busy}
onClick={() => void openExisting(record)}
>
Öffnen
</button>
</div>
</li>
))}
</ul>
</section>
)}
{!upload && (
<section className="block">
<Notice kind="info">
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.
</Notice>
<label className="file-drop">
<input
type="file"
accept=".csv,.json,.tsv,.txt"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleUpload(file);
}}
disabled={busy}
/>
<span>{busy ? 'Lade hoch …' : 'Datei auswählen'}</span>
</label>
</section>
)}
{upload && !preview && (
<section className="block">
<h2>Spalten zuordnen</h2>
<p className="muted">
{upload.rowCount} Zeilen aus <strong>{upload.columns.length}</strong> Spalten gelesen.
</p>
<form onSubmit={handlePreview}>
<div className="grid-2">
<MappingSelect
label="Spieler-Kennung (Pflicht)"
value={mapping.playerKey}
columns={upload.columns}
required
onChange={(value) => setMapping({ ...mapping, playerKey: value })}
/>
<MappingSelect
label="Produktname (Pflicht)"
value={mapping.product}
columns={upload.columns}
required
onChange={(value) => setMapping({ ...mapping, product: value })}
/>
<MappingSelect
label="Anzeigename"
value={mapping.playerName}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, playerName: value })}
/>
<MappingSelect
label="Bestell-ID (für Dedup)"
value={mapping.orderId}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, orderId: value })}
/>
<MappingSelect
label="Kaufdatum"
value={mapping.purchasedAt}
columns={upload.columns}
onChange={(value) => setMapping({ ...mapping, purchasedAt: value })}
/>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={mapping.createMissingPlayers}
onChange={(event) =>
setMapping({ ...mapping, createMissingPlayers: event.target.checked })
}
/>
<span>Unbekannte Spieler automatisch anlegen</span>
</label>
{!mapping.orderId && (
<Notice kind="warn">
Ohne Bestell-ID kann ein zweiter Import dieselben Käufe erneut buchen.
</Notice>
)}
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Berechne …' : 'Vorschau erzeugen'}
</button>
</div>
</form>
<h3>Beispielzeilen</h3>
<div className="table-wrap">
<table className="table small">
<thead>
<tr>
{upload.columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{upload.sample.map((row, index) => (
<tr key={index}>
{upload.columns.map((column) => (
<td key={column} className="muted">
{row[column] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{preview && (
<section className="block">
<h2>Vorschau</h2>
<div className="summary-grid">
{(Object.keys(PLAN_LABEL) as ImportPlan[]).map((plan) => (
<div
key={plan}
className={`summary-card${
plan === 'BOOK' || plan === 'CREATE_PLAYER_AND_BOOK' ? ' is-active' : ''
}`}
>
<span className="summary-value">{preview.summary[plan] ?? 0}</span>
<span className="summary-label">{PLAN_LABEL[plan]}</span>
</div>
))}
</div>
{bookable === 0 ? (
<Notice kind="warn">
Keine Zeile würde gebucht. Prüfe die Spaltenzuordnung oder die Produkt-Mappings
in der Verwaltung.
</Notice>
) : (
<Notice kind="info">
{bookable} Zeilen werden gebucht. Ein CC-Paket erzeugt dabei automatisch ein
CC-Guthaben <em>und</em> ein bezahltes Redesign-Guthaben.
</Notice>
)}
<div className="table-wrap scroll">
<table className="table small">
<thead>
<tr>
<th>#</th>
<th>Spieler</th>
<th>Produkt</th>
<th>Bestell-ID</th>
<th>Datum</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{(preview.import.rows ?? []).map((row) => (
<tr key={row.id} className={row.plan.startsWith('SKIP') ? 'is-skipped' : undefined}>
<td className="muted">{row.lineNo}</td>
<td>
{row.playerName || row.playerKey || '—'}
{row.resolvedPlayerId === null && row.plan === 'CREATE_PLAYER_AND_BOOK' && (
<span className="badge badge-new">neu</span>
)}
</td>
<td className="muted">{row.productName ?? '—'}</td>
<td className="mono muted">{row.externalId ?? '—'}</td>
<td className="muted">{formatDate(row.purchasedAt)}</td>
<td className={row.plan.startsWith('SKIP') ? 'muted' : 'pos'}>
{PLAN_LABEL[row.plan]}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-actions">
<button type="button" className="btn btn-ghost" onClick={() => void handleDiscard()}>
Verwerfen
</button>
<button type="button" className="btn btn-ghost" onClick={() => setPreview(null)}>
Zuordnung ändern
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy || bookable === 0}
onClick={() => void handleCommit()}
>
{busy ? 'Buche …' : `${bookable} Zeilen buchen`}
</button>
</div>
</section>
)}
</div>
);
}
function MappingSelect({
label,
value,
columns,
required,
onChange,
}: {
label: string;
value: string;
columns: string[];
required?: boolean;
onChange: (value: string) => void;
}) {
return (
<div className="field">
<label>{label}</label>
<select value={value} onChange={(event) => onChange(event.target.value)} required={required}>
<option value="">— nicht zuordnen —</option>
{columns.map((column) => (
<option key={column} value={column}>
{column}
</option>
))}
</select>
</div>
);
}