Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c523085405 | |||
| bd4664f23b | |||
| 5c8c4a947e | |||
| ef36d63aa7 | |||
| c23f08fdd9 | |||
| 9f3951e6ef | |||
| 8d8dd115c5 | |||
| 7ffad354fa | |||
| 1d2d8230f1 | |||
| 18a0c34036 | |||
| 8b12d17935 | |||
| 1a12dcd1d0 | |||
| 9f7bc8086c | |||
| 833287222d | |||
| e2cdaf059a | |||
| b2e8192a2d | |||
| 3a15183094 | |||
| 08d668c984 | |||
| 24e9d83a52 | |||
| 4d7e352220 | |||
| abc4403373 | |||
| 57930c374a | |||
| 9318f0b25c | |||
| 02a9df95b5 | |||
| 9ad4038d6b | |||
| c68fcc56f9 | |||
| 90eccad3f7 | |||
| 2440963afc | |||
| 08705ae44e | |||
| a57c5fa837 | |||
| 7a0be8aa61 | |||
| e2383aefb5 | |||
| 6601cd3e88 | |||
| 4a1b237482 | |||
| 629ca0ea88 | |||
| 6ff60dfb09 | |||
| db3f8bd13f | |||
| face097b39 |
@@ -1,3 +1,4 @@
|
|||||||
.venv/
|
.venv/
|
||||||
log/
|
log/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.db
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
# copy every content from the local file to the image
|
# copy every content from the local file to the image
|
||||||
COPY ./app.py /app
|
COPY ./app.py /app
|
||||||
COPY ./templates/index.html /app/templates/index.html
|
COPY ./templates/ /app/templates/
|
||||||
|
RUN mkdir -p /app/log
|
||||||
COPY ./static/ /app/static/
|
COPY ./static/ /app/static/
|
||||||
RUN ls -la /app/static/*
|
RUN ls -la /app/static/*
|
||||||
COPY ./app.ini /app
|
COPY ./app.ini /app
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
# erdbeerhannah
|
# erdbeerhannah
|
||||||
|
|
||||||
*Simple Rechner-App für Erdbeerstände*
|
*Simple Rechner-App für Erdbeerstände (und ähnliche Verkausstellen)*
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
|
|
||||||
Viele Erdbeerstände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple Rechner-App an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann.
|
Viele Obst- und Gemüse-Verkaufsstände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple, offline-fähige Progressive Web App (PWA) an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann.
|
||||||
|
|
||||||
Für jeden Artikel gibt es einen großen Knopf. Ein Klick addiert 1x den ausgewählten Artikel zur Gesamtsumme. Optional kann per Eingabefeld das Wechselgeld berechnet werden.
|
Über die Startseite (Landing Page) kann jeder Benutzer mit einem Klick und einem optionalen Passwort eine **eigene, geschützte Kassen-Instanz** erzeugen. Jede Kasse erhält dabei eine einzigartige, kryptische URL (UUID) und kann direkt im Browser konfiguriert werden.
|
||||||
|
|
||||||
Eine Demo-Instanz der App kann [hier](https://erdbeerhannah.elpatron.me/) zum freien Testen aufgerufen werden.
|
Für jeden der bis zu sechs Artikel gibt es einen großen Knopf. Ein Klick addiert 1x den ausgewählten Artikel zur Gesamtsumme. Über ein Eingabefeld kann blitzschnell das Rückgeld berechnet werden.
|
||||||
|
|
||||||
Die Demo-Instanz ist werbefrei und die Nutzung dauerhaft kostenlos. Details siehe in den Abschnitten *Anpassung* und *Privatsphäre*.
|
Eine Demo-Instanz der App kann [hier](https://erdbeerhannah.elpatron.me/) zur freien Nutzung aufgerufen werden.
|
||||||
|
|
||||||
**Der Autor übernimmt keinerlei Haftung für Verfügbarkeit, Funktionalität, insbesondere Richtigkeit der Rechenergebnisse.**
|
Die Nutzung ist werbefrei und dauerhaft kostenlos. Details siehe im Abschnitt *Privatsphäre*.
|
||||||
|
|
||||||
|
**Der Autor übernimmt keinerlei Haftung für Verfügbarkeit, Berechnungen und Richtigkeit der Rechenergebnisse.**
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -27,23 +29,46 @@ Die Web-App *erdbeerhannah* wurde für einfachste Bedienbarkeit auf allen Gerät
|
|||||||

|

|
||||||
*Darstellung auf dem iPad*
|
*Darstellung auf dem iPad*
|
||||||
|
|
||||||
## Anpassung der Artikel und Preise
|
## Features & PWA
|
||||||
|
|
||||||
Die Anpassung von Artikeln und Preisen ist derzeit lediglich im Quellcode der App möglich. Die App ist auf maximal sechs Artikel beschränkt.
|
- **Multi-Instanzen:** Kassen-Ansichten arbeiten unabhängig voneinander, gesichert durch UUIDs und Sessions. Man kann bedenkenlos mehrere Kassen-Tabs für unterschiedliche Schicht-Mitarbeiter öffnen.
|
||||||
|
- **Teilen leicht gemacht:** URLs zu erstellten Kassen können per "Teilen"-Button dank Web Share API sofort auf Mobile-Geräten geteilt (WhatsApp, E-Mail) oder in die Zwischenablage kopiert werden. Die zuletzt genutzten Instanzen merkt sich der eigene Browser automatisch für einen Schellzugriff auf der Startseite.
|
||||||
|
- **Progressive Web App (PWA):** erdbeerhannah lässt sich über den Browser ("Zum Startbildschirm hinzufügen") als eigenständige, offline-fähige App auf Smartphones und Tablets installieren.
|
||||||
|
|
||||||
Individuelle Anpassungen (Birnen? Kirschen? Spargel?) oder ein passendes Branding können auf Wunsch vom Autor gegen ein angemessenes Entgeld oder eine Spende an eine Organisation der Wahl des Autors vorgenommen werden. Ein Hosting unter der eigenen Domain (z.B. https://kasse.mein-erdbeerhof.de) ist ebenfalls möglich. Bei Interesse schreiben Sie bitte eine Mail an [m.busche@mailbox.org](mailto:m.busche@mailbox.org?subject=Erdbeerhannah).
|
## Anpassung der Artikel und Preise (Admin-Setup)
|
||||||
|
|
||||||
|
Im Gegensatz zu klassischen Kassensystemen benötigt man hier keine Programmierkenntnisse mehr:
|
||||||
|
Direkt nach der Erstellung landet man im passwortgeschützten **Setup-Bereich** (`/<uuid>/admin`). Hier können folgende Werte für jeden der bis zu sechs Artikel dynamisch eingestellt werden:
|
||||||
|
* **Name** (z.B. "Himbeeren", "Spargel")
|
||||||
|
* **Preis** (z.B. 4.90)
|
||||||
|
* **Icon / Emoji** (z.B. 🍓, 🛍️)
|
||||||
|
* **Darstellungsfarbe / Bootstrap Klasse** (Primär/Blau, Danger/Rot, Success/Grün, Warning/Gelb, ...)
|
||||||
|
|
||||||
|
Diese Änderungen werden sofort in Echtzeit für diese spezifische Kasse in die integrierte SQLite-Datenbank übernommen. Es ist auch jederzeit möglich, ungebrauchte Kassen über den roten Lösch-Button unwiderruflich aus der Datenbank zu entfernen.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Die Bereitstellung der App erfolgt am einfachsten mit Docker:
|
Die Bereitstellung der App erfolgt am einfachsten mit Docker:
|
||||||
|
|
||||||
|
`git clone https://gitea.elpatron.me/elpatron/erdbeerhannah.git`
|
||||||
|
|
||||||
|
`cd erdbeerhannah`
|
||||||
|
|
||||||
`docker image build -t erdbeerhannah:latest .`
|
`docker image build -t erdbeerhannah:latest .`
|
||||||
|
|
||||||
`docker run -p 9090:90 -t erdbeerhannah:latest`
|
Um die erstellten Kassen-Instanzen und deren Passwörter bei einem Neustart des Containers nicht zu verlieren, sollte für eine **persistente Datenspeicherung** die genutzte SQLite-Datenbank als Volume auf das Host-System gemountet werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vorab eine leere Datei anlegen, damit Docker fälschlicherweise kein Verzeichnis erstellt
|
||||||
|
touch kasse.db
|
||||||
|
|
||||||
|
# Container mit gemounteter Datenbank starten
|
||||||
|
docker run -p 9090:90 -v $(pwd)/kasse.db:/app/kasse.db -t erdbeerhannah:latest
|
||||||
|
```
|
||||||
|
|
||||||
## Privatsphäre
|
## Privatsphäre
|
||||||
|
|
||||||
Die App *erdbeerhannah* protokolliert keinerlei Daten. Keine IP-Adressen, keine Klicks, keine Benutzer-Eingaben und auch sonst nichts. Es werden keine Cookies genutzt, es findet kein Tracking statt und es gibt keine Werbung.
|
Die App *erdbeerhannah* protokolliert keine personenbezogenen Daten. Keine IP-Adressen und keine persönlichen Benutzer-Eingaben. Es werden keine Cookies genutzt. Zur reinen Nutzungsstatistik (Seitenaufrufe) verwenden wir das datenschutzfreundliche, Cookie-lose [Plausible Analytics](https://plausible.io/). Es gibt keine Werbung.
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
@@ -51,16 +76,15 @@ Die App *erdbeerhannah* protokolliert keinerlei Daten. Keine IP-Adressen, keine
|
|||||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
Version 2, December 2004
|
Version 2, December 2004
|
||||||
|
|
||||||
Copyright (C) 2024 Markus Busche; Knorrstr. 16, 24106 Kiel, Hermany
|
Copyright (C) 2024 Markus Busche; Knorrstr. 16, 24106 Kiel, Germany
|
||||||
|
|
||||||
Everyone is permitted to copy and distribute verbatim or modified
|
Everyone is permitted to copy and distribute verbatim or modified
|
||||||
copies of this license document, and changing it is allowed as long
|
copies of this license document, and changing it is allowed as long
|
||||||
as the name is changed.
|
as the name is changed.
|
||||||
|
|
||||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||||
```
|
```
|
||||||
```
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[uwsgi]
|
[uwsgi]
|
||||||
wsgi-file = wsgi.py
|
wsgi-file = wsgi.py
|
||||||
master = 5
|
master = true
|
||||||
|
processes = 4
|
||||||
http = :90
|
http = :90
|
||||||
die-on-term = true
|
die-on-term = true
|
||||||
@@ -1,127 +1,287 @@
|
|||||||
|
import math
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
from flask import Flask, render_template, request, session, send_from_directory
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from flask import Flask, render_template, request, session, send_from_directory, g, redirect, url_for
|
||||||
from flask_bootstrap import Bootstrap
|
from flask_bootstrap import Bootstrap
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SECRET_KEY'] = 'j69ol5mcHLsEtLg4Y/+myd9wWD4pp56E'
|
app.config['SECRET_KEY'] = 'j69ol5mcHLsEtLg4Y/+myd9wWD4pp56E'
|
||||||
|
|
||||||
# set up logging
|
# setup logging
|
||||||
formatter = logging.Formatter( # pylint: disable=invalid-name
|
formatter = logging.Formatter(
|
||||||
'%(asctime)s %(levelname)s %(process)d ---- %(threadName)s '
|
'%(asctime)s %(levelname)s %(process)d ---- %(threadName)s '
|
||||||
'%(module)s : %(funcName)s {%(pathname)s:%(lineno)d} %(message)s','%Y-%m-%dT%H:%M:%SZ')
|
'%(module)s : %(funcName)s {%(pathname)s:%(lineno)d} %(message)s','%Y-%m-%dT%H:%M:%SZ')
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
app.logger.setLevel(logging.DEBUG)
|
app.logger.setLevel(logging.DEBUG)
|
||||||
app.logger.addHandler(handler)
|
app.logger.addHandler(handler)
|
||||||
app.logger.info('Starting erdbeerhannah v1.0.3')
|
|
||||||
|
|
||||||
Bootstrap(app)
|
Bootstrap(app)
|
||||||
|
|
||||||
gesamtwert = 0
|
version = "1.1.0/2026-02-24"
|
||||||
change = 0
|
|
||||||
givenfloat = 0
|
def get_db_connection():
|
||||||
sum = ""
|
conn = sqlite3.connect('kasse.db')
|
||||||
item1 = 0
|
conn.row_factory = sqlite3.Row
|
||||||
item2 = 0
|
return conn
|
||||||
item3 = 0
|
|
||||||
item4 = 0
|
def init_db():
|
||||||
item5 = 0
|
conn = get_db_connection()
|
||||||
item6 = 0
|
conn.execute('''
|
||||||
background = "bg-white"
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
password TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
try:
|
||||||
|
conn.execute('ALTER TABLE instances ADD COLUMN password TEXT')
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # Column already exists
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
instance_id TEXT,
|
||||||
|
position INTEGER,
|
||||||
|
name TEXT,
|
||||||
|
price REAL,
|
||||||
|
icon TEXT,
|
||||||
|
color_class TEXT,
|
||||||
|
FOREIGN KEY (instance_id) REFERENCES instances (id),
|
||||||
|
PRIMARY KEY (instance_id, position)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
app.logger.info('Starting erdbeerhannah ' + version)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
g.request_start_time = time.time()
|
||||||
|
g.request_time = lambda: "%.4fs" % (time.time() - g.request_start_time)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_header(r):
|
||||||
|
r.headers["Cache-Control"] = "no-store, max-age=0"
|
||||||
|
return r
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def index():
|
def landing():
|
||||||
global gesamtwert, item1, item2, item3, item4, item5, item6, sum, givenfloat, change, given, background
|
if request.method == "POST":
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
instance_id = str(uuid.uuid4())
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.execute('INSERT INTO instances (id, password) VALUES (?, ?)', (instance_id, generate_password_hash(password)))
|
||||||
|
default_products = [
|
||||||
|
(instance_id, 1, "500g Erdbeeren", 4.9, "🍓", "btn-primary"),
|
||||||
|
(instance_id, 2, "Marmelade groß", 4.8, "🫙🫙", "btn-danger"),
|
||||||
|
(instance_id, 3, "Marmelade klein", 3.3, "🫙", "btn-danger"),
|
||||||
|
(instance_id, 4, "500g Kirschen", 5.0, "🍒", "btn-warning"),
|
||||||
|
(instance_id, 5, "500g Himbeeren", 4.5, "🫐", "btn-warning"),
|
||||||
|
(instance_id, 6, "Tragetasche", 0.2, "🛍️", "btn-success")
|
||||||
|
]
|
||||||
|
conn.executemany('INSERT INTO products (instance_id, position, name, price, icon, color_class) VALUES (?, ?, ?, ?, ?, ?)', default_products)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('admin', instance_id=instance_id))
|
||||||
|
return render_template("landing.html", version=version)
|
||||||
|
|
||||||
|
@app.route("/<instance_id>", methods=["GET", "POST"])
|
||||||
|
def index(instance_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone()
|
||||||
|
if not instance:
|
||||||
|
conn.close()
|
||||||
|
return "Instance not found", 404
|
||||||
|
|
||||||
|
products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
products = {row['position']: dict(row) for row in products_rows}
|
||||||
|
|
||||||
|
session_prefix = f"kasse_{instance_id}_"
|
||||||
|
|
||||||
|
gesamtwert = session.get(f'{session_prefix}gesamtwert', 0.0)
|
||||||
|
change = session.get(f'{session_prefix}change', "0.00")
|
||||||
|
givenfloat = session.get(f'{session_prefix}given', 0.0)
|
||||||
|
items = {p: session.get(f'{session_prefix}item{p}', 0) for p in products}
|
||||||
|
|
||||||
|
background = "bg-white"
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
# wert = float(request.form["wert"])
|
action = request.form.get('action')
|
||||||
wert = request.form.get('wert', "0", type=float)
|
position = request.form.get('position', type=int)
|
||||||
|
|
||||||
|
if action == "reset":
|
||||||
|
gesamtwert = 0.0
|
||||||
|
change = "0.00"
|
||||||
|
givenfloat = 0.0
|
||||||
|
items = {p: 0 for p in products}
|
||||||
|
background = "bg-white"
|
||||||
|
elif action == "calculate_change":
|
||||||
given = request.form.get('given', "0", type=float)
|
given = request.form.get('given', "0", type=float)
|
||||||
app.logger.debug('wert: %s, given: %s', wert, given)
|
givenfloat = given
|
||||||
wertfloat = float(wert)
|
change_val = givenfloat - gesamtwert
|
||||||
givenfloat = float(given)
|
change = f"{change_val:.2f}"
|
||||||
|
background = "bg-danger" if change_val < 0 else "bg-white"
|
||||||
|
elif position and position in products:
|
||||||
|
price = products[position]['price']
|
||||||
|
gesamtwert += price
|
||||||
|
items[position] += 1
|
||||||
|
|
||||||
# reset button
|
session[f'{session_prefix}gesamtwert'] = round(gesamtwert, 2)
|
||||||
if wertfloat == 0:
|
session[f'{session_prefix}change'] = change
|
||||||
global gesamtwert
|
session[f'{session_prefix}given'] = round(givenfloat, 2)
|
||||||
gesamtwert = 0
|
for p in products:
|
||||||
change = 0
|
session[f'{session_prefix}item{p}'] = items[p]
|
||||||
sum = "0"
|
|
||||||
item1 = 0
|
# Update our local variables after processing so they reflect what is rendered
|
||||||
item2 = 0
|
|
||||||
item3 = 0
|
|
||||||
item4 = 0
|
|
||||||
item5 = 0
|
|
||||||
item6 = 0
|
|
||||||
background = "bg-white"
|
|
||||||
# summarize value
|
|
||||||
elif wertfloat != -2:
|
|
||||||
gesamtwert += wertfloat
|
|
||||||
gesamtwert = round(gesamtwert, 2)
|
gesamtwert = round(gesamtwert, 2)
|
||||||
if gesamtwert > 0:
|
|
||||||
sum = str(gesamtwert) + "0"
|
|
||||||
|
|
||||||
# summarize items
|
n = len(products)
|
||||||
if wertfloat == 5.8:
|
grid_cols = math.ceil(math.sqrt(n)) if n else 1
|
||||||
item1 += 1
|
grid_rows = math.ceil(n / grid_cols) if n else 1
|
||||||
if wertfloat == 4.8:
|
|
||||||
item2 += 1
|
|
||||||
if wertfloat == 3.3:
|
|
||||||
item3 += 1
|
|
||||||
if wertfloat == 8.8:
|
|
||||||
item4 += 1
|
|
||||||
if wertfloat == 5.5:
|
|
||||||
item5 += 1
|
|
||||||
if wertfloat == .2:
|
|
||||||
item6 += 1
|
|
||||||
if givenfloat > 0:
|
|
||||||
try:
|
|
||||||
gesamtwert = session['summefloat'] or 0
|
|
||||||
sum = str(gesamtwert) + "0"
|
|
||||||
change = str(round((givenfloat - gesamtwert) * -1, 2)) + "0"
|
|
||||||
except:
|
|
||||||
app.logger.warning("Failed to read sum")
|
|
||||||
if givenfloat - gesamtwert < 0:
|
|
||||||
background = "bg-danger"
|
|
||||||
else:
|
|
||||||
background = "bg-white"
|
|
||||||
|
|
||||||
session['item1'] = item1
|
return render_template("index.html",
|
||||||
session['item2'] = item2
|
instance_id=instance_id,
|
||||||
session['item3'] = item3
|
products=products,
|
||||||
session['item4'] = item4
|
grid_cols=grid_cols,
|
||||||
session['item5'] = item5
|
grid_rows=grid_rows,
|
||||||
session['item6'] = item6
|
gesamtwert=f"{gesamtwert:.2f}",
|
||||||
session['summefloat'] = gesamtwert
|
change=change,
|
||||||
session['summestring'] = sum
|
given=f"{givenfloat:.2f}" if givenfloat > 0 else "0",
|
||||||
session['change'] = change
|
items=items,
|
||||||
session['given'] = givenfloat
|
|
||||||
|
|
||||||
app.logger.info('Sum %s, given %s, change %s', sum, givenfloat, change)
|
|
||||||
|
|
||||||
return render_template("index.html", gesamtwert=session.get('summestring', 0),
|
|
||||||
change=session.get('change', 0),
|
|
||||||
given=session.get('given', 0),
|
|
||||||
item1=session.get('item1', 0),
|
|
||||||
item2=session.get('item2', 0),
|
|
||||||
item3=session.get('item3', 0),
|
|
||||||
item4=session.get('item4', 0),
|
|
||||||
item5=session.get('item5', 0),
|
|
||||||
item6=session.get('item6', 0),
|
|
||||||
background=background,
|
background=background,
|
||||||
|
version=version
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route("/<instance_id>/admin", methods=["GET", "POST"])
|
||||||
|
def admin(instance_id):
|
||||||
|
conn = get_db_connection()
|
||||||
|
instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone()
|
||||||
|
if not instance:
|
||||||
|
conn.close()
|
||||||
|
return "Instance not found", 404
|
||||||
|
|
||||||
|
auth_key = f'admin_auth_{instance_id}'
|
||||||
|
|
||||||
|
# Check if instance has no password (empty or None)
|
||||||
|
stored_password = instance['password']
|
||||||
|
has_no_password = (
|
||||||
|
stored_password is None or
|
||||||
|
check_password_hash(stored_password, '')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle Login Submission
|
||||||
|
if request.method == "POST" and 'admin_password' in request.form:
|
||||||
|
entered = request.form['admin_password']
|
||||||
|
if has_no_password and entered == '':
|
||||||
|
session[auth_key] = True
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('admin', instance_id=instance_id))
|
||||||
|
elif not has_no_password and check_password_hash(stored_password, entered):
|
||||||
|
session[auth_key] = True
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('admin', instance_id=instance_id))
|
||||||
|
else:
|
||||||
|
conn.close()
|
||||||
|
return render_template("login.html", instance_id=instance_id, error="Falsches Passwort", version=version)
|
||||||
|
|
||||||
|
# Require Authentication
|
||||||
|
if not session.get(auth_key):
|
||||||
|
conn.close()
|
||||||
|
return render_template("login.html", instance_id=instance_id, error=None, version=version)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.form.get('action')
|
||||||
|
|
||||||
|
if action == "delete":
|
||||||
|
conn.execute('DELETE FROM products WHERE instance_id = ?', (instance_id,))
|
||||||
|
conn.execute('DELETE FROM instances WHERE id = ?', (instance_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
session.pop(auth_key, None)
|
||||||
|
return redirect(url_for('landing'))
|
||||||
|
|
||||||
|
for key in request.form:
|
||||||
|
if key.startswith('delete_') and request.form[key]:
|
||||||
|
pos = int(key.split('_')[1])
|
||||||
|
conn.execute('DELETE FROM products WHERE instance_id = ? AND position = ?',
|
||||||
|
(instance_id, pos))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('admin', instance_id=instance_id))
|
||||||
|
|
||||||
|
if action == "add_product":
|
||||||
|
name = request.form.get('add_name', '').strip()
|
||||||
|
price_str = request.form.get('add_price', '').replace(',', '.').strip()
|
||||||
|
try:
|
||||||
|
price = float(price_str) if price_str else None
|
||||||
|
except ValueError:
|
||||||
|
price = None
|
||||||
|
icon = request.form.get('add_icon', '🛒').strip() or '🛒'
|
||||||
|
color = request.form.get('add_color', 'btn-primary')
|
||||||
|
if name and price is not None:
|
||||||
|
max_pos = conn.execute(
|
||||||
|
'SELECT COALESCE(MAX(position), 0) FROM products WHERE instance_id = ?',
|
||||||
|
(instance_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO products (instance_id, position, name, price, icon, color_class) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
(instance_id, max_pos + 1, name, price, icon, color)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('admin', instance_id=instance_id))
|
||||||
|
|
||||||
|
# Update existing products
|
||||||
|
products_rows = conn.execute(
|
||||||
|
'SELECT position FROM products WHERE instance_id = ?', (instance_id,)
|
||||||
|
).fetchall()
|
||||||
|
for row in products_rows:
|
||||||
|
pos = row['position']
|
||||||
|
name = request.form.get(f'name_{pos}')
|
||||||
|
price_str = request.form.get(f'price_{pos}', '').replace(',', '.').strip()
|
||||||
|
try:
|
||||||
|
price = float(price_str) if price_str else None
|
||||||
|
except ValueError:
|
||||||
|
price = None
|
||||||
|
icon = request.form.get(f'icon_{pos}')
|
||||||
|
color = request.form.get(f'color_{pos}')
|
||||||
|
if name is not None and price is not None:
|
||||||
|
conn.execute('''
|
||||||
|
UPDATE products SET name = ?, price = ?, icon = ?, color_class = ?
|
||||||
|
WHERE instance_id = ? AND position = ?
|
||||||
|
''', (name, price, icon, color, instance_id, pos))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for('index', instance_id=instance_id))
|
||||||
|
|
||||||
|
products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
products = {row['position']: dict(row) for row in products_rows}
|
||||||
|
return render_template("admin.html", instance_id=instance_id, products=products, version=version)
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
return send_from_directory(os.path.join(app.root_path, 'static'),
|
return send_from_directory(os.path.join(app.root_path, 'static'),
|
||||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||||
|
|
||||||
# @app.route('/about')
|
@app.route('/manifest.json')
|
||||||
# def about():
|
def manifest():
|
||||||
# return send_from_directory(os.path.join(app.root_path, 'static'),
|
return send_from_directory('static', 'manifest.json')
|
||||||
# 'about.html')
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker():
|
||||||
|
return send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.logger.info('Starting erdbeerhannah v1.0.3')
|
app.run(debug=True, host='127.0.0.1')
|
||||||
app.run(debug=True, host='0.0.0.0')
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_icon(size, filename):
|
||||||
|
# Create a red square
|
||||||
|
img = Image.new('RGB', (size, size), color='#dc3545')
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Try to draw a simple 'E' in the center as a fallback if no emoji font
|
||||||
|
# Calculate approximate font size
|
||||||
|
font_size = int(size * 0.6)
|
||||||
|
try:
|
||||||
|
# standard windows font
|
||||||
|
font = ImageFont.truetype("arialbd.ttf", font_size)
|
||||||
|
except IOError:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
text = "E"
|
||||||
|
# Get bounding box
|
||||||
|
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_w = right - left
|
||||||
|
text_h = bottom - top
|
||||||
|
|
||||||
|
x = (size - text_w) / 2
|
||||||
|
y = (size - text_h) / 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
filepath = os.path.join('static', filename)
|
||||||
|
img.save(filepath)
|
||||||
|
print(f"Saved {filepath}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_icon(192, 'icon-192x192.png')
|
||||||
|
create_icon(512, 'icon-512x512.png')
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 645 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "erdbeerrechner",
|
||||||
|
"short_name": "erdbeer",
|
||||||
|
"description": "Eine anpassbare Kassen-App.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f8f9fa",
|
||||||
|
"theme_color": "#dc3545",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const CACHE_NAME = 'erdbeerkasse-v1';
|
||||||
|
const ASSETS_TO_CACHE = [
|
||||||
|
'/',
|
||||||
|
'/manifest.json',
|
||||||
|
'/static/icon-192x192.png',
|
||||||
|
'/static/icon-512x512.png',
|
||||||
|
'https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-first strategy for dynamic instances, fallback to cache
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Clone the response before caching if it's a valid response
|
||||||
|
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseToCache = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseToCache);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>erdbeerhannah 🍓💶 - Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#dc3545">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
|
||||||
|
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2 class="mb-4">Kassen-Konfiguration</h2>
|
||||||
|
<p>Dies ist die Konfiguration für deine Kasse: <strong>{{ instance_id }}</strong></p>
|
||||||
|
<p>Speichere dir diese URL ab, um später Änderungen vorzunehmen.</p>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Deine Kasse ist erreichbar unter: <a href="{{ url_for('index', instance_id=instance_id) }}">/{{ instance_id
|
||||||
|
}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" class="bg-white p-4 shadow-sm rounded">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Position</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Preis (€)</th>
|
||||||
|
<th>Icon/Emoji</th>
|
||||||
|
<th>Farbe (Bootstrap Klasse)</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if not products %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-muted text-center py-4">Keine Artikel. Füge unten einen neuen hinzu.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% for pos, prod in products|dictsort %}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">{{ pos }}</td>
|
||||||
|
<td><input type="text" class="form-control" name="name_{{ pos }}" value="{{ prod['name'] }}"
|
||||||
|
required></td>
|
||||||
|
<td><input type="text" inputmode="decimal" class="form-control" name="price_{{ pos }}"
|
||||||
|
value="{{ '{:.2f}'.format(prod['price']).replace('.', ',') }}" required></td>
|
||||||
|
<td><input type="text" class="form-control" name="icon_{{ pos }}" value="{{ prod['icon'] }}"
|
||||||
|
required></td>
|
||||||
|
<td>
|
||||||
|
<select class="form-control" name="color_{{ pos }}">
|
||||||
|
<option value="btn-primary" {% if prod['color_class']=='btn-primary' %}selected{% endif
|
||||||
|
%}>Blau (Primary)</option>
|
||||||
|
<option value="btn-secondary" {% if prod['color_class']=='btn-secondary' %}selected{%
|
||||||
|
endif %}>Grau (Secondary)</option>
|
||||||
|
<option value="btn-success" {% if prod['color_class']=='btn-success' %}selected{% endif
|
||||||
|
%}>Grün (Success)</option>
|
||||||
|
<option value="btn-danger" {% if prod['color_class']=='btn-danger' %}selected{% endif
|
||||||
|
%}>Rot (Danger)</option>
|
||||||
|
<option value="btn-warning" {% if prod['color_class']=='btn-warning' %}selected{% endif
|
||||||
|
%}>Gelb (Warning)</option>
|
||||||
|
<option value="btn-info" {% if prod['color_class']=='btn-info' %}selected{% endif %}>
|
||||||
|
Hellblau (Info)</option>
|
||||||
|
<option value="btn-dark" {% if prod['color_class']=='btn-dark' %}selected{% endif %}>
|
||||||
|
Schwarz (Dark)</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{% if products|length > 1 %}
|
||||||
|
<button type="submit" name="delete_{{ pos }}" value="1" class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Artikel wirklich löschen?');">Löschen</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('index', instance_id=instance_id) }}" class="btn btn-secondary">Zurück zur
|
||||||
|
Kasse</a>
|
||||||
|
<button type="submit" name="action" value="delete" class="btn btn-danger ml-2"
|
||||||
|
onclick="return confirm('Möchtest du diese Kasse wirklich unwiderruflich löschen? Alle zugehörigen Verkaufsdaten und Produkteinstellungen gehen verloren.');">🗑️
|
||||||
|
Kasse löschen</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="action" value="save" class="btn btn-success">Speichern & Zur Kasse</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" class="bg-white p-4 shadow-sm rounded mt-4">
|
||||||
|
<input type="hidden" name="action" value="add_product">
|
||||||
|
<h5 class="mb-3">Neuer Artikel hinzufügen</h5>
|
||||||
|
<div class="form-row align-items-end">
|
||||||
|
<div class="form-group col-md-3">
|
||||||
|
<label for="add_name">Name</label>
|
||||||
|
<input type="text" class="form-control" name="add_name" id="add_name" required
|
||||||
|
placeholder="z.B. 500g Brombeeren">
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-2">
|
||||||
|
<label for="add_price">Preis (€)</label>
|
||||||
|
<input type="text" inputmode="decimal" class="form-control" name="add_price" id="add_price" required
|
||||||
|
placeholder="4,50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-1">
|
||||||
|
<label for="add_icon">Icon</label>
|
||||||
|
<input type="text" class="form-control" name="add_icon" id="add_icon" placeholder="🫐"
|
||||||
|
maxlength="4">
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-2">
|
||||||
|
<label for="add_color">Farbe</label>
|
||||||
|
<select class="form-control" name="add_color" id="add_color">
|
||||||
|
<option value="btn-primary">Blau</option>
|
||||||
|
<option value="btn-secondary">Grau</option>
|
||||||
|
<option value="btn-success">Grün</option>
|
||||||
|
<option value="btn-danger">Rot</option>
|
||||||
|
<option value="btn-warning">Gelb</option>
|
||||||
|
<option value="btn-info">Hellblau</option>
|
||||||
|
<option value="btn-dark">Schwarz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-md-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Artikel hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center text-muted pb-4">
|
||||||
|
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
|
||||||
|
target="_blank">Kiel Strawberry City.</a><br>
|
||||||
|
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
+156
-86
@@ -1,121 +1,191 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta http-equiv="content-Type" content="text/html; utf-8" />
|
<meta http-equiv="content-Type" content="text/html; utf-8" />
|
||||||
<meta http-equiv="Pragma" content="cache" />
|
<meta http-equiv="Pragma" content="cache" />
|
||||||
<meta name="robots" content="INDEX,FOLLOW" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
<meta http-equiv="content-Language" content="de" />
|
|
||||||
<meta name="description" content="Viele Erdbeerstände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple Rechner-App an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann." />
|
|
||||||
<meta name="keywords" content="rechner calculator bargeld artikel obst erdbeeren spargel kirschen verkaufsstand wechselgeld" />
|
|
||||||
<meta name="author" content="Markus Busche" />
|
|
||||||
<meta name="publisher" content="Markus Busche" />
|
|
||||||
<meta name="copyright" content="2024 Markus Busche" />
|
|
||||||
<meta name="audience" content="Alle" />
|
|
||||||
<meta name="page-type" content="HTML-Formular" />
|
|
||||||
<meta name="page-topic" content="Dienstleistung" />
|
|
||||||
<meta http-equiv="Reply-to" content="m.busche@mailbox.org" />
|
|
||||||
<meta name="expires" content="" />
|
|
||||||
<meta name="revisit-after" content="2 days" />
|
|
||||||
<title>erdbeerhannah 🍓💶</title>
|
<title>erdbeerhannah 🍓💶</title>
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#dc3545">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
|
||||||
|
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body, html {
|
body,
|
||||||
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
.table-container {
|
|
||||||
height: 100%;
|
.kasse-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kasse-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat({{ grid_cols }}, 1fr);
|
||||||
|
grid-template-rows: repeat({{ grid_rows }}, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tile {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.table {
|
|
||||||
|
.article-tile .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
table-layout: fixed;
|
white-space: normal;
|
||||||
|
font-size: clamp(0.9rem, 4vw, 1.5rem);
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.table td {
|
|
||||||
height: calc(100vh / 6); /* Höhe der Zeilen dynamisch anpassen */
|
.kasse-sum-row {
|
||||||
vertical-align: middle;
|
flex-shrink: 0;
|
||||||
text-align: center;
|
display: flex;
|
||||||
}
|
align-items: center;
|
||||||
.btn {
|
justify-content: space-around;
|
||||||
width: 100%; /* Button füllt die Zelle */
|
padding: 0.5rem;
|
||||||
height: 100%; /* Button füllt die Zelle */
|
|
||||||
}
|
|
||||||
.bold-row {
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 250%;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.large-font {
|
|
||||||
font-size: 300%;
|
.kasse-reset {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.custom-btn-size {
|
|
||||||
font-size: 180%;
|
.kasse-footer {
|
||||||
}
|
flex-shrink: 0;
|
||||||
.custom-btn-size-med {
|
padding: 0.5rem;
|
||||||
font-size: 150%;
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-container {
|
.input-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column; /* Ändert die Richtung der Flex-Elemente zu Spalten */
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
text-align: center;
|
||||||
}
|
padding: 2rem;
|
||||||
.input-container input {
|
color: #6c757d;
|
||||||
margin-bottom: 10px;
|
|
||||||
width: 80%; /* Setzt die Breite des Eingabefelds */
|
|
||||||
}
|
|
||||||
.input-container button {
|
|
||||||
width: 80%; /* Setzt die Breite des Buttons */
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid table-container">
|
<form method="post" class="kasse-container container-fluid">
|
||||||
<table class="table table-bordered">
|
<div class="kasse-header">
|
||||||
<form method="post">
|
<span>erdbeerrechner 🍓💶</span>
|
||||||
<tbody>
|
<a href="{{ url_for('admin', instance_id=instance_id) }}"
|
||||||
<tr class="bold-row">
|
class="btn btn-outline-secondary">⚙️ Setup</a>
|
||||||
<td colspan="3">Erdbeerkasse 🍓💶</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><button type="submit" name="wert" value="5.8" class="btn btn-xl btn-primary custom-btn-size">🍓 5,80€ ({{ item1 }})</button></td>
|
|
||||||
<td><button type="submit" name="wert" value="4.8" class="btn btn-xl btn-danger custom-btn-size">🫙🫙 4,80€ ({{ item2 }})</button></td>
|
|
||||||
<td><button type="submit" name="wert" value="3.3" class="btn btn-xl btn-danger custom-btn-size">🫙 3,30€ ({{ item3 }})</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><button type="submit" name="wert" value="8.8" class="btn btn-xl btn-warning custom-btn-size">🧃🧃 8,80€ ({{ item4 }})</button></td>
|
|
||||||
<td><button type="submit" name="wert" value="5.5" class="btn btn-xl btn-warning custom-btn-size">🧃 5,50€ ({{ item5 }})</button></td>
|
|
||||||
<td><button type="submit" name="wert" value="0.2" class="btn btn-xl btn-success custom-btn-size">🛍️ 0,20€ ({{ item6 }})</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="bold-row">🫰 {{ gesamtwert }}€</td>
|
|
||||||
<td>
|
|
||||||
<div class="input-container">
|
|
||||||
<input type="text" class="form-control" name="given" placeholder="{{ given }}">
|
|
||||||
<button type="submit" name="wert" value="-2" class="btn btn-xl btn-primary custom-btn-size-med">🧾</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="bold-row {{ background }}">🪙 {{ change }}€</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3"><button type="submit" name="wert" value="0" id="reset" class="btn btn-xl btn-dark custom-btn-size">Reset 🦭</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">Made with ♥️, marmalade and zero kowledge in Kiel Strawberry City.<br>
|
|
||||||
Version: 1.0.3/2024-05-27<br>
|
|
||||||
<a href="https://gitea.elpatron.me/elpatron/erdbeerhannah/src/branch/main/README.md" target="_blank">Weitere Infos</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</form>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
<div class="article-grid">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
|
{% if not products %}
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
|
<div class="empty-state">
|
||||||
|
Keine Artikel. Bitte im <a href="{{ url_for('admin', instance_id=instance_id) }}">Admin</a> hinzufügen.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for pos, prod in products|dictsort %}
|
||||||
|
<div class="article-tile">
|
||||||
|
<button type="submit" name="position" value="{{ pos }}" title="{{ prod['name'] }}"
|
||||||
|
class="btn {{ prod['color_class'] }}">
|
||||||
|
{{ prod['icon'] }}<br>
|
||||||
|
{{ '{:,.2f}'.format(prod['price']).replace('.', ',') }}€<br>
|
||||||
|
({{ items.get(pos, 0) }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kasse-sum-row">
|
||||||
|
<span title="Summe">🫰 {{ gesamtwert.replace('.', ',') }}€</span>
|
||||||
|
<div class="input-container">
|
||||||
|
<input type="number" step="0.01" class="form-control" name="given"
|
||||||
|
placeholder="{{ given }}" value="{% if given != '0' %}{{ given }}{% endif %}">
|
||||||
|
<button type="submit" name="action" value="calculate_change"
|
||||||
|
title="Wechselgeld berechnen" class="btn btn-primary btn-sm">🧾 Berechnen</button>
|
||||||
|
</div>
|
||||||
|
<span title="Wechselgeld" class="{{ background }}">🪙 {{ change.replace('.', ',') }}€</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kasse-reset">
|
||||||
|
<button type="submit" name="action" value="reset" class="btn btn-dark btn-block">Reset 🦭</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kasse-footer">
|
||||||
|
Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
|
||||||
|
target="_blank">Kiel Strawberry City.</a><br>
|
||||||
|
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...
|
||||||
|
<button type="button" onclick="shareInstance()" class="btn btn-sm btn-outline-primary ml-2">📤 URL Teilen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save instance to localStorage for the landing page
|
||||||
|
const instanceId = "{{ instance_id }}";
|
||||||
|
let saved = JSON.parse(localStorage.getItem('erdbeerkassen')) || [];
|
||||||
|
// Optional: remove if exists so it gets pushed to the end (most recent)
|
||||||
|
saved = saved.filter(i => i.id !== instanceId);
|
||||||
|
saved.push({ id: instanceId, date: new Date().toISOString() });
|
||||||
|
localStorage.setItem('erdbeerkassen', JSON.stringify(saved));
|
||||||
|
|
||||||
|
function shareInstance() {
|
||||||
|
const url = window.location.href;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: 'erdbeerrechner 🍓💶',
|
||||||
|
url: url
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
alert('URL in die Zwischenablage kopiert!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>erdbeerhannah 🍓💶 - Start</title>
|
||||||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<style>
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 10vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#dc3545">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
|
||||||
|
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="mb-4">Willkommen beim erdbeerrechner 🍓💶</h1>
|
||||||
|
<p class="lead mb-5">Erstelle deine eigene, anpassbare Kassen-Instanz für deinen Verkaufsstand.</p>
|
||||||
|
<form method="post" class="bg-white p-4 shadow-sm rounded" style="max-width: 400px; width: 100%;">
|
||||||
|
<div class="form-group text-left">
|
||||||
|
<label for="password">Admin Passwort (optional)</label>
|
||||||
|
<input type="password" class="form-control" name="password" id="password" placeholder="Passwort...">
|
||||||
|
<small class="form-text text-muted">Dieses Passwort wird für den /admin Bereich benötigt.</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg shadow-sm w-100">Neue Kasse erstellen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-5 w-100" id="saved-instances-container" style="display: none; max-width: 500px;">
|
||||||
|
<h4 class="mb-3 text-secondary">Gespeicherte Kassen:</h4>
|
||||||
|
<ul class="list-group shadow-sm" id="saved-instances-list">
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 text-muted pb-4">
|
||||||
|
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
|
||||||
|
target="_blank">Kiel Strawberry City.</a><br>
|
||||||
|
Version: {{ version }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
let saved = JSON.parse(localStorage.getItem('erdbeerkassen')) || [];
|
||||||
|
if (saved.length > 0) {
|
||||||
|
document.getElementById('saved-instances-container').style.display = 'block';
|
||||||
|
const list = document.getElementById('saved-instances-list');
|
||||||
|
|
||||||
|
[...saved].reverse().forEach(instance => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap text-left';
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = '/' + instance.id;
|
||||||
|
link.textContent = 'Kasse ' + instance.id.substring(0, 8);
|
||||||
|
link.className = 'font-weight-bold flex-grow-1 text-decoration-none';
|
||||||
|
|
||||||
|
const btnGroup = document.createElement('div');
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-sm btn-outline-danger mr-2';
|
||||||
|
deleteBtn.textContent = '🗑️';
|
||||||
|
deleteBtn.title = 'Aus Liste entfernen';
|
||||||
|
deleteBtn.onclick = () => {
|
||||||
|
saved = saved.filter(i => i.id !== instance.id);
|
||||||
|
localStorage.setItem('erdbeerkassen', JSON.stringify(saved));
|
||||||
|
li.remove();
|
||||||
|
if (list.children.length === 0) {
|
||||||
|
document.getElementById('saved-instances-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareBtn = document.createElement('button');
|
||||||
|
shareBtn.className = 'btn btn-sm btn-outline-primary';
|
||||||
|
shareBtn.textContent = '📤 Teilen';
|
||||||
|
shareBtn.onclick = () => {
|
||||||
|
const url = window.location.origin + '/' + instance.id;
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: 'erdbeerrechner 🍓💶',
|
||||||
|
url: url
|
||||||
|
}).catch(console.error);
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
alert('URL in die Zwischenablage kopiert!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btnGroup.appendChild(deleteBtn);
|
||||||
|
btnGroup.appendChild(shareBtn);
|
||||||
|
|
||||||
|
li.appendChild(link);
|
||||||
|
li.appendChild(btnGroup);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>erdbeerhannah 🍓💶 - Login</title>
|
||||||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#dc3545">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
|
||||||
|
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container mt-5" style="max-width: 400px;">
|
||||||
|
<h2 class="mb-4 text-center">Admin Login</h2>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="bg-white p-4 shadow-sm rounded">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin_password">Passwort (bei passwortloser Kasse leer lassen)</label>
|
||||||
|
<input type="password" class="form-control" name="admin_password" id="admin_password"
|
||||||
|
autofocus placeholder="Leer lassen wenn keine Kasse mit Passwort">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Einloggen</button>
|
||||||
|
<a href="{{ url_for('index', instance_id=instance_id) }}" class="btn btn-secondary w-100 mt-2">Zurück zur
|
||||||
|
Kasse</a>
|
||||||
|
</form>
|
||||||
|
<div class="mt-4 text-center text-muted pb-4">
|
||||||
|
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
|
||||||
|
target="_blank">Kiel Strawberry City.</a><br>
|
||||||
|
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user