256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
import { useEffect, 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 Bars() {
|
|
const [bars, setBars] = useState<BarRow[]>([]);
|
|
const [drinks, setDrinks] = useState<Drink[]>([]);
|
|
|
|
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>) {
|
|
await fetch(`/admin/api/bars/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
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">
|
|
<strong>{b.name}</strong>
|
|
<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>
|
|
<div class="row">
|
|
{drinks.filter(d => !d.archived).map(d => {
|
|
const checked = b.drink_ids.includes(d.id);
|
|
return (
|
|
<label key={d.id}>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => {
|
|
const next = checked
|
|
? b.drink_ids.filter(x => x !== d.id)
|
|
: [...b.drink_ids, d.id];
|
|
patch(b.id, { drink_ids: next } as any);
|
|
}}
|
|
/>
|
|
{d.name}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|