scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin)
This commit is contained in:
parent
cf33e6ea04
commit
e0898bea22
34 changed files with 5446 additions and 0 deletions
256
client/src/admin/Admin.tsx
Normal file
256
client/src/admin/Admin.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue