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.
465 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|