Initial commit
This commit is contained in:
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Hellas – Bestand & WaWi
|
||||
|
||||
## Überblick
|
||||
Dieses Repository enthält:
|
||||
- **WaWi‑App** (Flask + SQLite) für Artikelverwaltung, Ausbuchen und Verkäufe.
|
||||
- **Live‑Bestand Ansicht** (`index.html`) die den Bestand aus der WaWi‑API lädt.
|
||||
- **Import‑Script** für Daten aus der ursprünglichen `hellas_bestand.html`.
|
||||
|
||||
## Struktur
|
||||
- `wawi/app.py` – Flask‑App (CRUD, Ausbuchen, Verkäufe, Benutzerverwaltung)
|
||||
- `wawi/templates/` – HTML‑Templates der WaWi‑App
|
||||
- `wawi/static/` – Styles + Logo
|
||||
- `wawi/hellas.db` – SQLite‑Datenbank (wird automatisch erstellt)
|
||||
- `wawi/import_from_html.py` – Import aus `hellas_bestand.html`
|
||||
- `index.html` – Live‑Bestand Ansicht (ruft `/wawi/proxy/bestand`)
|
||||
|
||||
## WaWi lokal starten
|
||||
```bash
|
||||
cd wawi
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install flask
|
||||
python app.py
|
||||
```
|
||||
Standardzugriff: Login über Benutzerverwaltung (siehe unten).
|
||||
|
||||
## Benutzerverwaltung
|
||||
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
|
||||
- `APP_USER` (default: `admin`)
|
||||
- `APP_PASSWORD` (default: `admin`)
|
||||
|
||||
Passwörter werden **gehasht** gespeichert.
|
||||
Über `/users` können weitere Benutzer angelegt und Passwörter zurückgesetzt werden.
|
||||
|
||||
## Import aus hellas_bestand.html
|
||||
```bash
|
||||
cd wawi
|
||||
python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
|
||||
```
|
||||
|
||||
## Live‑Bestand Ansicht (index.html)
|
||||
`index.html` lädt den Bestand aus der WaWi‑App:
|
||||
- API‑Proxy: `/wawi/proxy/bestand`
|
||||
|
||||
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist.
|
||||
|
||||
## Deployment (Kurz)
|
||||
1) App nach `/var/www/hellas/wawi` kopieren
|
||||
2) Gunicorn + systemd starten
|
||||
3) Nginx Reverse‑Proxy auf `/wawi`
|
||||
|
||||
Für Produktion setze:
|
||||
- `SECRET_KEY` (Session‑Cookie)
|
||||
- `APP_USER`, `APP_PASSWORD` (Admin)
|
||||
- optional `APP_API_KEY` (für direkten API‑Zugriff)
|
||||
|
||||
397
hellas_bestand Kopie.html
Normal file
397
hellas_bestand Kopie.html
Normal file
File diff suppressed because one or more lines are too long
397
hellas_bestand.html
Normal file
397
hellas_bestand.html
Normal file
File diff suppressed because one or more lines are too long
621
index.html
Normal file
621
index.html
Normal file
@@ -0,0 +1,621 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Hellas – Shop</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a2036;
|
||||
--card: #0f2236;
|
||||
--muted: #9fb1c8;
|
||||
--text: #f1f4f8;
|
||||
--line: rgba(255,255,255,.12);
|
||||
--accent: #f3d52a;
|
||||
--teal: #2a8a8a;
|
||||
--ok: #7bd58d;
|
||||
--bad: #ff6b7d;
|
||||
--warn: #f0c04a;
|
||||
--shadow: 0 12px 28px rgba(0,0,0,.28);
|
||||
--radius: 14px;
|
||||
--surface: #0f2236;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
background:
|
||||
radial-gradient(900px 520px at 50% -10%, rgba(255,255,255,.10), transparent 60%),
|
||||
radial-gradient(900px 600px at 50% 20%, rgba(16,44,70,.85), transparent 65%),
|
||||
radial-gradient(1200px 700px at 50% 80%, rgba(7,26,44,.95), transparent 70%),
|
||||
linear-gradient(180deg, #0a2036 0%, #0b243b 45%, #092135 100%);
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(9,24,40,.9);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,.35);
|
||||
}
|
||||
header::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--teal), rgba(42,138,138,.0));
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand img {
|
||||
height: 42px;
|
||||
width: auto;
|
||||
display: block;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,.4));
|
||||
}
|
||||
h1 {
|
||||
font-family: "Arial Narrow", "Helvetica Neue Condensed", Impact, "Franklin Gothic Medium", "Arial Black", sans-serif;
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 12px; letter-spacing: .3px; }
|
||||
.controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.search {
|
||||
min-width: 240px; max-width: 460px; flex: 1;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,.06) inset;
|
||||
}
|
||||
.search input {
|
||||
width: 100%; border: 0; outline: 0; background: transparent;
|
||||
color: var(--text); font-size: 14px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
user-select: none; cursor: pointer;
|
||||
font-size: 13px; color: var(--text);
|
||||
box-shadow: 0 2px 0 rgba(0,0,0,.18);
|
||||
}
|
||||
.pill input { accent-color: var(--accent); }
|
||||
main .wrap { padding-top: 14px; padding-bottom: 28px; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; }
|
||||
.stat .value { font-size: 18px; font-weight: 700; letter-spacing: .3px; color: var(--accent); }
|
||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
.card-tile {
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
border-radius: 16px;
|
||||
background: rgba(12,31,51,.92);
|
||||
box-shadow: 0 10px 22px rgba(0,0,0,.28);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.card-tile:hover { transform: translateY(-1px); box-shadow: 0 14px 26px rgba(0,0,0,.32); }
|
||||
.card-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: rgba(0,0,0,.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .6px;
|
||||
}
|
||||
.card-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.card-body { padding: 12px 12px 10px; display: grid; gap: 6px; }
|
||||
.card-title {
|
||||
font-family: "Arial Narrow", "Helvetica Neue Condensed", Impact, "Franklin Gothic Medium", "Arial Black", sans-serif;
|
||||
font-size: 16px; font-weight: 600; letter-spacing: .6px;
|
||||
}
|
||||
.card-price { color: var(--accent); font-weight: 800; font-size: 14px; }
|
||||
.card-sub { color: var(--muted); font-size: 12px; }
|
||||
.card-actions { padding: 0 12px 12px; display: flex; justify-content: flex-end; }
|
||||
.detail-btn { width: 100%; }
|
||||
.size-list { display: grid; gap: 8px; padding: 10px 2px 2px; }
|
||||
.size-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; padding: 8px 10px; border-radius: 10px; }
|
||||
.size-row:nth-child(odd) { background: rgba(255,255,255,.04); }
|
||||
.size-meta { font-size: 13px; color: var(--text); }
|
||||
.size-meta strong { font-weight: 700; letter-spacing: .2px; }
|
||||
.art { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.art .name {
|
||||
font-family: "Arial Narrow", "Helvetica Neue Condensed", Impact, "Franklin Gothic Medium", "Arial Black", sans-serif;
|
||||
font-size: 18px; font-weight: 600; letter-spacing: .6px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.art .sub { font-size: 12px; color: var(--muted); }
|
||||
.badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.badge {
|
||||
font-size: 12px; padding: 6px 10px; border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.05);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,.08), rgba(0,0,0,.2)),
|
||||
radial-gradient(circle at 30% 30%, rgba(255,255,255,.25), transparent 40%);
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .6px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.price {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.content {
|
||||
border-top: 1px solid rgba(255,255,255,.08);
|
||||
padding: 10px 12px 12px 12px;
|
||||
background: rgba(6,16,28,.3);
|
||||
}
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap table { min-width: 520px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; overflow: hidden; }
|
||||
th, td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,.06); text-align: right; vertical-align: middle; font-variant-numeric: tabular-nums; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
thead th { background: rgba(0,0,0,.12); }
|
||||
tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); }
|
||||
.delta-pos { color: rgba(123,213,141,.98); font-weight: 700; }
|
||||
.delta-neg { color: rgba(255,107,125,.98); font-weight: 700; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.footer {
|
||||
margin-top: 14px; color: var(--muted); font-size: 12px;
|
||||
display: flex; gap: 10px; flex-wrap: wrap; justify-content: space-between;
|
||||
border-top: 1px dashed rgba(255,255,255,.12);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.empty, .error {
|
||||
border: 1px dashed rgba(255,255,255,.2);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
.error { color: #ffd6de; border-color: rgba(255,107,125,.4); }
|
||||
.order-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5,12,20,.6);
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 84px 16px 24px;
|
||||
z-index: 50;
|
||||
opacity: 0;
|
||||
transition: opacity .18s ease;
|
||||
}
|
||||
.modal.open { display: flex; opacity: 1; }
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
|
||||
box-shadow: 0 16px 28px rgba(0,0,0,.35);
|
||||
padding: 14px;
|
||||
max-height: calc(100vh - 80px);
|
||||
overflow: auto;
|
||||
transform: scale(.98);
|
||||
transition: transform .18s ease;
|
||||
}
|
||||
.modal.open .modal-card { transform: scale(1); }
|
||||
.modal-card h3 { margin: 0 0 10px; }
|
||||
.detail-header { display: grid; gap: 8px; }
|
||||
.detail-title { font-size: 20px; font-weight: 700; letter-spacing: .4px; }
|
||||
.detail-price { color: var(--accent); font-weight: 800; }
|
||||
.detail-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,.2);
|
||||
}
|
||||
.detail-media img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.modal-card { position: relative; }
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,.35);
|
||||
background: rgba(8,16,28,.85);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,.35);
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.form-grid label { display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.form-grid input, .form-grid textarea {
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.form-actions { margin-top: 12px; display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 12px; border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text); cursor: pointer;
|
||||
}
|
||||
.btn.accent { background: var(--accent); color: #071320; border-color: transparent; font-weight: 700; }
|
||||
.img-modal .modal-card { max-width: 720px; padding: 10px; }
|
||||
.img-modal img { width: 100%; height: auto; border-radius: 12px; display: block; }
|
||||
.order-modal { z-index: 60; }
|
||||
@media (max-width: 700px) {
|
||||
.top { align-items: flex-start; }
|
||||
.brand { width: 100%; }
|
||||
.controls { width: 100%; }
|
||||
.search { min-width: 100%; }
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.card-media { aspect-ratio: 4 / 3; }
|
||||
.order-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<div class="top">
|
||||
<div class="brand">
|
||||
<img src="logo.png" alt="Hellas 1899 Logo" />
|
||||
<div>
|
||||
<h1>Shop</h1>
|
||||
<div class="meta" id="meta">Stand: –</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="search" role="search">
|
||||
<span aria-hidden="true">🔎</span>
|
||||
<input id="q" type="search" placeholder="Artikel suchen… (z.B. Badehose, Hoodie, Kappe)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="wrap">
|
||||
<div id="stats" class="stats" aria-live="polite"></div>
|
||||
<div id="grid" class="grid" aria-live="polite"></div>
|
||||
<div class="footer">
|
||||
<div>Tip: Auf einen Artikel klicken, um die Größen‑Tabelle zu öffnen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="orderModal" class="modal order-modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-card">
|
||||
<button class="close-x" id="orderCloseX" aria-label="Schließen">×</button>
|
||||
<h3>Bestellung</h3>
|
||||
<form id="orderForm">
|
||||
<div class="form-grid">
|
||||
<label>Artikel
|
||||
<input id="fArtikel" name="artikel" readonly />
|
||||
</label>
|
||||
<label>Größe
|
||||
<input id="fGroesse" name="groesse" readonly />
|
||||
</label>
|
||||
<label>Menge
|
||||
<input id="fMenge" name="menge" type="number" min="1" value="1" required />
|
||||
</label>
|
||||
<label>Name
|
||||
<input id="fName" name="name" required />
|
||||
</label>
|
||||
<label>Handy
|
||||
<input id="fHandy" name="handy" required />
|
||||
</label>
|
||||
<label>Mannschaft
|
||||
<input id="fMannschaft" name="mannschaft" placeholder="z. B. U12" required />
|
||||
</label>
|
||||
<label style="grid-column: 1 / -1;">Notiz
|
||||
<textarea id="fNotiz" name="notiz" rows="3"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" id="orderCancel">Abbrechen</button>
|
||||
<button type="submit" class="btn accent">Bestellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="imgModal" class="modal img-modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-card">
|
||||
<img id="imgPreview" src="" alt="Artikelbild" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detailModal" class="modal" role="dialog" aria-modal="true">
|
||||
<div class="modal-card">
|
||||
<button class="close-x" id="detailCloseX" aria-label="Schließen">×</button>
|
||||
<div class="detail-header">
|
||||
<div id="detailMedia" class="detail-media"></div>
|
||||
<div class="detail-title" id="detailTitle"></div>
|
||||
<div class="detail-price" id="detailPrice"></div>
|
||||
<div class="card-sub" id="detailSizes"></div>
|
||||
</div>
|
||||
<div class="size-list" id="detailSizeList"></div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" id="detailClose">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Proxy der WaWi‑App (kein API‑Key im Browser nötig).
|
||||
const API_URL = "/wawi/proxy/bestand";
|
||||
let DATA = [];
|
||||
const DATA_MAP = new Map();
|
||||
|
||||
function fmt(v) {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) return "–";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function badge(label, value, cls="") {
|
||||
if (value === null || value === undefined) return "";
|
||||
return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`;
|
||||
}
|
||||
|
||||
function deltaClass(d) {
|
||||
if (d === null || d === undefined) return "";
|
||||
if (d > 0) return "delta-pos";
|
||||
if (d < 0) return "delta-neg";
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasDiff(item) {
|
||||
const t = item.totals || {};
|
||||
return (t.abweichung ?? 0) !== 0 || (t.fehlbestand ?? 0) !== 0;
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
const grid = document.getElementById("grid");
|
||||
const stats = document.getElementById("stats");
|
||||
|
||||
const totals = items.reduce((acc, item) => {
|
||||
const t = item.totals || {};
|
||||
acc.count += 1;
|
||||
acc.soll += t.soll ?? 0;
|
||||
return acc;
|
||||
}, { count: 0, soll: 0 });
|
||||
|
||||
stats.innerHTML = `
|
||||
<div class="stat"><div class="label">Artikel</div><div class="value">${fmt(totals.count)}</div></div>
|
||||
<div class="stat"><div class="label">Bestand gesamt</div><div class="value">${fmt(totals.soll)}</div></div>
|
||||
`;
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = items.map((item, idx) => {
|
||||
const t = item.totals || {};
|
||||
const diff = t.abweichung ?? 0;
|
||||
const fb = t.fehlbestand ?? null;
|
||||
|
||||
let statusCls = "ok";
|
||||
if ((diff ?? 0) !== 0) statusCls = "bad";
|
||||
else if ((fb ?? 0) !== 0) statusCls = "warn";
|
||||
|
||||
const statusText = ((diff ?? 0) === 0 && (fb ?? 0) === 0) ? "OK" : "Prüfen";
|
||||
|
||||
const price = Number(item.preis) || 0;
|
||||
const priceText = price > 0 ? `${price.toFixed(2)} €` : "Preis auf Anfrage";
|
||||
const img = (item.bild_url || "").trim();
|
||||
|
||||
return `
|
||||
<div class="card-tile">
|
||||
<div class="card-media">
|
||||
${img ? `<button class="thumb-btn" data-img="${img}" aria-label="Bild vergrößern" title="Bild vergrößern" style="width:100%;height:100%;background-image:url('${img}');background-size:cover;background-position:center;border:0;cursor:pointer;"></button>` : "Bild"}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${item.artikel}</div>
|
||||
<div class="card-price">${priceText}</div>
|
||||
<div class="card-sub">${item.rows.length} Größen</div>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="order-btn detail-btn" data-artikel="${item.artikel}" data-preis="${priceText}" data-img="${img}">Bestellen / Details</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const q = (document.getElementById("q").value || "").trim().toLowerCase();
|
||||
let items = DATA.slice();
|
||||
if (q) {
|
||||
items = items.filter(it => (it.artikel || "").toLowerCase().includes(q));
|
||||
}
|
||||
render(items);
|
||||
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const res = await fetch(API_URL);
|
||||
if (!res.ok) {
|
||||
const grid = document.getElementById("grid");
|
||||
grid.innerHTML = `<div class="error">API Fehler (${res.status}). Bitte API‑Key prüfen.</div>`;
|
||||
return;
|
||||
}
|
||||
DATA = await res.json();
|
||||
DATA_MAP.clear();
|
||||
DATA.forEach(item => DATA_MAP.set(item.artikel, item));
|
||||
document.getElementById("meta").textContent = `Stand: ${new Date().toLocaleString("de-DE")}`;
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
document.getElementById("q").addEventListener("input", applyFilters);
|
||||
|
||||
const modal = document.getElementById("orderModal");
|
||||
const form = document.getElementById("orderForm");
|
||||
const setField = (id, v) => document.getElementById(id).value = v || "";
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const imgBtn = e.target.closest(".thumb-btn");
|
||||
if (imgBtn) {
|
||||
document.getElementById("imgPreview").src = imgBtn.dataset.img;
|
||||
document.getElementById("imgModal").classList.add("open");
|
||||
return;
|
||||
}
|
||||
const detailBtn = e.target.closest(".detail-btn");
|
||||
if (detailBtn) {
|
||||
const artikel = detailBtn.dataset.artikel;
|
||||
const item = DATA_MAP.get(artikel);
|
||||
if (!item) return;
|
||||
const img = (detailBtn.dataset.img || "").trim();
|
||||
const media = document.getElementById("detailMedia");
|
||||
media.innerHTML = img ? `<img src="${img}" alt="${artikel}">` : "";
|
||||
document.getElementById("detailTitle").textContent = artikel;
|
||||
document.getElementById("detailPrice").textContent = detailBtn.dataset.preis || "Preis auf Anfrage";
|
||||
document.getElementById("detailSizes").textContent = `${item.rows.length} Größen`;
|
||||
const list = item.rows.map(r => `
|
||||
<div class="size-row">
|
||||
<div class="size-meta"><strong>${fmt(r.groesse)}</strong> · Bestand: ${fmt(r.gezaehlt)}</div>
|
||||
${(Number(r.gezaehlt) || 0) > 0 ? `<button class="order-btn" data-artikel="${item.artikel}" data-groesse="${fmt(r.groesse)}">Bestellen</button>` : ""}
|
||||
</div>
|
||||
`).join("");
|
||||
document.getElementById("detailSizeList").innerHTML = list;
|
||||
document.getElementById("detailModal").classList.add("open");
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest(".order-btn");
|
||||
if (!btn) return;
|
||||
setField("fArtikel", btn.dataset.artikel);
|
||||
setField("fGroesse", btn.dataset.groesse);
|
||||
setField("fMenge", 1);
|
||||
modal.classList.add("open");
|
||||
});
|
||||
document.getElementById("orderCancel").addEventListener("click", () => {
|
||||
modal.classList.remove("open");
|
||||
if (document.getElementById("detailModal").classList.contains("open")) {
|
||||
document.getElementById("detailModal").querySelector(".size-row")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
document.getElementById("orderCloseX").addEventListener("click", () => {
|
||||
modal.classList.remove("open");
|
||||
});
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) modal.classList.remove("open");
|
||||
});
|
||||
document.getElementById("imgModal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "imgModal") e.currentTarget.classList.remove("open");
|
||||
});
|
||||
document.getElementById("detailClose").addEventListener("click", () => {
|
||||
document.getElementById("detailModal").classList.remove("open");
|
||||
});
|
||||
document.getElementById("detailCloseX").addEventListener("click", () => {
|
||||
document.getElementById("detailModal").classList.remove("open");
|
||||
});
|
||||
document.getElementById("detailModal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "detailModal") e.currentTarget.classList.remove("open");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
const res = await fetch("/wawi/order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Bestellung gesendet.");
|
||||
modal.classList.remove("open");
|
||||
document.getElementById("detailModal").classList.remove("open");
|
||||
form.reset();
|
||||
setField("fMenge", 1);
|
||||
} else {
|
||||
alert("Fehler beim Senden der Bestellung.");
|
||||
}
|
||||
});
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
699
wawi/app.py
Normal file
699
wawi/app.py
Normal file
@@ -0,0 +1,699 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import secrets
|
||||
import smtplib
|
||||
from uuid import uuid4
|
||||
from email.message import EmailMessage
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "hellas.db"
|
||||
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
|
||||
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
# Optional Prefix, z. B. /wawi, wenn die App hinter einem Sub‑Pfad läuft.
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
|
||||
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
|
||||
|
||||
app = Flask(__name__, static_url_path=STATIC_URL_PATH)
|
||||
# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen).
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "change-me")
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = False
|
||||
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||
bp = Blueprint("bp", __name__)
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
if "db" not in g:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
g.db = conn
|
||||
return g.db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc: Exception | None) -> None:
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db = get_db()
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artikel TEXT NOT NULL,
|
||||
groesse TEXT NOT NULL,
|
||||
preis REAL NOT NULL DEFAULT 0,
|
||||
bild_url TEXT,
|
||||
soll INTEGER NOT NULL DEFAULT 0,
|
||||
gezaehlt INTEGER NOT NULL DEFAULT 0,
|
||||
verkaeufe INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ausbuchungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL,
|
||||
menge INTEGER NOT NULL,
|
||||
grund TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(item_id) REFERENCES items(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
handy TEXT NOT NULL,
|
||||
mannschaft TEXT NOT NULL,
|
||||
artikel TEXT NOT NULL,
|
||||
groesse TEXT NOT NULL,
|
||||
menge INTEGER NOT NULL,
|
||||
notiz TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
completed_by TEXT,
|
||||
completed_at TEXT,
|
||||
canceled INTEGER NOT NULL DEFAULT 0,
|
||||
canceled_by TEXT,
|
||||
canceled_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.commit()
|
||||
ensure_price_column(db)
|
||||
ensure_image_column(db)
|
||||
ensure_orders_columns(db)
|
||||
ensure_admin_user(db)
|
||||
|
||||
|
||||
def ensure_price_column(db: sqlite3.Connection) -> None:
|
||||
# Fügt die Preisspalte nachträglich hinzu, falls DB älter ist.
|
||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||
if any(c["name"] == "preis" for c in cols):
|
||||
return
|
||||
db.execute("ALTER TABLE items ADD COLUMN preis REAL NOT NULL DEFAULT 0")
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_image_column(db: sqlite3.Connection) -> None:
|
||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||
if any(c["name"] == "bild_url" for c in cols):
|
||||
return
|
||||
db.execute("ALTER TABLE items ADD COLUMN bild_url TEXT")
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||
cols = db.execute("PRAGMA table_info(orders)").fetchall()
|
||||
names = {c["name"] for c in cols}
|
||||
if "done" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN done INTEGER NOT NULL DEFAULT 0")
|
||||
if "completed_by" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN completed_by TEXT")
|
||||
if "completed_at" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN completed_at TEXT")
|
||||
if "canceled" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN canceled INTEGER NOT NULL DEFAULT 0")
|
||||
if "canceled_by" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT")
|
||||
if "canceled_at" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT")
|
||||
db.commit()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def ensure_db() -> None:
|
||||
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent).
|
||||
init_db()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def save_upload(file) -> str | None:
|
||||
if not file or not getattr(file, "filename", ""):
|
||||
return None
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return None
|
||||
name = secure_filename(Path(file.filename).stem) or "image"
|
||||
safe_name = f"{name}-{uuid4().hex}{ext}"
|
||||
dest = UPLOAD_DIR / safe_name
|
||||
file.save(dest)
|
||||
return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}"
|
||||
|
||||
|
||||
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||
# Legt einen Admin‑User an, wenn noch kein Benutzer existiert.
|
||||
row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
|
||||
if row and row["c"] > 0:
|
||||
return
|
||||
user = os.environ.get("APP_USER", "admin")
|
||||
password = os.environ.get("APP_PASSWORD", "admin")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user, generate_password_hash(password), now_iso()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not session.get("user"):
|
||||
return redirect(url_for("bp.login", next=request.path))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth():
|
||||
return {"logged_in": bool(session.get("user"))}
|
||||
|
||||
|
||||
def api_key_required(fn):
|
||||
# Schützt API‑Endpoints per X-API-Key oder ?key= Parameter.
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
expected = os.environ.get("APP_API_KEY", "")
|
||||
if not expected:
|
||||
return jsonify({"error": "API key not configured"}), 500
|
||||
provided = request.headers.get("X-API-Key") or request.args.get("key") or ""
|
||||
if provided != expected:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
q = (request.args.get("q") or "").strip()
|
||||
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
|
||||
direction = (request.args.get("dir") or "desc").strip().lower()
|
||||
|
||||
allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"}
|
||||
if sort not in allowed:
|
||||
sort = "gezaehlt"
|
||||
if direction not in {"asc", "desc"}:
|
||||
direction = "desc"
|
||||
|
||||
params: list[Any] = []
|
||||
where = ""
|
||||
if q:
|
||||
where = "WHERE artikel LIKE ? OR groesse LIKE ?"
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like])
|
||||
|
||||
sql = f"""
|
||||
SELECT *
|
||||
FROM items
|
||||
{where}
|
||||
ORDER BY {sort} {direction}, artikel ASC, groesse ASC
|
||||
"""
|
||||
rows = get_db().execute(sql, params).fetchall()
|
||||
|
||||
grouped = {}
|
||||
for r in rows:
|
||||
key = r["artikel"] or ""
|
||||
grouped.setdefault(key, []).append(r)
|
||||
groups = [{"artikel": k, "rows": v} for k, v in grouped.items()]
|
||||
|
||||
total = get_db().execute("SELECT COUNT(*) AS c FROM items").fetchone()["c"]
|
||||
total_bestand = get_db().execute("SELECT COALESCE(SUM(gezaehlt), 0) AS s FROM items").fetchone()["s"]
|
||||
open_orders = get_db().execute("SELECT COUNT(*) AS c FROM orders WHERE done = 0 AND canceled = 0").fetchone()["c"]
|
||||
return render_template(
|
||||
"index.html",
|
||||
groups=groups,
|
||||
q=q,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
total=total,
|
||||
total_bestand=total_bestand,
|
||||
open_orders=open_orders,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def new_item():
|
||||
if request.method == "POST":
|
||||
artikel = (request.form.get("artikel") or "").strip()
|
||||
groesse = (request.form.get("groesse") or "").strip()
|
||||
preis = float(request.form.get("preis") or 0)
|
||||
bild_url = (request.form.get("bild_url") or "").strip()
|
||||
uploaded = save_upload(request.files.get("bild_file"))
|
||||
if uploaded:
|
||||
bild_url = uploaded
|
||||
soll = int(request.form.get("soll") or 0)
|
||||
gezaehlt = int(request.form.get("gezaehlt") or 0)
|
||||
verkaeufe = int(request.form.get("verkaeufe") or 0)
|
||||
|
||||
if artikel and groesse:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
|
||||
)
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
return render_template("edit.html", item=None)
|
||||
|
||||
|
||||
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_item(item_id: int):
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
artikel = (request.form.get("artikel") or "").strip()
|
||||
groesse = (request.form.get("groesse") or "").strip()
|
||||
preis = float(request.form.get("preis") or 0)
|
||||
bild_url = (request.form.get("bild_url") or "").strip()
|
||||
uploaded = save_upload(request.files.get("bild_file"))
|
||||
if uploaded:
|
||||
bild_url = uploaded
|
||||
soll = int(request.form.get("soll") or 0)
|
||||
gezaehlt = int(request.form.get("gezaehlt") or 0)
|
||||
verkaeufe = int(request.form.get("verkaeufe") or 0)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), item_id),
|
||||
)
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
return render_template("edit.html", item=item)
|
||||
|
||||
|
||||
@bp.route("/delete/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_item(item_id: int):
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
|
||||
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def ausbuchen(item_id: int):
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
menge = int(request.form.get("menge") or 0)
|
||||
grund = (request.form.get("grund") or "").strip() or None
|
||||
if menge > 0:
|
||||
neue_gezaehlt = max(int(item["gezaehlt"]) - menge, 0)
|
||||
neue_verkaeufe = int(item["verkaeufe"]) + menge
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET gezaehlt = ?, verkaeufe = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(neue_gezaehlt, neue_verkaeufe, now_iso(), item_id),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ausbuchungen (item_id, menge, grund, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(item_id, menge, grund, now_iso()),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
return render_template("ausbuchen.html", item=item)
|
||||
|
||||
|
||||
@bp.route("/verkauf/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
def verkauf(item_id: int):
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
menge = 1
|
||||
neue_gezaehlt = max(int(item["gezaehlt"]) - menge, 0)
|
||||
neue_verkaeufe = int(item["verkaeufe"]) + menge
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET gezaehlt = ?, verkaeufe = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(neue_gezaehlt, neue_verkaeufe, now_iso(), item_id),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ausbuchungen (item_id, menge, grund, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(item_id, menge, "Verkauf", now_iso()),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if request.method == "POST":
|
||||
user = (request.form.get("user") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
row = get_db().execute(
|
||||
"SELECT id, username, password_hash FROM users WHERE username = ?",
|
||||
(user,),
|
||||
).fetchone()
|
||||
if row and check_password_hash(row["password_hash"], password):
|
||||
session["user"] = user
|
||||
nxt = request.args.get("next") or url_for("bp.index")
|
||||
return redirect(nxt)
|
||||
return render_template("login.html", error=True)
|
||||
return render_template("login.html", error=False)
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("bp.login"))
|
||||
|
||||
|
||||
@bp.route("/users", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def users():
|
||||
db = get_db()
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
if not username or not password:
|
||||
error = "Benutzer und Passwort sind erforderlich."
|
||||
else:
|
||||
try:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO users (username, password_hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(username, generate_password_hash(password), now_iso()),
|
||||
)
|
||||
db.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
error = "Benutzername existiert bereits."
|
||||
|
||||
rows = db.execute("SELECT id, username, created_at FROM users ORDER BY username").fetchall()
|
||||
return render_template("users.html", rows=rows, error=error)
|
||||
|
||||
|
||||
@bp.route("/users/delete/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user(user_id: int):
|
||||
db = get_db()
|
||||
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||
if count <= 1:
|
||||
return redirect(url_for("bp.users"))
|
||||
db.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.users"))
|
||||
|
||||
|
||||
@bp.route("/api/bestand", methods=["GET"])
|
||||
@api_key_required
|
||||
def api_bestand():
|
||||
return jsonify(build_bestand())
|
||||
|
||||
|
||||
@bp.route("/proxy/bestand", methods=["GET"])
|
||||
def proxy_bestand():
|
||||
# Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige).
|
||||
return jsonify(build_bestand())
|
||||
|
||||
|
||||
def build_bestand() -> list[dict]:
|
||||
# Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht.
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
|
||||
FROM items
|
||||
ORDER BY artikel, groesse
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
data: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
artikel = (r["artikel"] or "").strip()
|
||||
if not artikel:
|
||||
continue
|
||||
item = data.setdefault(
|
||||
artikel,
|
||||
{"artikel": artikel, "preis": 0, "bild_url": "", "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
|
||||
)
|
||||
if not item["preis"]:
|
||||
item["preis"] = float(r["preis"] or 0)
|
||||
if not item["bild_url"]:
|
||||
item["bild_url"] = (r["bild_url"] or "").strip()
|
||||
soll = int(r["soll"] or 0)
|
||||
gezaehlt = int(r["gezaehlt"] or 0)
|
||||
verkaeufe = int(r["verkaeufe"] or 0)
|
||||
abw = gezaehlt - soll
|
||||
fehl = max(soll - gezaehlt, 0)
|
||||
item["rows"].append(
|
||||
{
|
||||
"groesse": r["groesse"],
|
||||
"soll": soll,
|
||||
"gezaehlt": gezaehlt,
|
||||
"abweichung": abw,
|
||||
"fehlbestand": fehl if fehl > 0 else None,
|
||||
"verkaeufe": verkaeufe,
|
||||
}
|
||||
)
|
||||
t = item["totals"]
|
||||
t["soll"] += soll
|
||||
t["gezaehlt"] += gezaehlt
|
||||
t["abweichung"] += abw
|
||||
t["fehlbestand"] += fehl
|
||||
t["verkaeufe"] += verkaeufe
|
||||
|
||||
result: list[dict] = []
|
||||
for artikel, item in data.items():
|
||||
t = item["totals"]
|
||||
if t["fehlbestand"] == 0:
|
||||
t["fehlbestand"] = None
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@bp.route("/order", methods=["POST"])
|
||||
def order():
|
||||
data = request.get_json(silent=True) or request.form
|
||||
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
|
||||
if any(not (data.get(k) or "").strip() for k in required):
|
||||
return jsonify({"error": "Pflichtfelder fehlen."}), 400
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL)
|
||||
""",
|
||||
(
|
||||
data.get("name"),
|
||||
data.get("handy"),
|
||||
data.get("mannschaft"),
|
||||
data.get("artikel"),
|
||||
data.get("groesse"),
|
||||
int(data.get("menge") or 0),
|
||||
data.get("notiz"),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
|
||||
smtp_host = os.environ.get("SMTP_HOST")
|
||||
smtp_user = os.environ.get("SMTP_USER")
|
||||
smtp_pass = os.environ.get("SMTP_PASS")
|
||||
smtp_port = int(os.environ.get("SMTP_PORT", "587"))
|
||||
smtp_from = os.environ.get("SMTP_FROM", smtp_user or "no-reply@localhost")
|
||||
|
||||
if not smtp_host or not smtp_user or not smtp_pass:
|
||||
return jsonify({"error": "Mailversand nicht konfiguriert."}), 500
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Neue Bestellung (Hellas Bestand)"
|
||||
msg["From"] = smtp_from
|
||||
recipients = [a.strip() for a in to_addr.split(",") if a.strip()]
|
||||
msg["To"] = ", ".join(recipients)
|
||||
body = (
|
||||
"Neue Bestellung:\n"
|
||||
f"Name: {data.get('name')}\n"
|
||||
f"Handy: {data.get('handy')}\n"
|
||||
f"Mannschaft: {data.get('mannschaft')}\n"
|
||||
f"Artikel: {data.get('artikel')}\n"
|
||||
f"Größe: {data.get('groesse')}\n"
|
||||
f"Menge: {data.get('menge')}\n"
|
||||
f"Notiz: {data.get('notiz') or '-'}\n"
|
||||
"WaWi: https://hellas.welker.me/wawi\n"
|
||||
)
|
||||
msg.set_content(body)
|
||||
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(msg, to_addrs=recipients)
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/orders")
|
||||
@login_required
|
||||
def orders():
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
|
||||
FROM orders
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
"""
|
||||
).fetchall()
|
||||
return render_template("orders.html", rows=rows)
|
||||
|
||||
|
||||
@bp.route("/orders/complete/<int:order_id>", methods=["POST"])
|
||||
@login_required
|
||||
def complete_order(order_id: int):
|
||||
user = session.get("user") or "unknown"
|
||||
db = get_db()
|
||||
order = db.execute(
|
||||
"""
|
||||
SELECT id, artikel, groesse, menge, done, canceled
|
||||
FROM orders
|
||||
WHERE id = ?
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
if order is None:
|
||||
return redirect(url_for("bp.orders"))
|
||||
if not order["done"] and not order["canceled"]:
|
||||
menge = int(order["menge"] or 0)
|
||||
item = db.execute(
|
||||
"""
|
||||
SELECT gezaehlt
|
||||
FROM items
|
||||
WHERE artikel = ? AND groesse = ?
|
||||
""",
|
||||
(order["artikel"], order["groesse"]),
|
||||
).fetchone()
|
||||
current = int(item["gezaehlt"] or 0) if item else 0
|
||||
if current < menge:
|
||||
flash("Bestand reicht nicht aus, um die Bestellung abzuschließen.")
|
||||
return redirect(url_for("bp.orders"))
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END,
|
||||
verkaeufe = verkaeufe + ?,
|
||||
updated_at = ?
|
||||
WHERE artikel = ? AND groesse = ?
|
||||
""",
|
||||
(menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET done = 1, completed_by = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user, now_iso(), order_id),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("bp.orders"))
|
||||
|
||||
|
||||
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
|
||||
@login_required
|
||||
def cancel_order(order_id: int):
|
||||
user = session.get("user") or "unknown"
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET canceled = 1, canceled_by = ?, canceled_at = ?
|
||||
WHERE id = ? AND done = 0
|
||||
""",
|
||||
(user, now_iso(), order_id),
|
||||
)
|
||||
db.commit()
|
||||
return redirect(url_for("bp.orders"))
|
||||
|
||||
|
||||
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def reset_user_password(user_id: int):
|
||||
db = get_db()
|
||||
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if user is None:
|
||||
return redirect(url_for("bp.users"))
|
||||
new_password = secrets.token_urlsafe(8).replace("-", "").replace("_", "")[:12]
|
||||
db.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(generate_password_hash(new_password), user_id),
|
||||
)
|
||||
db.commit()
|
||||
flash(f"Neues Passwort für {user['username']}: {new_password}")
|
||||
return redirect(url_for("bp.users"))
|
||||
|
||||
|
||||
app.register_blueprint(bp, url_prefix=URL_PREFIX or "")
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
88
wawi/import_from_html.py
Normal file
88
wawi/import_from_html.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = BASE_DIR / "hellas.db"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def ensure_db(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artikel TEXT NOT NULL,
|
||||
groesse TEXT NOT NULL,
|
||||
preis REAL NOT NULL DEFAULT 0,
|
||||
soll INTEGER NOT NULL DEFAULT 0,
|
||||
gezaehlt INTEGER NOT NULL DEFAULT 0,
|
||||
verkaeufe INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def extract_data(html_text: str) -> list[dict]:
|
||||
# Liest den const DATA = [...] Block aus der alten HTML‑Datei.
|
||||
match = re.search(r"const\s+DATA\s*=\s*(\[[\s\S]*?\]);", html_text)
|
||||
if not match:
|
||||
raise ValueError("DATA-Block nicht gefunden.")
|
||||
raw = match.group(1)
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Import aus hellas_bestand.html in SQLite.")
|
||||
parser.add_argument("html", type=Path, help="Pfad zur hellas_bestand.html")
|
||||
parser.add_argument("--truncate", action="store_true", help="Vor Import alle Items löschen.")
|
||||
args = parser.parse_args()
|
||||
|
||||
html_text = args.html.read_text(encoding="utf-8")
|
||||
data = extract_data(html_text)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
ensure_db(conn)
|
||||
|
||||
if args.truncate:
|
||||
conn.execute("DELETE FROM items")
|
||||
conn.commit()
|
||||
|
||||
now = now_iso()
|
||||
insert_sql = """
|
||||
INSERT INTO items (artikel, groesse, soll, gezaehlt, verkaeufe, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
rows_added = 0
|
||||
for item in data:
|
||||
artikel = (item.get("artikel") or "").strip()
|
||||
for row in item.get("rows") or []:
|
||||
groesse = (row.get("groesse") or "").strip()
|
||||
soll = int(row.get("soll") or 0)
|
||||
gezaehlt = int(row.get("gezaehlt") or 0)
|
||||
verkaeufe = int(row.get("verkaeufe") or 0)
|
||||
if not artikel or not groesse:
|
||||
continue
|
||||
conn.execute(insert_sql, (artikel, groesse, soll, gezaehlt, verkaeufe, now, now))
|
||||
rows_added += 1
|
||||
|
||||
conn.commit()
|
||||
print(f"Import fertig. Zeilen eingefügt: {rows_added}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
BIN
wawi/static/logo.png
Normal file
BIN
wawi/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
204
wawi/static/style.css
Normal file
204
wawi/static/style.css
Normal file
@@ -0,0 +1,204 @@
|
||||
:root {
|
||||
--bg: #0a2036;
|
||||
--card: #0f2236;
|
||||
--text: #f1f4f8;
|
||||
--muted: #9fb1c8;
|
||||
--line: rgba(255,255,255,.12);
|
||||
--accent: #f3d52a;
|
||||
--teal: #2a8a8a;
|
||||
--ok: #7bd58d;
|
||||
--bad: #ff6b7d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif;
|
||||
background:
|
||||
radial-gradient(900px 520px at 50% -10%, rgba(255,255,255,.10), transparent 60%),
|
||||
radial-gradient(900px 600px at 50% 20%, rgba(16,44,70,.85), transparent 65%),
|
||||
radial-gradient(1200px 700px at 50% 80%, rgba(7,26,44,.95), transparent 70%),
|
||||
linear-gradient(180deg, #0a2036 0%, #0b243b 45%, #092135 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
header {
|
||||
background: rgba(9,24,40,.9);
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: 0 8px 22px rgba(0,0,0,.35);
|
||||
}
|
||||
header::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--teal), rgba(42,138,138,.0));
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand img { height: 42px; width: auto; display: block; filter: drop-shadow(0 2px 6px rgba(0,0,0,.4)); }
|
||||
h1 {
|
||||
font-family: "Arial Narrow", "Helvetica Neue Condensed", Impact, "Franklin Gothic Medium", "Arial Black", sans-serif;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 12px; }
|
||||
.nav { display: flex; gap: 10px; align-items: center; }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.06);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { border-color: rgba(255,255,255,.2); }
|
||||
.btn.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn.icon span { line-height: 1; }
|
||||
.btn.icon:hover { transform: translateY(-1px); }
|
||||
.btn.icon.danger { background: rgba(255,107,125,.2); }
|
||||
.btn.icon.accent { background: var(--accent); color: #071320; border-color: transparent; }
|
||||
.actions .btn.icon { margin-right: 6px; }
|
||||
.nav .btn.icon { width: 38px; height: 38px; font-size: 18px; }
|
||||
.btn-accent { background: var(--accent); color: #071320; border-color: transparent; font-weight: 700; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.small { padding: 6px 10px; font-size: 12px; }
|
||||
.btn.danger { background: rgba(255,107,125,.2); border-color: rgba(255,107,125,.4); color: #ffe6ea; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 14px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search input, .search select {
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.stat {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
|
||||
box-shadow: 0 16px 28px rgba(0,0,0,.35);
|
||||
}
|
||||
.stat-link { text-decoration: none; color: inherit; }
|
||||
.stat-link:hover { border-color: rgba(255,255,255,.25); }
|
||||
.stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; }
|
||||
.stat .value { font-size: 18px; font-weight: 700; letter-spacing: .3px; color: var(--accent); }
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
|
||||
box-shadow: 0 16px 28px rgba(0,0,0,.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: right; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
thead th { background: rgba(0,0,0,.18); color: var(--muted); font-weight: 600; }
|
||||
tbody tr:nth-child(odd) { background: rgba(255,255,255,.03); }
|
||||
.group-row td {
|
||||
background: rgba(255,255,255,.06);
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.group-row td strong { font-size: 14px; }
|
||||
.order-history td {
|
||||
background: rgba(255,255,255,.02);
|
||||
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||
}
|
||||
.inline-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: .6px;
|
||||
text-transform: uppercase;
|
||||
list-style: none;
|
||||
}
|
||||
.inline-details summary::-webkit-details-marker { display: none; }
|
||||
.history-grid {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 6px 16px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.actions { text-align: right; white-space: nowrap; }
|
||||
.actions form { display: inline; }
|
||||
.pos { color: var(--ok); font-weight: 700; }
|
||||
.neg { color: var(--bad); font-weight: 700; }
|
||||
.empty { text-align: center; padding: 18px; color: var(--muted); }
|
||||
|
||||
.form-card { padding: 16px; }
|
||||
.form-card h2 { margin: 0 0 10px; font-size: 18px; }
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||
input[type="text"], input[type="number"] {
|
||||
background: rgba(255,255,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.form-actions { margin-top: 14px; display: flex; gap: 10px; }
|
||||
.note { color: var(--muted); margin-bottom: 10px; }
|
||||
|
||||
.to-top {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,.08);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,.35);
|
||||
}
|
||||
.to-top:hover { transform: translateY(-2px); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.btn.small { padding: 6px 8px; }
|
||||
}
|
||||
23
wawi/templates/ausbuchen.html
Normal file
23
wawi/templates/ausbuchen.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="card form-card">
|
||||
<h2>Ausbuchen: {{ item.artikel }} ({{ item.groesse }})</h2>
|
||||
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
|
||||
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Menge
|
||||
<input type="number" name="menge" min="1" required />
|
||||
</label>
|
||||
<label>
|
||||
Grund (optional)
|
||||
<input type="text" name="grund" placeholder="z. B. Verkauf, Defekt, Muster" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-accent" type="submit">Ausbuchen</button>
|
||||
<a class="btn ghost" href="{{ url_for('bp.index') }}">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
37
wawi/templates/base.html
Normal file
37
wawi/templates/base.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>{{ title or "Hellas Artikelverwaltung" }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap top">
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Hellas 1899 Logo" />
|
||||
<div>
|
||||
<h1>Hellas 1899 · Artikelverwaltung</h1>
|
||||
<div class="meta">Bestand & Ausbuchungen</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
{% if logged_in %}
|
||||
<a class="btn icon" href="{{ url_for('bp.index') }}" title="Übersicht" aria-label="Übersicht"><span>⌂</span></a>
|
||||
<a class="btn icon accent" href="{{ url_for('bp.new_item') }}" title="Neuer Artikel" aria-label="Neuer Artikel"><span>+</span></a>
|
||||
<a class="btn icon" href="{{ url_for('bp.users') }}" title="Benutzer" aria-label="Benutzer"><span>☺︎</span></a>
|
||||
<a class="btn icon" href="{{ url_for('bp.orders') }}" title="Bestellungen" aria-label="Bestellungen"><span>☑︎</span></a>
|
||||
<a class="btn icon ghost" href="{{ url_for('bp.logout') }}" title="Logout" aria-label="Logout"><span>⏻</span></a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
<a href="#" class="to-top" title="Nach oben" aria-label="Nach oben">↑</a>
|
||||
</body>
|
||||
</html>
|
||||
46
wawi/templates/edit.html
Normal file
46
wawi/templates/edit.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="card form-card">
|
||||
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Artikel
|
||||
<input type="text" name="artikel" required value="{{ item.artikel if item else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Größe
|
||||
<input type="text" name="groesse" required value="{{ item.groesse if item else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Preis (EUR)
|
||||
<input type="number" name="preis" step="0.01" min="0" value="{{ item.preis if item else 0 }}" />
|
||||
</label>
|
||||
<label>
|
||||
Bild‑URL (optional)
|
||||
<input type="text" name="bild_url" placeholder="/images/artikel.jpg" value="{{ item.bild_url if item else '' }}" />
|
||||
</label>
|
||||
<label>
|
||||
Bild hochladen (optional)
|
||||
<input type="file" name="bild_file" accept="image/*" />
|
||||
</label>
|
||||
<label>
|
||||
Soll
|
||||
<input type="number" name="soll" min="0" value="{{ item.soll if item else 0 }}" />
|
||||
</label>
|
||||
<label>
|
||||
Bestand
|
||||
<input type="number" name="gezaehlt" min="0" value="{{ item.gezaehlt if item else 0 }}" />
|
||||
</label>
|
||||
<label>
|
||||
Verkäufe
|
||||
<input type="number" name="verkaeufe" min="0" value="{{ item.verkaeufe if item else 0 }}" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-accent" type="submit">Speichern</button>
|
||||
<a class="btn ghost" href="{{ url_for('bp.index') }}">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
88
wawi/templates/index.html
Normal file
88
wawi/templates/index.html
Normal file
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="toolbar">
|
||||
<form class="search" method="get" action="{{ url_for('bp.index') }}">
|
||||
<input type="search" name="q" placeholder="Artikel oder Größe suchen…" value="{{ q }}" />
|
||||
<select name="sort">
|
||||
<option value="gezaehlt" {% if sort == "gezaehlt" %}selected{% endif %}>Bestand</option>
|
||||
<option value="soll" {% if sort == "soll" %}selected{% endif %}>Soll</option>
|
||||
<option value="artikel" {% if sort == "artikel" %}selected{% endif %}>Artikel</option>
|
||||
<option value="groesse" {% if sort == "groesse" %}selected{% endif %}>Größe</option>
|
||||
<option value="verkaeufe" {% if sort == "verkaeufe" %}selected{% endif %}>Verkäufe</option>
|
||||
</select>
|
||||
<select name="dir">
|
||||
<option value="desc" {% if direction == "desc" %}selected{% endif %}>↓</option>
|
||||
<option value="asc" {% if direction == "asc" %}selected{% endif %}>↑</option>
|
||||
</select>
|
||||
<button class="btn" type="submit">Filtern</button>
|
||||
<a class="btn ghost" href="{{ url_for('bp.index') }}">Zurücksetzen</a>
|
||||
</form>
|
||||
<div class="stat">
|
||||
<div class="label">Artikel gesamt</div>
|
||||
<div class="value">{{ total }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Bestand gesamt</div>
|
||||
<div class="value">{{ total_bestand }}</div>
|
||||
</div>
|
||||
<a class="stat stat-link" href="{{ url_for('bp.orders') }}" title="Offene Bestellungen anzeigen">
|
||||
<div class="label">Offene Bestellungen</div>
|
||||
<div class="value">{{ open_orders }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artikel</th>
|
||||
<th>Größe</th>
|
||||
<th>Preis</th>
|
||||
<th>Soll</th>
|
||||
<th>Bestand</th>
|
||||
<th>Abweichung</th>
|
||||
<th>Fehlbestand</th>
|
||||
<th>Verkäufe</th>
|
||||
<th class="actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if groups %}
|
||||
{% for g in groups %}
|
||||
<tr class="group-row">
|
||||
<td colspan="9"><strong>{{ g.artikel }}</strong></td>
|
||||
</tr>
|
||||
{% for r in g.rows %}
|
||||
{% set diff = (r.gezaehlt or 0) - (r.soll or 0) %}
|
||||
{% set fehl = (r.soll or 0) - (r.gezaehlt or 0) %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{{ r.groesse }}</td>
|
||||
<td>{{ "%.2f"|format(r.preis or 0) }} €</td>
|
||||
<td>{{ r.soll }}</td>
|
||||
<td>{{ r.gezaehlt }}</td>
|
||||
<td class="{{ 'pos' if diff > 0 else 'neg' if diff < 0 else '' }}">{{ diff }}</td>
|
||||
<td>{{ fehl if fehl > 0 else "–" }}</td>
|
||||
<td>{{ r.verkaeufe }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn icon" href="{{ url_for('bp.edit_item', item_id=r.id) }}" title="Bearbeiten" aria-label="Bearbeiten"><span>✎</span></a>
|
||||
<form method="post" action="{{ url_for('bp.verkauf', item_id=r.id) }}" onsubmit="return confirm('Wirklich 1 Stück als verkauft buchen?');">
|
||||
<button class="btn icon" type="submit" title="Verkauf +1" aria-label="Verkauf +1"><span>🛒</span></button>
|
||||
</form>
|
||||
<a class="btn icon" href="{{ url_for('bp.ausbuchen', item_id=r.id) }}" title="Ausbuchen" aria-label="Ausbuchen"><span>⇩</span></a>
|
||||
<form method="post" action="{{ url_for('bp.delete_item', item_id=r.id) }}" onsubmit="return confirm('Wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.');">
|
||||
<button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="9" class="empty">Keine Treffer.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
wawi/templates/login.html
Normal file
24
wawi/templates/login.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="card form-card">
|
||||
<h2>Login</h2>
|
||||
{% if error %}
|
||||
<div class="note">Benutzername oder Passwort ist falsch.</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzer
|
||||
<input type="text" name="user" required />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-accent" type="submit">Anmelden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
79
wawi/templates/orders.html
Normal file
79
wawi/templates/orders.html
Normal file
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="note">
|
||||
{% for m in messages %}
|
||||
<div>{{ m }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Name</th>
|
||||
<th>Handy</th>
|
||||
<th>Mannschaft</th>
|
||||
<th>Artikel</th>
|
||||
<th>Größe</th>
|
||||
<th>Menge</th>
|
||||
<th>Notiz</th>
|
||||
<th>Status</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in rows %}
|
||||
<tr>
|
||||
<td>{{ o.created_at }}</td>
|
||||
<td>{{ o.name }}</td>
|
||||
<td>{{ o.handy }}</td>
|
||||
<td>{{ o.mannschaft }}</td>
|
||||
<td>{{ o.artikel }}</td>
|
||||
<td>{{ o.groesse }}</td>
|
||||
<td>{{ o.menge }}</td>
|
||||
<td>{{ o.notiz or "–" }}</td>
|
||||
<td>
|
||||
{% if o.canceled %}Storniert
|
||||
{% elif o.done %}Erledigt
|
||||
{% else %}Offen
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if not o.done and not o.canceled %}
|
||||
<form method="post" action="{{ url_for('bp.complete_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung als erledigt markieren?');">
|
||||
<button class="btn small" type="submit">Erledigt</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
|
||||
<button class="btn small danger" type="submit">Stornieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="order-history">
|
||||
<td colspan="10">
|
||||
<details class="inline-details">
|
||||
<summary>Historie</summary>
|
||||
<div class="history-grid">
|
||||
<div><strong>Abgeschlossen von:</strong> {{ o.completed_by or "–" }}</div>
|
||||
<div><strong>Abgeschlossen am:</strong> {{ o.completed_at or "–" }}</div>
|
||||
<div><strong>Storniert von:</strong> {{ o.canceled_by or "–" }}</div>
|
||||
<div><strong>Storniert am:</strong> {{ o.canceled_at or "–" }}</div>
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="10" class="empty">Keine Bestellungen.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
wawi/templates/users.html
Normal file
65
wawi/templates/users.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="card form-card">
|
||||
<h2>Benutzer verwalten</h2>
|
||||
{% if error %}
|
||||
<div class="note">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="note">
|
||||
{% for m in messages %}
|
||||
<div>{{ m }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" name="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Passwort
|
||||
<input type="password" name="password" required />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-accent" type="submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top: 14px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Erstellt</th>
|
||||
<th class="actions">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in rows %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="{{ url_for('bp.reset_user_password', user_id=u.id) }}" onsubmit="return confirm('Passwort für diesen Benutzer wirklich zurücksetzen?');">
|
||||
<button class="btn small" type="submit">Passwort neu</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('bp.delete_user', user_id=u.id) }}" onsubmit="return confirm('Benutzer wirklich löschen?');">
|
||||
<button class="btn small danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="empty">Keine Benutzer.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user