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.
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user