Files
CC-Manager/web/src/pages/Import.tsx
T
Harlekin 245666d363 Weboberflaeche mit Spielerkonto, Import und Verwaltung
Die Spieler-Detailseite beantwortet die Kernfrage direkt: drei grosse Zaehler,
rot sobald ein Topf im Minus ist, darunter ob ein neuer CC oder ein Redesign
gerade moeglich ist.

Inaktive CCs zeigen ihren Grund und bieten genau die passende Aktion an -
gratis reaktivieren oder bezahlt. Beim Redesign steht der Kulanz-Topf an
erster Stelle, damit er zuerst aufgebraucht wird.

Der Import fuehrt ueber Spaltenzuordnung und Vorschau; gebucht wird erst nach
Bestaetigung.
2026-09-18 11:37:43 +02:00

389 lines
12 KiB
TypeScript

import { 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 ueblichen 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 reset = () => {
setUpload(null);
setPreview(null);
setMapping(EMPTY_MAPPING);
setError(null);
};
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; createdPlayers: number }>(
`/api/imports/${upload.importId}/commit`,
{ method: 'POST', body: {} },
);
setFlash(
`${result.booked} Buchungen angelegt` +
(result.createdPlayers > 0 ? `, ${result.createdPlayers} Spieler neu erfasst.` : '.'),
);
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 && (
<section className="block">
<Notice kind="info">
CSV oder JSON hochladen. Danach werden die Spalten zugeordnet und eine Vorschau
gezeigt — gebucht wird erst nach Bestaetigung. Bereits importierte Bestell-IDs
werden automatisch uebersprungen.
</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 auswaehlen'}</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 (fuer 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 Kaeufe 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 wuerde gebucht. Pruefe 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 aendern
</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>
);
}