Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den Namen kennt nur er, die Migration nicht. Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt", obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger, der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch. Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt - sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben wird nichts. Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht, ohne im Hintergrund dauernd zu fragen.
805 lines
25 KiB
TypeScript
805 lines
25 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import Modal from '../components/Modal';
|
|
import Notice from '../components/Notice';
|
|
import { ApiError, api } from '../lib/api';
|
|
import { formatDate } from '../lib/format';
|
|
import { useSession } from '../lib/session';
|
|
import type {
|
|
AdminUser,
|
|
AuditEntry,
|
|
ProductMapping,
|
|
ProductTarget,
|
|
Role,
|
|
ScheduleSetting,
|
|
ScheduleState,
|
|
} from '../lib/types';
|
|
|
|
type Tab = 'users' | 'mappings' | 'audit' | 'schedule';
|
|
|
|
const TARGET_LABEL: Record<ProductTarget, string> = {
|
|
CC_PACKAGE: 'CC-Paket',
|
|
REDESIGN_PACKAGE: 'Redesign-Paket',
|
|
IGNORE: 'Ignorieren',
|
|
};
|
|
|
|
export default function Admin() {
|
|
const [tab, setTab] = useState<Tab>('users');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [flash, setFlash] = useState<string | null>(null);
|
|
|
|
const notify = (message: string) => {
|
|
setFlash(message);
|
|
window.setTimeout(() => setFlash(null), 4000);
|
|
};
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<h1>Verwaltung</h1>
|
|
</div>
|
|
|
|
<div className="tabs">
|
|
<button
|
|
type="button"
|
|
className={tab === 'users' ? 'tab is-active' : 'tab'}
|
|
onClick={() => setTab('users')}
|
|
>
|
|
Nutzer
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tab === 'mappings' ? 'tab is-active' : 'tab'}
|
|
onClick={() => setTab('mappings')}
|
|
>
|
|
Produkt-Mappings
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tab === 'audit' ? 'tab is-active' : 'tab'}
|
|
onClick={() => setTab('audit')}
|
|
>
|
|
Audit-Log
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={tab === 'schedule' ? 'tab is-active' : 'tab'}
|
|
onClick={() => setTab('schedule')}
|
|
>
|
|
Zeitplan
|
|
</button>
|
|
</div>
|
|
|
|
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
|
|
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
|
|
|
|
{tab === 'users' && <UsersTab onError={setError} onDone={notify} />}
|
|
{tab === 'mappings' && <MappingsTab onError={setError} onDone={notify} />}
|
|
{tab === 'audit' && <AuditTab onError={setError} />}
|
|
{tab === 'schedule' && <ScheduleTab onError={setError} onDone={notify} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TabProps {
|
|
onError: (message: string) => void;
|
|
onDone: (message: string) => void;
|
|
}
|
|
|
|
function UsersTab({ onError, onDone }: TabProps) {
|
|
const { user: me } = useSession();
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const result = await api<{ items: AdminUser[] }>('/api/admin/users');
|
|
setUsers(result.items);
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
|
}
|
|
}, [onError]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const act = async (action: () => Promise<unknown>, message: string) => {
|
|
try {
|
|
await action();
|
|
await load();
|
|
onDone(message);
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Aktion fehlgeschlagen');
|
|
}
|
|
};
|
|
|
|
const resetPassword = (target: AdminUser) => {
|
|
const password = window.prompt(
|
|
`Neues Passwort für "${target.username}" (min. 12 Zeichen, Groß/Klein, Ziffer):`,
|
|
);
|
|
if (!password) return;
|
|
void act(
|
|
() =>
|
|
api(`/api/admin/users/${target.id}/reset-password`, {
|
|
method: 'POST',
|
|
body: { newPassword: password },
|
|
}),
|
|
`Passwort für "${target.username}" gesetzt. Muss beim nächsten Login geändert werden.`,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section className="block">
|
|
<div className="block-head">
|
|
<h2>Nutzer</h2>
|
|
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
|
|
Nutzer anlegen
|
|
</button>
|
|
</div>
|
|
|
|
<Notice kind="info">
|
|
Es gibt keine öffentliche Registrierung. Konten werden ausschließlich hier angelegt.
|
|
</Notice>
|
|
|
|
<div className="table-wrap">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Benutzername</th>
|
|
<th>Rolle</th>
|
|
<th>Status</th>
|
|
<th>2FA</th>
|
|
<th>Letzter Login</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((row) => (
|
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
|
<td>
|
|
<strong>{row.username}</strong>
|
|
{row.id === me?.id && <span className="badge">du</span>}
|
|
{row.isLocked && (
|
|
<span
|
|
className="badge"
|
|
title="Zugang aus der Erstinstallation - geschützt, damit immer ein Weg zurück bleibt"
|
|
>
|
|
geschützt
|
|
</span>
|
|
)}
|
|
{row.mustChangePassword && <span className="badge badge-paid">Erstpasswort</span>}
|
|
</td>
|
|
<td>
|
|
<select
|
|
value={row.role}
|
|
disabled={row.id === me?.id || row.isLocked === true}
|
|
onChange={(event) =>
|
|
void act(
|
|
() =>
|
|
api(`/api/admin/users/${row.id}`, {
|
|
method: 'PATCH',
|
|
body: { role: event.target.value as Role },
|
|
}),
|
|
`Rolle von "${row.username}" geändert.`,
|
|
)
|
|
}
|
|
>
|
|
<option value="VIEWER">Viewer</option>
|
|
<option value="EDITOR">Editor</option>
|
|
<option value="ADMIN">Admin</option>
|
|
</select>
|
|
</td>
|
|
<td>{row.isActive ? 'aktiv' : 'gesperrt'}</td>
|
|
<td className="muted">{row.totpEnabled ? 'ja' : 'nein'}</td>
|
|
<td className="muted small">{formatDate(row.lastLoginAt)}</td>
|
|
<td className="row-actions">
|
|
{row.isLocked ? (
|
|
<span className="muted small">
|
|
{row.id === me?.id
|
|
? 'Passwort änderst du unter Konto'
|
|
: 'Erstzugang - unantastbar'}
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-tiny btn-ghost"
|
|
onClick={() => resetPassword(row)}
|
|
>
|
|
Passwort
|
|
</button>
|
|
)}
|
|
{row.id !== me?.id && !row.isLocked && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn btn-tiny btn-ghost"
|
|
onClick={() =>
|
|
void act(
|
|
() =>
|
|
api(`/api/admin/users/${row.id}`, {
|
|
method: 'PATCH',
|
|
body: { isActive: !row.isActive },
|
|
}),
|
|
row.isActive
|
|
? `"${row.username}" gesperrt.`
|
|
: `"${row.username}" entsperrt.`,
|
|
)
|
|
}
|
|
>
|
|
{row.isActive ? 'Sperren' : 'Entsperren'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-tiny btn-danger"
|
|
onClick={() => {
|
|
if (!window.confirm(`Konto "${row.username}" wirklich löschen?`)) return;
|
|
void act(
|
|
() => api(`/api/admin/users/${row.id}`, { method: 'DELETE' }),
|
|
`"${row.username}" gelöscht.`,
|
|
);
|
|
}}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{creating && (
|
|
<CreateUserModal
|
|
onClose={() => setCreating(false)}
|
|
onCreated={(username) => {
|
|
setCreating(false);
|
|
void load();
|
|
onDone(`Konto "${username}" angelegt.`);
|
|
}}
|
|
onError={onError}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CreateUserModal({
|
|
onClose,
|
|
onCreated,
|
|
onError,
|
|
}: {
|
|
onClose: () => void;
|
|
onCreated: (username: string) => void;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [role, setRole] = useState<Role>('EDITOR');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setBusy(true);
|
|
try {
|
|
await api('/api/admin/users', { method: 'POST', body: { username, password, role } });
|
|
onCreated(username);
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal title="Nutzer anlegen" onClose={onClose}>
|
|
<form onSubmit={submit}>
|
|
<label htmlFor="new-username">Benutzername</label>
|
|
<input
|
|
id="new-username"
|
|
value={username}
|
|
onChange={(event) => setUsername(event.target.value)}
|
|
autoFocus
|
|
required
|
|
/>
|
|
|
|
<label htmlFor="new-password">Erstpasswort</label>
|
|
<input
|
|
id="new-password"
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
required
|
|
/>
|
|
<p className="muted small">
|
|
Min. 12 Zeichen, Groß- und Kleinbuchstaben, eine Ziffer. Muss beim ersten Login
|
|
geändert werden.
|
|
</p>
|
|
|
|
<label htmlFor="new-role">Rolle</label>
|
|
<select
|
|
id="new-role"
|
|
value={role}
|
|
onChange={(event) => setRole(event.target.value as Role)}
|
|
>
|
|
<option value="VIEWER">Viewer - nur lesen</option>
|
|
<option value="EDITOR">Editor - Spieler, Guthaben, CCs</option>
|
|
<option value="ADMIN">Admin - alles inkl. Import und Nutzer</option>
|
|
</select>
|
|
|
|
<div className="form-actions">
|
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
|
Abbrechen
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
{busy ? 'Lege an …' : 'Anlegen'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function MappingsTab({ onError, onDone }: TabProps) {
|
|
const [items, setItems] = useState<ProductMapping[]>([]);
|
|
const [pattern, setPattern] = useState('');
|
|
const [target, setTarget] = useState<ProductTarget>('CC_PACKAGE');
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const result = await api<{ items: ProductMapping[] }>('/api/admin/product-mappings');
|
|
setItems(result.items);
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
|
}
|
|
}, [onError]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const add = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
try {
|
|
await api('/api/admin/product-mappings', {
|
|
method: 'POST',
|
|
body: { matchPattern: pattern, target },
|
|
});
|
|
setPattern('');
|
|
await load();
|
|
onDone('Mapping angelegt.');
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Anlegen fehlgeschlagen');
|
|
}
|
|
};
|
|
|
|
const remove = async (row: ProductMapping) => {
|
|
if (!window.confirm(`Mapping "${row.matchPattern}" löschen?`)) return;
|
|
try {
|
|
await api(`/api/admin/product-mappings/${row.id}`, { method: 'DELETE' });
|
|
await load();
|
|
onDone('Mapping gelöscht.');
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Löschen fehlgeschlagen');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="block">
|
|
<h2>Produkt-Mappings</h2>
|
|
<Notice kind="info">
|
|
Beim Import wird der Produktname gegen diese Muster geprüft (Teiltreffer, Groß- und
|
|
Kleinschreibung egal). Das erste passende Muster mit der niedrigsten Priorität gewinnt.
|
|
</Notice>
|
|
|
|
<form className="inline-form" onSubmit={add}>
|
|
<input
|
|
placeholder="Muster, z. B. cc-paket"
|
|
value={pattern}
|
|
onChange={(event) => setPattern(event.target.value)}
|
|
required
|
|
/>
|
|
<select value={target} onChange={(event) => setTarget(event.target.value as ProductTarget)}>
|
|
<option value="CC_PACKAGE">CC-Paket</option>
|
|
<option value="REDESIGN_PACKAGE">Redesign-Paket</option>
|
|
<option value="IGNORE">Ignorieren</option>
|
|
</select>
|
|
<button type="submit" className="btn btn-primary">
|
|
Hinzufügen
|
|
</button>
|
|
</form>
|
|
|
|
<div className="table-wrap">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Muster</th>
|
|
<th>Bedeutung</th>
|
|
<th className="num">Priorität</th>
|
|
<th>Aktiv</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((row) => (
|
|
<tr key={row.id} className={row.isActive ? undefined : 'is-skipped'}>
|
|
<td className="mono">{row.matchPattern}</td>
|
|
<td>{TARGET_LABEL[row.target]}</td>
|
|
<td className="num muted">{row.priority}</td>
|
|
<td className="muted">{row.isActive ? 'ja' : 'nein'}</td>
|
|
<td className="row-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-tiny btn-danger"
|
|
onClick={() => void remove(row)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{items.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="empty">
|
|
Noch keine Mappings.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AuditTab({ onError }: { onError: (message: string) => void }) {
|
|
const [items, setItems] = useState<AuditEntry[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [filter, setFilter] = useState('');
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const params = new URLSearchParams({ take: '150' });
|
|
if (filter) params.set('action', filter);
|
|
const result = await api<{ items: AuditEntry[]; total: number }>(
|
|
`/api/admin/audit?${params}`,
|
|
{ signal: controller.signal },
|
|
);
|
|
setItems(result.items);
|
|
setTotal(result.total);
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
|
}
|
|
}, 250);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [filter, onError]);
|
|
|
|
return (
|
|
<section className="block">
|
|
<h2>Audit-Log</h2>
|
|
|
|
<input
|
|
className="search"
|
|
placeholder="Nach Aktion filtern, z. B. credit. oder character."
|
|
value={filter}
|
|
onChange={(event) => setFilter(event.target.value)}
|
|
/>
|
|
|
|
<div className="table-wrap scroll">
|
|
<table className="table small">
|
|
<thead>
|
|
<tr>
|
|
<th>Zeitpunkt</th>
|
|
<th>Wer</th>
|
|
<th>Aktion</th>
|
|
<th>Ziel</th>
|
|
<th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((entry) => (
|
|
<tr key={entry.id}>
|
|
<td className="muted small">{formatDate(entry.createdAt)}</td>
|
|
<td>{entry.actor?.username ?? entry.actorName ?? '-'}</td>
|
|
<td className="mono">{entry.action}</td>
|
|
<td className="muted">
|
|
{entry.targetType}
|
|
{entry.targetId ? ` #${entry.targetId}` : ''}
|
|
</td>
|
|
<td className="muted small details">
|
|
{entry.details ? JSON.stringify(entry.details) : '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{items.length === 0 && (
|
|
<tr>
|
|
<td colSpan={5} className="empty">
|
|
Keine Einträge.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{total > items.length && (
|
|
<p className="muted">
|
|
{items.length} von {total} Einträgen angezeigt.
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Zeitplan: was von allein laufen soll und wie oft.
|
|
*
|
|
* Die Abstände stehen in Minuten, weil sich damit alles ausdrücken lässt -
|
|
* daneben steht, was das in Stunden bedeutet, damit niemand im Kopf rechnen
|
|
* muss. Beide Abgleiche greifen auf fremde Server zu; ein sehr kurzer Takt
|
|
* ist deshalb keine gute Idee und unter einer Viertelstunde nicht möglich.
|
|
*/
|
|
function ScheduleTab({
|
|
onError,
|
|
onDone,
|
|
}: {
|
|
onError: (message: string) => void;
|
|
onDone: (message: string) => void;
|
|
}) {
|
|
const [stand, setStand] = useState<ScheduleState | null>(null);
|
|
|
|
const laden = useCallback(async () => {
|
|
try {
|
|
setStand(await api<ScheduleState>('/api/admin/schedule'));
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
|
|
}
|
|
}, [onError]);
|
|
|
|
useEffect(() => {
|
|
void laden();
|
|
}, [laden]);
|
|
|
|
const LABEL: Record<string, string> = {
|
|
gextension: 'Käufe bei GExtension',
|
|
forum: 'Forums-Anträge',
|
|
letzter_fehler: 'Zuletzt gescheitert',
|
|
};
|
|
|
|
if (stand === null) {
|
|
return (
|
|
<section className="block">
|
|
<h2>Zeitplan</h2>
|
|
<p className="muted">Lade …</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<section className="block">
|
|
<h2>Zeitplan</h2>
|
|
<Notice kind="info">
|
|
Gebucht wird hier nichts von allein: neue Pakete landen in der Import-Vorschau, neue
|
|
Anträge in der Liste. Beide Abgleiche greifen auf Server zu, die der Community gehören -
|
|
ein knapper Takt bringt wenig und belastet sie ohne Not.
|
|
</Notice>
|
|
|
|
{stand.settings.map((setting) => (
|
|
<ScheduleForm
|
|
key={setting.name}
|
|
setting={setting}
|
|
titel={LABEL[setting.name] ?? setting.name}
|
|
onSaved={(text) => {
|
|
onDone(text);
|
|
void laden();
|
|
}}
|
|
onError={onError}
|
|
/>
|
|
))}
|
|
</section>
|
|
|
|
<section className="block">
|
|
<h2>Letzte Läufe</h2>
|
|
{stand.runs.length === 0 ? (
|
|
<p className="muted">Noch kein Lauf verzeichnet.</p>
|
|
) : (
|
|
<div className="table-wrap">
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Lauf</th>
|
|
<th>Zuletzt</th>
|
|
<th>Ergebnis</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stand.runs.map((run) => (
|
|
<tr key={run.name}>
|
|
<td>{LABEL[run.name] ?? run.name}</td>
|
|
<td className="muted small">{formatDate(run.lastRunAt)}</td>
|
|
<td className="muted small">
|
|
{run.lastStatus === 'FAILED' && <span className="neg">Fehler: </span>}
|
|
{run.lastNote ?? '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Minuten in eine Angabe, die sich lesen lässt. */
|
|
function inStunden(minuten: number): string {
|
|
if (minuten < 60) return `${minuten} Minuten`;
|
|
if (minuten % 1440 === 0) {
|
|
const tage = minuten / 1440;
|
|
return tage === 1 ? 'einmal täglich' : `alle ${tage} Tage`;
|
|
}
|
|
const stunden = minuten / 60;
|
|
const text = Number.isInteger(stunden) ? String(stunden) : stunden.toFixed(1).replace('.', ',');
|
|
return `${text} Stunden`;
|
|
}
|
|
|
|
function ScheduleForm({
|
|
setting,
|
|
titel,
|
|
onSaved,
|
|
onError,
|
|
}: {
|
|
setting: ScheduleSetting;
|
|
titel: string;
|
|
onSaved: (message: string) => void;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const [enabled, setEnabled] = useState(setting.enabled);
|
|
const [interval, setInterval] = useState(String(setting.intervalMinutes));
|
|
const [idleInterval, setIdleInterval] = useState(
|
|
setting.idleIntervalMinutes === null ? '' : String(setting.idleIntervalMinutes),
|
|
);
|
|
const [idleDays, setIdleDays] = useState(
|
|
setting.idleAfterDays === null ? '' : String(setting.idleAfterDays),
|
|
);
|
|
const [hour, setHour] = useState(
|
|
setting.onlyAfterHour === null ? '' : String(setting.onlyAfterHour),
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const istKaeufe = setting.name === 'gextension';
|
|
|
|
const speichern = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setBusy(true);
|
|
try {
|
|
await api(`/api/admin/schedule/${setting.name}`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
enabled,
|
|
intervalMinutes: Number(interval),
|
|
...(istKaeufe
|
|
? {
|
|
idleIntervalMinutes: idleInterval ? Number(idleInterval) : null,
|
|
idleAfterDays: idleDays ? Number(idleDays) : null,
|
|
}
|
|
: { onlyAfterHour: hour === '' ? null : Number(hour) }),
|
|
},
|
|
});
|
|
onSaved(`Zeitplan für „${titel}" gespeichert.`);
|
|
} catch (err) {
|
|
onError(err instanceof ApiError ? err.message : 'Speichern fehlgeschlagen');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={speichern} className="schedule-form">
|
|
<div className="schedule-head">
|
|
<h3>{titel}</h3>
|
|
<label className="checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={enabled}
|
|
onChange={(event) => setEnabled(event.target.checked)}
|
|
/>
|
|
<span>{enabled ? 'läuft automatisch' : 'nur von Hand'}</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="schedule-fields">
|
|
<div>
|
|
<label htmlFor={`${setting.name}-interval`}>Abstand (Minuten)</label>
|
|
<input
|
|
id={`${setting.name}-interval`}
|
|
type="number"
|
|
min={15}
|
|
max={10080}
|
|
value={interval}
|
|
onChange={(event) => setInterval(event.target.value)}
|
|
disabled={!enabled}
|
|
required
|
|
/>
|
|
<span className="muted small">
|
|
{Number(interval) >= 15 ? `= ${inStunden(Number(interval))}` : 'mindestens 15 Minuten'}
|
|
</span>
|
|
</div>
|
|
|
|
{istKaeufe ? (
|
|
<>
|
|
<div>
|
|
<label htmlFor={`${setting.name}-idle`}>Ruhende Konten (Minuten)</label>
|
|
<input
|
|
id={`${setting.name}-idle`}
|
|
type="number"
|
|
min={15}
|
|
max={10080}
|
|
value={idleInterval}
|
|
onChange={(event) => setIdleInterval(event.target.value)}
|
|
disabled={!enabled}
|
|
placeholder="leer = derselbe Abstand"
|
|
/>
|
|
<span className="muted small">
|
|
{idleInterval && Number(idleInterval) >= 15
|
|
? `= ${inStunden(Number(idleInterval))}`
|
|
: 'wie oben'}
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor={`${setting.name}-days`}>Ruhend ab (Tage ohne Kauf)</label>
|
|
<input
|
|
id={`${setting.name}-days`}
|
|
type="number"
|
|
min={1}
|
|
value={idleDays}
|
|
onChange={(event) => setIdleDays(event.target.value)}
|
|
disabled={!enabled}
|
|
placeholder="leer = alle gleich"
|
|
/>
|
|
<span className="muted small">
|
|
{idleDays ? `= rund ${Math.round(Number(idleDays) / 365)} Jahre` : 'kein Unterschied'}
|
|
</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div>
|
|
<label htmlFor={`${setting.name}-hour`}>Frühestens ab (Uhr)</label>
|
|
<input
|
|
id={`${setting.name}-hour`}
|
|
type="number"
|
|
min={0}
|
|
max={23}
|
|
value={hour}
|
|
onChange={(event) => setHour(event.target.value)}
|
|
disabled={!enabled}
|
|
placeholder="leer = jederzeit"
|
|
/>
|
|
<span className="muted small">
|
|
{hour === ''
|
|
? 'sobald der Abstand um ist'
|
|
: `nicht vor ${hour} Uhr - bei einem täglichen Takt also nachts`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-actions">
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
{busy ? 'Speichere …' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|