wutzcalc/client/src/admin/Admin.tsx

359 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'preact/hooks';
import { formatCents } from '../api';
async function j<T = any>(res: Response): Promise<T> {
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
interface Drink { id: number; name: string; price_cents: number; archived: number }
interface BarRow { id: number; name: string; pfand_cents: number; drink_ids: number[] }
interface Totals { bar_id: number; bar_name: string; tx_count: number; paid_cents: number; crew_count: number; pfand_returns: number }
interface PerDrink { drink_id: number; drink_name: string; sold_qty: number }
export function Admin() {
const [authed, setAuthed] = useState<boolean | null>(null);
useEffect(() => {
fetch('/admin/api/me').then(j).then(r => setAuthed(r.authed)).catch(() => setAuthed(false));
}, []);
if (authed === null) return <div class="admin"><p>Lade…</p></div>;
if (!authed) return <Login onAuthed={() => setAuthed(true)} />;
return <Dashboard onLogout={() => setAuthed(false)} />;
}
function Login({ onAuthed }: { onAuthed: () => void }) {
const [pw, setPw] = useState('');
const [err, setErr] = useState<string | null>(null);
async function submit(e: Event) {
e.preventDefault();
setErr(null);
try {
await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
}).then(j);
onAuthed();
} catch (e: any) {
setErr(String(e));
}
}
return (
<form class="admin" onSubmit={submit}>
<div class="login">
<h1>Backoffice</h1>
<input
type="password"
placeholder="Passwort"
value={pw}
onInput={(e: any) => setPw(e.currentTarget.value)}
/>
{err && <div style="color:#ff8a8a">{err}</div>}
<button type="submit">Anmelden</button>
</div>
</form>
);
}
function Dashboard({ onLogout }: { onLogout: () => void }) {
return (
<div class="admin">
<div class="row" style="justify-content: space-between">
<h1 style="margin:0">wutzcalc Backoffice</h1>
<button onClick={async () => { await fetch('/admin/logout', { method: 'POST' }); onLogout(); }}>
Abmelden
</button>
</div>
<Stats />
<Exports />
<Drinks />
<Bars />
</div>
);
}
function Stats() {
const [data, setData] = useState<{ totals: Totals[]; per_drink: PerDrink[] } | null>(null);
useEffect(() => { fetch('/admin/api/stats').then(j).then(setData); }, []);
if (!data) return <p>Lade Statistik…</p>;
return (
<>
<h2>Umsatz pro Bar</h2>
<table>
<thead><tr><th>Bar</th><th>Transaktionen</th><th>Bezahlt</th><th>Crew-Transaktionen</th><th>Pfand zurück</th></tr></thead>
<tbody>
{data.totals.map(t => (
<tr key={t.bar_id}>
<td>{t.bar_name}</td>
<td>{t.tx_count}</td>
<td>{formatCents(t.paid_cents)}</td>
<td>{t.crew_count}</td>
<td>{t.pfand_returns}</td>
</tr>
))}
</tbody>
</table>
<h2>Getränke</h2>
<table>
<thead><tr><th>Getränk</th><th>Verkauft</th></tr></thead>
<tbody>
{data.per_drink.map(d => (
<tr key={d.drink_id}>
<td>{d.drink_name}</td>
<td>{d.sold_qty ?? 0}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
function Exports() {
return (
<>
<h2>CSV-Export</h2>
<div class="row">
<a href="/admin/api/export.csv?what=transactions"><button>Transaktionen</button></a>
<a href="/admin/api/export.csv?what=items"><button>Positionen</button></a>
</div>
</>
);
}
function Drinks() {
const [list, setList] = useState<Drink[]>([]);
const [name, setName] = useState('');
const [price, setPrice] = useState('');
function reload() { fetch('/admin/api/drinks').then(j).then(setList); }
useEffect(reload, []);
async function add() {
const cents = Math.round(parseFloat(price) * 100);
if (!name || !Number.isFinite(cents)) return;
await fetch('/admin/api/drinks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, price_cents: cents }),
});
setName(''); setPrice(''); reload();
}
async function patch(id: number, body: Partial<Drink>) {
await fetch(`/admin/api/drinks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
reload();
}
return (
<>
<h2>Getränke</h2>
<table>
<thead><tr><th>Name</th><th>Preis</th><th>Status</th><th></th></tr></thead>
<tbody>
{list.map(d => (
<tr key={d.id}>
<td>
<input value={d.name} onChange={(e: any) => patch(d.id, { name: e.currentTarget.value })} />
</td>
<td>
<input
type="number"
step="0.01"
value={(d.price_cents / 100).toFixed(2)}
onChange={(e: any) => patch(d.id, { price_cents: Math.round(parseFloat(e.currentTarget.value) * 100) })}
/>
</td>
<td class={d.archived ? 'muted' : ''}>{d.archived ? 'archiviert' : 'aktiv'}</td>
<td>
<button onClick={() => patch(d.id, { archived: !d.archived } as any)}>
{d.archived ? 'aktivieren' : 'archivieren'}
</button>
</td>
</tr>
))}
</tbody>
</table>
<div class="row">
<input placeholder="Name" value={name} onInput={(e: any) => setName(e.currentTarget.value)} />
<input placeholder="Preis €" type="number" step="0.01" value={price} onInput={(e: any) => setPrice(e.currentTarget.value)} />
<button onClick={add}>Hinzufügen</button>
</div>
</>
);
}
function BarDrinkEditor({
bar,
allDrinks,
onChange,
}: {
bar: BarRow;
allDrinks: Drink[];
onChange: (ids: number[]) => void;
}) {
const byId = new Map(allDrinks.map(d => [d.id, d]));
const selected = bar.drink_ids.filter(id => {
const d = byId.get(id);
return d && !d.archived;
});
const available = allDrinks.filter(d => !d.archived && !selected.includes(d.id));
const dragFrom = useRef<number | null>(null);
const [dragOver, setDragOver] = useState<number | null>(null);
function remove(idx: number) {
onChange(selected.filter((_, i) => i !== idx));
}
function add(id: number) {
onChange([...selected, id]);
}
function reorder(from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= selected.length || to >= selected.length) return;
const next = selected.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved!);
onChange(next);
}
return (
<>
<div style="margin-top:8px; font-size:14px; opacity:0.7">Aktive Getränke (zum Sortieren ziehen)</div>
<ul class="dnd-list">
{selected.length === 0 && <li class="muted">Keine</li>}
{selected.map((id, idx) => {
const d = byId.get(id);
if (!d) return null;
return (
<li
key={id}
class={`dnd-item ${dragOver === idx ? 'drop-target' : ''}`}
draggable
onDragStart={(e: any) => {
dragFrom.current = idx;
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e: any) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOver !== idx) setDragOver(idx);
}}
onDragLeave={() => { if (dragOver === idx) setDragOver(null); }}
onDrop={(e: any) => {
e.preventDefault();
const from = dragFrom.current;
dragFrom.current = null;
setDragOver(null);
if (from != null) reorder(from, idx);
}}
onDragEnd={() => { dragFrom.current = null; setDragOver(null); }}
>
<span class="grip" aria-hidden="true">⋮⋮</span>
<span class="dnd-name">{idx + 1}. {d.name}</span>
<button onClick={() => remove(idx)}>×</button>
</li>
);
})}
</ul>
{available.length > 0 && (
<>
<div style="font-size:14px; opacity:0.7">Hinzufügen</div>
<div class="row">
{available.map(d => (
<button key={d.id} onClick={() => add(d.id)}>+ {d.name}</button>
))}
</div>
</>
)}
</>
);
}
function Bars() {
const [bars, setBars] = useState<BarRow[]>([]);
const [drinks, setDrinks] = useState<Drink[]>([]);
const [newName, setNewName] = useState('');
function reload() {
fetch('/admin/api/bars').then(j).then(setBars);
fetch('/admin/api/drinks').then(j).then(setDrinks);
}
useEffect(reload, []);
async function patch(id: number, body: Partial<BarRow>) {
const res = await fetch(`/admin/api/bars/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) alert(`Fehler: ${await res.text()}`);
reload();
}
async function addBar() {
const name = newName.trim();
if (!name) return;
const res = await fetch('/admin/api/bars', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, pfand_cents: 200 }),
});
if (!res.ok) {
alert(`Fehler: ${await res.text()}`);
return;
}
setNewName('');
reload();
}
return (
<>
<h2>Bars</h2>
{bars.map(b => (
<div key={b.id} style="margin-bottom:16px; padding:8px; border:1px solid #333; border-radius:6px">
<div class="row">
<label>
Name
<input
value={b.name}
onChange={(e: any) => patch(b.id, { name: e.currentTarget.value } as any)}
/>
</label>
<label>
Pfand €
<input
type="number"
step="0.01"
value={(b.pfand_cents / 100).toFixed(2)}
onChange={(e: any) =>
patch(b.id, { pfand_cents: Math.round(parseFloat(e.currentTarget.value) * 100) } as any)
}
/>
</label>
</div>
<BarDrinkEditor
bar={b}
allDrinks={drinks}
onChange={(ids) => patch(b.id, { drink_ids: ids } as any)}
/>
</div>
))}
<div class="row">
<input
placeholder="Neue Bar"
value={newName}
onInput={(e: any) => setNewName(e.currentTarget.value)}
/>
<button onClick={addBar}>Bar hinzufügen</button>
</div>
</>
);
}