Add Flask shopping app with Nomad deployment
Some checks failed
deploy / deploy (push) Has been cancelled
Some checks failed
deploy / deploy (push) Has been cancelled
Flask app serving the Citti shopping list with SQLite persistence, per-category item addition, dynamic custom categories, photo upload, and a post-trip price analysis page. - Port 9756, DATA_DIR env var for persistent volume in Nomad - Gitea Actions pipeline builds Docker image and deploys via Nomad API - Makefile targets: run, install, build, push Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
41
.gitea/workflows/deploy.yml
Normal file
41
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: gea.i80.dk/hjess/citti
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GITEA_TOKEN }}" | \
|
||||||
|
docker login gea.i80.dk -u ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build & push image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t ${{ env.IMAGE }}:latest \
|
||||||
|
-t ${{ env.IMAGE }}:${{ github.sha }} .
|
||||||
|
docker push ${{ env.IMAGE }}:latest
|
||||||
|
docker push ${{ env.IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Deploy to Nomad
|
||||||
|
env:
|
||||||
|
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
|
||||||
|
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
|
||||||
|
run: |
|
||||||
|
JOB_JSON=$(curl -sf "${NOMAD_ADDR}/v1/jobs/parse" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"JobHCL\": $(jq -Rs . < citti.nomad)}")
|
||||||
|
curl -sf -X POST "${NOMAD_ADDR}/v1/jobs" \
|
||||||
|
-H "X-Nomad-Token: ${NOMAD_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"Job\": ${JOB_JSON}}"
|
||||||
|
echo "✓ Deployed"
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
data.db
|
||||||
|
static/uploads/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY static/ static/
|
||||||
|
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 9756
|
||||||
|
|
||||||
|
RUN useradd -m appuser && chown -R appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
IMAGE ?= gea.i80.dk/hjess/citti
|
||||||
|
TAG ?= latest
|
||||||
|
|
||||||
|
.PHONY: run install build push
|
||||||
|
|
||||||
|
run:
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
install:
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker build -t $(IMAGE):$(TAG) .
|
||||||
|
|
||||||
|
push: build
|
||||||
|
docker push $(IMAGE):$(TAG)
|
||||||
267
app.py
Normal file
267
app.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "."))
|
||||||
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DB = str(DATA_DIR / "data.db")
|
||||||
|
|
||||||
|
SECTIONS = [
|
||||||
|
{"id": "koed", "emoji": "🥩", "title": "Kød & fisk", "est": 1580, "varer": [
|
||||||
|
{"key": "entrecote", "name": "Entrecote ½ kg", "note": "Frisk sydamerikansk", "qty": "6 × ½ kg", "est": 605.94, "badges": ["card"]},
|
||||||
|
{"key": "nakkekoteletter", "name": "Nakkekoteletter ½ kg", "note": "Med/uden krydderi", "qty": "6 × ½ kg", "est": 191.94},
|
||||||
|
{"key": "kyllingebryst", "name": "Kyllingebryst ½ kg", "note": "Texas-marinade", "qty": "6 × ½ kg", "est": 359.94},
|
||||||
|
{"key": "hakket-oksekoed", "name": "Hakket oksekød", "note": "Køddisk — spørg personalet", "qty": "3 kg", "est": 240},
|
||||||
|
{"key": "rumpsteak", "name": "Rumpsteak ½ kg", "note": "Modnet, tyske kvier", "qty": "1 × ½ kg", "est": 167.99},
|
||||||
|
{"key": "lakseside", "name": "Lakseside premium", "note": "15,99/100g · tag ~700g", "qty": "~700g", "est": 112},
|
||||||
|
{"key": "grillpoelser", "name": "Gerstand grillpølser", "note": "10 stk. à 100g", "qty": "1 kg", "est": 74.99},
|
||||||
|
{"key": "bacon", "name": "Steinemann bacon i skiver","note": "Røget · fryseegnet", "qty": "1 kg", "est": 74.99},
|
||||||
|
]},
|
||||||
|
{"id": "frost", "emoji": "🧊", "title": "Frostgrønt · 3 kg poser", "est": 100, "varer": [
|
||||||
|
{"key": "wok-mix", "name": "Wok-mix blandede grøntsager", "qty": "3 kg", "est": 40},
|
||||||
|
{"key": "aerter", "name": "Ærter", "qty": "3 kg", "est": 30},
|
||||||
|
{"key": "broccoli", "name": "Broccoli", "qty": "3 kg", "est": 35},
|
||||||
|
{"key": "frost-andet", "name": "Hvad der ellers frister", "note": "Tjek frysedisken", "qty": "—", "est": 0},
|
||||||
|
]},
|
||||||
|
{"id": "mejeri", "emoji": "🧀", "title": "Mejeri, ost & frost", "est": 185, "varer": [
|
||||||
|
{"key": "feta", "name": "Greco Schaffeta feta 150g", "note": "70% fåremælk", "qty": "3 stk.", "est": 44.97},
|
||||||
|
{"key": "frutti-mare", "name": "Frutti di Mare", "note": "Rejer, muslinger, blæksprutte", "qty": "1 kg", "est": 59.99},
|
||||||
|
{"key": "tigerrejer", "name": "Sorte tigerrejer frost", "note": "Rå, pillede · wok/grill", "qty": "1 kg", "est": 79.99},
|
||||||
|
]},
|
||||||
|
{"id": "pasta", "emoji": "🍝", "title": "Pasta frisk køl · Gut & Günstig ~800g", "est": 160, "varer": [
|
||||||
|
{"key": "tortelloni-skinke", "name": "Tortelloni Skinke", "qty": "2 stk.", "est": 55},
|
||||||
|
{"key": "tortelloni-spinat", "name": "Tortelloni Spinat & ricotta", "qty": "2 stk.", "est": 55},
|
||||||
|
{"key": "tortelloni-valg", "name": "Tortelloni valgfri variant", "note": "Svampe, tomat-mozzarella o.l.", "qty": "2 stk.", "est": 55},
|
||||||
|
]},
|
||||||
|
{"id": "groent", "emoji": "🥗", "title": "Grønt, olier & tørvarer", "est": 245, "varer": [
|
||||||
|
{"key": "avocado", "name": "Avocado Hass", "note": "Fra Peru, SanLucar", "qty": "5 stk.", "est": 74.95},
|
||||||
|
{"key": "olivenolie", "name": "Bertolli olivenolie extra vergine", "qty": "1L", "est": 99.99},
|
||||||
|
{"key": "mutti", "name": "Mutti passata", "note": "Natur eller basilikum","qty": "3 × 700g", "est": 44.97},
|
||||||
|
{"key": "rustichella", "name": "Rustichella d'Abruzzo pasta", "qty": "4 × 500g", "est": 75.96},
|
||||||
|
]},
|
||||||
|
{"id": "konserves", "emoji": "🥫", "title": "Konserves — tomat & basis", "est": 185, "varer": [
|
||||||
|
{"key": "hakkede-tomater", "name": "Hakkede tomater 400g", "qty": "8 dåser", "est": 50},
|
||||||
|
{"key": "flaede-tomater", "name": "Flåede tomater 400g", "qty": "4 dåser", "est": 25},
|
||||||
|
{"key": "tomatpure", "name": "Tomatpuré / koncentrat", "qty": "4 stk.", "est": 35},
|
||||||
|
{"key": "tun", "name": "Tun i olie 80g", "qty": "6 dåser", "est": 47},
|
||||||
|
{"key": "majs", "name": "Majs 340g", "qty": "4 dåser", "est": 28},
|
||||||
|
]},
|
||||||
|
{"id": "indisk", "emoji": "🌶️", "title": "Indisk & karry", "est": 160, "varer": [
|
||||||
|
{"key": "tikka-masala", "name": "Tikka masala sauce", "note": "350–500g · Taste of India", "qty": "5 stk.", "est": 70},
|
||||||
|
{"key": "karrypasta-roed", "name": "Karrypasta rød 50g", "qty": "2 stk.", "est": 20},
|
||||||
|
{"key": "karrypasta-groen", "name": "Karrypasta grøn 50g", "qty": "2 stk.", "est": 20},
|
||||||
|
{"key": "karrypasta-gul", "name": "Karrypasta gul/mild", "qty": "2 stk.", "est": 20},
|
||||||
|
{"key": "mango-chutney", "name": "Mango chutney 300g", "qty": "2 stk.", "est": 24},
|
||||||
|
{"key": "basmatiris", "name": "Basmatiris 1 kg", "qty": "2 stk.", "est": 28},
|
||||||
|
]},
|
||||||
|
{"id": "mex", "emoji": "🌮", "title": "Mexicansk & asiatisk", "est": 185, "varer": [
|
||||||
|
{"key": "tacokrydderi", "name": "Tacokrydderi pakke", "qty": "6 stk.", "est": 33},
|
||||||
|
{"key": "jalapenos", "name": "Jalapeños på glas 200g", "qty": "2 stk.", "est": 16},
|
||||||
|
{"key": "salsa", "name": "Salsa / tomatdip 300g", "qty": "2 stk.", "est": 19},
|
||||||
|
{"key": "tacoshells", "name": "Tacoshells / tortilla wraps","qty": "2 stk.", "est": 24},
|
||||||
|
{"key": "kokosmalk", "name": "Kokosmælk 400ml", "qty": "6 dåser", "est": 47},
|
||||||
|
{"key": "sojasauce", "name": "Sojasauce 250ml", "qty": "1 stk.", "est": 10},
|
||||||
|
{"key": "bambusskud", "name": "Bambusskud / vandkastanjer","qty": "2 dåser", "est": 14},
|
||||||
|
]},
|
||||||
|
{"id": "drikke", "emoji": "🍺", "title": "Drikkevarer", "est": 530, "varer": [
|
||||||
|
{"key": "carlsberg", "name": "Carlsberg / Carls Special 0,33L", "qty": "24 stk.", "est": 79, "badges": ["kasse"]},
|
||||||
|
{"key": "pepsi-max", "name": "Pepsi Max 1,5L", "qty": "6 stk.", "est": 49.99, "badges": ["kasse"]},
|
||||||
|
{"key": "faxe-kondi", "name": "Faxe Kondi Appelsin 0,33L", "qty": "24 stk.", "est": 54.99, "badges": ["kasse"]},
|
||||||
|
{"key": "kuehling-vin", "name": "Kühling hvid-/rosévin 0,75L", "qty": "4 stk.", "est": 159.96, "badges": ["card"]},
|
||||||
|
{"key": "grauburgunder", "name": "Grauburgunder hvidvin 1,5L", "qty": "1 stk.", "est": 59.99, "badges": ["card"]},
|
||||||
|
{"key": "kaffe", "name": "Eduscho Caffè Crema hele bønner", "qty": "1 kg", "est": 104.99},
|
||||||
|
]},
|
||||||
|
{"id": "hus", "emoji": "🏠", "title": "Husholdning & skafferi", "est": 535, "varer": [
|
||||||
|
{"key": "ariel", "name": "Ariel vaskemiddel", "note": "100–111 vaske", "qty": "1 stk.", "est": 199.90},
|
||||||
|
{"key": "finish", "name": "Finish opvasketabs", "note": "64 stk.", "qty": "1 stk.", "est": 59.99},
|
||||||
|
{"key": "cashew", "name": "BASE Culinar cashewnødder", "qty": "1 kg", "est": 79.99},
|
||||||
|
{"key": "nutella", "name": "Nutella 1 kg glas", "qty": "1 stk.", "est": 49.99, "badges": ["card"]},
|
||||||
|
{"key": "biscoff", "name": "Lotus Biscoff smørepålæg 1,6 kg", "qty": "1 stk.", "est": 99.99, "badges": ["card"]},
|
||||||
|
{"key": "haribo", "name": "Haribo vingummi / lakrids", "note": "800–1000g", "qty": "1 stk.", "est": 44.99},
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text):
|
||||||
|
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
|
||||||
|
return re.sub(r'[-\s]+', '-', re.sub(r'[^\w\s-]', '', text.lower())).strip('-')
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with get_db() as c:
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS prices (
|
||||||
|
key TEXT PRIMARY KEY, price REAL, checked INT DEFAULT 0, updated TEXT)""")
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS extras (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT, section_key TEXT, price REAL,
|
||||||
|
photo TEXT, note TEXT, created TEXT)""")
|
||||||
|
c.execute("""CREATE TABLE IF NOT EXISTS custom_sections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
emoji TEXT DEFAULT '📦', title TEXT, created TEXT)""")
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
with get_db() as c:
|
||||||
|
saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")}
|
||||||
|
extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY created DESC")]
|
||||||
|
custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")]
|
||||||
|
by_sec = {}
|
||||||
|
for e in extras:
|
||||||
|
by_sec.setdefault(e.get('section_key') or '', []).append(e)
|
||||||
|
return render_template('index.html', sections=SECTIONS, saved=saved,
|
||||||
|
by_sec=by_sec, custom_secs=custom_secs)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/price', methods=['POST'])
|
||||||
|
def save_price():
|
||||||
|
d = request.json
|
||||||
|
key = d.get('key')
|
||||||
|
price = d.get('price')
|
||||||
|
checked = int(d.get('checked', 0))
|
||||||
|
if not key:
|
||||||
|
return jsonify(error='missing key'), 400
|
||||||
|
with get_db() as c:
|
||||||
|
c.execute("""INSERT INTO prices(key,price,checked,updated) VALUES(?,?,?,?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET price=excluded.price,
|
||||||
|
checked=excluded.checked, updated=excluded.updated""",
|
||||||
|
(key, price, checked, datetime.now().isoformat()))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/item', methods=['POST'])
|
||||||
|
def add_item():
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify(error='name required'), 400
|
||||||
|
price_s = request.form.get('price', '').strip().replace(',', '.')
|
||||||
|
price = float(price_s) if price_s else None
|
||||||
|
section_key = request.form.get('section_key', '').strip() or None
|
||||||
|
note = request.form.get('note', '').strip()
|
||||||
|
|
||||||
|
photo = None
|
||||||
|
f = request.files.get('photo')
|
||||||
|
if f and f.filename:
|
||||||
|
ext = (os.path.splitext(f.filename)[1] or '.jpg').lower()
|
||||||
|
fname = f"{slugify(name)}-{int(datetime.now().timestamp())}{ext}"
|
||||||
|
f.save(UPLOAD_DIR / fname)
|
||||||
|
photo = fname
|
||||||
|
|
||||||
|
with get_db() as c:
|
||||||
|
row = c.execute(
|
||||||
|
"INSERT INTO extras(name,section_key,price,photo,note,created) VALUES(?,?,?,?,?,?)",
|
||||||
|
(name, section_key, price, photo, note, datetime.now().isoformat()))
|
||||||
|
item_id = row.lastrowid
|
||||||
|
|
||||||
|
return jsonify(ok=True, id=item_id, photo=photo,
|
||||||
|
name=name, section_key=section_key, price=price, note=note)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/item/<int:item_id>', methods=['DELETE'])
|
||||||
|
def delete_item(item_id):
|
||||||
|
with get_db() as c:
|
||||||
|
row = c.execute("SELECT photo FROM extras WHERE id=?", (item_id,)).fetchone()
|
||||||
|
if row and row['photo']:
|
||||||
|
p = UPLOAD_DIR / row['photo']
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
c.execute("DELETE FROM extras WHERE id=?", (item_id,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/uploads/<path:filename>')
|
||||||
|
def serve_upload(filename):
|
||||||
|
return send_from_directory(UPLOAD_DIR, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/category', methods=['POST'])
|
||||||
|
def add_category():
|
||||||
|
d = request.json
|
||||||
|
emoji = (d.get('emoji') or '📦').strip() or '📦'
|
||||||
|
title = (d.get('title') or '').strip()
|
||||||
|
if not title:
|
||||||
|
return jsonify(error='title required'), 400
|
||||||
|
with get_db() as c:
|
||||||
|
row = c.execute("INSERT INTO custom_sections(emoji,title,created) VALUES(?,?,?)",
|
||||||
|
(emoji, title, datetime.now().isoformat()))
|
||||||
|
cat_id = row.lastrowid
|
||||||
|
return jsonify(ok=True, id=cat_id, key=f"custom-{cat_id}", emoji=emoji, title=title)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/category/<int:cat_id>', methods=['DELETE'])
|
||||||
|
def delete_category(cat_id):
|
||||||
|
key = f"custom-{cat_id}"
|
||||||
|
with get_db() as c:
|
||||||
|
for r in c.execute("SELECT photo FROM extras WHERE section_key=?", (key,)).fetchall():
|
||||||
|
if r['photo']:
|
||||||
|
p = UPLOAD_DIR / r['photo']
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
c.execute("DELETE FROM extras WHERE section_key=?", (key,))
|
||||||
|
c.execute("DELETE FROM custom_sections WHERE id=?", (cat_id,))
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/analyse')
|
||||||
|
def analyse():
|
||||||
|
with get_db() as c:
|
||||||
|
saved = {r['key']: dict(r) for r in c.execute("SELECT * FROM prices")}
|
||||||
|
extras = [dict(r) for r in c.execute("SELECT * FROM extras ORDER BY price DESC NULLS LAST")]
|
||||||
|
custom_secs = [dict(r) for r in c.execute("SELECT * FROM custom_sections ORDER BY created")]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
total_est = total_actual = 0
|
||||||
|
for sec in SECTIONS:
|
||||||
|
sec_est = sec_actual = 0
|
||||||
|
for item in sec['varer']:
|
||||||
|
s = saved.get(item['key'], {})
|
||||||
|
actual = s.get('price')
|
||||||
|
sec_est += item['est']
|
||||||
|
if actual:
|
||||||
|
sec_actual += actual
|
||||||
|
rows.append({
|
||||||
|
'title': sec['title'],
|
||||||
|
'emoji': sec['emoji'],
|
||||||
|
'est': sec_est,
|
||||||
|
'actual': sec_actual or None,
|
||||||
|
'diff': (sec_actual - sec_est) if sec_actual else None,
|
||||||
|
})
|
||||||
|
total_est += sec_est
|
||||||
|
if sec_actual:
|
||||||
|
total_actual += sec_actual
|
||||||
|
|
||||||
|
return render_template('analyse.html', sections=SECTIONS, saved=saved,
|
||||||
|
extras=extras, rows=rows, custom_secs=custom_secs,
|
||||||
|
total_est=total_est,
|
||||||
|
total_actual=total_actual or None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_db()
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
local_ip = s.getsockname()[0]
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
local_ip = "127.0.0.1"
|
||||||
|
print(f"\n Computer: http://localhost:9756")
|
||||||
|
print(f" Telefon (samme WiFi): http://{local_ip}:9756\n")
|
||||||
|
app.run(host='0.0.0.0', port=9756, debug=False)
|
||||||
50
citti.nomad
Normal file
50
citti.nomad
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
job "citti" {
|
||||||
|
datacenters = ["dc1"]
|
||||||
|
type = "service"
|
||||||
|
|
||||||
|
group "app" {
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
volume "citti-data" {
|
||||||
|
type = "host"
|
||||||
|
read_only = false
|
||||||
|
source = "citti-data"
|
||||||
|
}
|
||||||
|
|
||||||
|
network {
|
||||||
|
port "http" {
|
||||||
|
static = 9756
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task "web" {
|
||||||
|
driver = "docker"
|
||||||
|
|
||||||
|
config {
|
||||||
|
image = "gea.i80.dk/hjess/citti:latest"
|
||||||
|
ports = ["http"]
|
||||||
|
force_pull = true
|
||||||
|
}
|
||||||
|
|
||||||
|
volume_mount {
|
||||||
|
volume = "citti-data"
|
||||||
|
destination = "/data"
|
||||||
|
read_only = false
|
||||||
|
}
|
||||||
|
|
||||||
|
env {
|
||||||
|
DATA_DIR = "/data"
|
||||||
|
}
|
||||||
|
|
||||||
|
resources {
|
||||||
|
cpu = 200
|
||||||
|
memory = 256
|
||||||
|
}
|
||||||
|
|
||||||
|
service {
|
||||||
|
name = "citti"
|
||||||
|
port = "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
flask>=3.0
|
||||||
284
templates/analyse.html
Normal file
284
templates/analyse.html
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Citti Analyse — Uge 23-25</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f2ed; --card: #ffffff; --border: #e2ddd6; --text: #1a1814;
|
||||||
|
--muted: #8a857c; --accent: #c84b2f; --green: #2d6a4f; --green-light: #e8f4ee;
|
||||||
|
--amber: #92580a; --amber-light: #fef3e2; --blue: #1d4e89; --blue-light: #e8f0f9;
|
||||||
|
--red-light: #fde8e3;
|
||||||
|
--mono: 'DM Mono', monospace; --sans: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--sans); background: var(--bg); color: var(--text); font-size: 15px; line-height: 1.5; padding: 1.5rem 1rem 4rem; }
|
||||||
|
.page { max-width: 860px; margin: 0 auto; }
|
||||||
|
header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 2px solid var(--text); }
|
||||||
|
h1 { font-size: 1.75rem; font-weight: 600; letter-spacing: -0.03em; }
|
||||||
|
h1 span { color: var(--accent); }
|
||||||
|
.btn-back { background: var(--text); color: white; border: none; border-radius: 8px; padding: 8px 14px; font-size: 13px; font-family: var(--sans); cursor: pointer; text-decoration: none; display: inline-block; font-weight: 500; }
|
||||||
|
|
||||||
|
.kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 1.5rem; }
|
||||||
|
.kpi { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; }
|
||||||
|
.kpi-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
|
||||||
|
.kpi-val { font-size: 1.75rem; font-weight: 600; font-family: var(--mono); letter-spacing: -0.02em; }
|
||||||
|
.kpi-val.green { color: var(--green); }
|
||||||
|
.kpi-val.red { color: var(--accent); }
|
||||||
|
.kpi-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Category table */
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.5rem; }
|
||||||
|
.card-title { background: var(--text); color: white; padding: 0.6rem 1rem; font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th { padding: 6px 12px; font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; text-align: left; background: #f0ece6; border-bottom: 1px solid var(--border); }
|
||||||
|
th.num { text-align: right; }
|
||||||
|
td { padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
td.num { text-align: right; font-family: var(--mono); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr.total-row td { font-weight: 600; background: #faf9f7; border-top: 2px solid var(--border); }
|
||||||
|
.diff-pos { color: var(--accent); font-weight: 500; }
|
||||||
|
.diff-neg { color: var(--green); font-weight: 500; }
|
||||||
|
.no-data { color: var(--muted); font-style: italic; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Bar chart */
|
||||||
|
.bar-wrap { padding: 1rem; }
|
||||||
|
.bar-row { display: grid; grid-template-columns: 140px 1fr 70px; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.bar-label { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.bar-track { background: #e8f4ee; border-radius: 4px; height: 10px; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 4px; background: var(--green); transition: width 0.6s ease; }
|
||||||
|
.bar-fill.over { background: var(--accent); }
|
||||||
|
.bar-val { font-size: 11px; font-family: var(--mono); color: var(--muted); text-align: right; }
|
||||||
|
|
||||||
|
/* Item details */
|
||||||
|
.items-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; padding: 1rem; }
|
||||||
|
.item-card { border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem; background: var(--bg); }
|
||||||
|
.item-card-name { font-size: 13px; font-weight: 500; margin-bottom: 4px; }
|
||||||
|
.item-card-prices { display: flex; gap: 12px; font-size: 12px; }
|
||||||
|
.item-card-est { color: var(--muted); font-family: var(--mono); }
|
||||||
|
.item-card-actual { color: var(--green); font-family: var(--mono); font-weight: 600; }
|
||||||
|
.item-card-diff { font-size: 11px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Extra items grid */
|
||||||
|
.extras-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; padding: 1rem; }
|
||||||
|
.extra-card { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: white; }
|
||||||
|
.extra-card img { width: 100%; height: 120px; object-fit: cover; display: block; }
|
||||||
|
.extra-card-no-photo { width: 100%; height: 120px; background: var(--bg); display: flex; align-items: center; justify-content: center; font-size: 40px; }
|
||||||
|
.extra-card-body { padding: 0.6rem 0.75rem; }
|
||||||
|
.extra-card-name { font-size: 13px; font-weight: 500; }
|
||||||
|
.extra-card-price { font-size: 16px; font-weight: 600; font-family: var(--mono); color: var(--accent); margin-top: 2px; }
|
||||||
|
.extra-card-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||||
|
|
||||||
|
.empty-state { padding: 2rem; text-align: center; color: var(--muted); font-size: 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.bar-row { grid-template-columns: 90px 1fr 60px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Citti <span>Analyse</span></h1>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:4px">Uge 23–25 · 03.06–23.06.2026</div>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn-back">← Indkøbsseddel</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% set total_actual = total_actual or 0 %}
|
||||||
|
{% set total_est = total_est or 4065 %}
|
||||||
|
{% set diff = total_actual - total_est if total_actual else 0 %}
|
||||||
|
{% set pct = ((diff / total_est) * 100) if total_est else 0 %}
|
||||||
|
|
||||||
|
<div class="kpi-row">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">Faktisk betalt (Citti)</div>
|
||||||
|
{% if total_actual %}
|
||||||
|
<div class="kpi-val">{{ "{:,.0f}".format(total_actual).replace(",",".") }}</div>
|
||||||
|
<div class="kpi-sub">kr. registreret</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="kpi-val" style="color:var(--muted)">—</div>
|
||||||
|
<div class="kpi-sub">ingen priser indtastet endnu</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">Estimeret</div>
|
||||||
|
<div class="kpi-val">{{ "{:,.0f}".format(total_est).replace(",",".") }}</div>
|
||||||
|
<div class="kpi-sub">kr. planlagt</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">Difference</div>
|
||||||
|
{% if total_actual %}
|
||||||
|
<div class="kpi-val {% if diff > 0 %}red{% else %}green{% endif %}">
|
||||||
|
{% if diff > 0 %}+{% endif %}{{ "{:,.0f}".format(diff).replace(",",".") }}
|
||||||
|
</div>
|
||||||
|
<div class="kpi-sub">kr. ({{ "{:+.1f}".format(pct) }}%)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="kpi-val" style="color:var(--muted)">—</div>
|
||||||
|
<div class="kpi-sub">-</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KATEGORI OVERSIGT -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Kategorioversigt</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th class="num">Estimat</th>
|
||||||
|
<th class="num">Faktisk</th>
|
||||||
|
<th class="num">Difference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.emoji }} {{ r.title }}</td>
|
||||||
|
<td class="num">{{ "{:,.0f}".format(r.est).replace(",",".") }}</td>
|
||||||
|
<td class="num">{% if r.actual %}{{ "{:,.0f}".format(r.actual).replace(",",".") }}{% else %}<span class="no-data">—</span>{% endif %}</td>
|
||||||
|
<td class="num">
|
||||||
|
{% if r.diff is not none %}
|
||||||
|
<span class="{{ 'diff-pos' if r.diff > 0 else 'diff-neg' }}">
|
||||||
|
{% if r.diff > 0 %}+{% endif %}{{ "{:,.0f}".format(r.diff).replace(",",".") }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="no-data">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Total Citti</td>
|
||||||
|
<td class="num">{{ "{:,.0f}".format(total_est).replace(",",".") }}</td>
|
||||||
|
<td class="num">{% if total_actual %}{{ "{:,.0f}".format(total_actual).replace(",",".") }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="num">
|
||||||
|
{% if total_actual %}
|
||||||
|
<span class="{{ 'diff-pos' if diff > 0 else 'diff-neg' }}">
|
||||||
|
{% if diff > 0 %}+{% endif %}{{ "{:,.0f}".format(diff).replace(",",".") }}
|
||||||
|
</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STABDIAGRAM -->
|
||||||
|
{% if total_actual %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Faktisk vs. estimat per kategori</div>
|
||||||
|
<div class="bar-wrap">
|
||||||
|
{% for r in rows %}
|
||||||
|
{% if r.actual %}
|
||||||
|
{% set pct_bar = [((r.actual / r.est) * 100), 100]|min if r.est else 0 %}
|
||||||
|
<div class="bar-row">
|
||||||
|
<div class="bar-label" title="{{ r.title }}">{{ r.emoji }} {{ r.title.split('·')[0].strip() }}</div>
|
||||||
|
<div class="bar-track">
|
||||||
|
<div class="bar-fill {% if r.actual > r.est %}over{% endif %}" style="width:{{ pct_bar }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bar-val">{{ "{:,.0f}".format(r.actual).replace(",",".") }} / {{ "{:,.0f}".format(r.est).replace(",",".") }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- VARE-DETALJER -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Alle varer — est. vs. faktisk</div>
|
||||||
|
{% set has_items = saved|length > 0 %}
|
||||||
|
{% if has_items %}
|
||||||
|
<div class="items-grid">
|
||||||
|
{% for sec in sections %}
|
||||||
|
{% for item in sec.varer %}
|
||||||
|
{% set s = saved.get(item.key, {}) %}
|
||||||
|
{% if s.get('price') %}
|
||||||
|
{% set diff_item = s.price - item.est %}
|
||||||
|
<div class="item-card">
|
||||||
|
<div class="item-card-name">{{ item.name }}</div>
|
||||||
|
<div class="item-card-prices">
|
||||||
|
<span class="item-card-est">est. {{ "{:.0f}".format(item.est) }}</span>
|
||||||
|
<span class="item-card-actual">→ {{ "{:.0f}".format(s.price) }} kr.</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-card-diff {% if diff_item > 2 %}diff-pos{% elif diff_item < -2 %}diff-neg{% endif %}">
|
||||||
|
{% if diff_item > 0.5 %}+{{ "{:.0f}".format(diff_item) }} kr. dyrere{% elif diff_item < -0.5 %}{{ "{:.0f}".format(diff_item) }} kr. billigere{% else %}som estimat{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Ingen priser registreret endnu.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EKSTRA VARER -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Ekstra varer tilføjet i butikken ({{ extras|length }})</div>
|
||||||
|
{% if extras %}
|
||||||
|
<div class="extras-grid">
|
||||||
|
{% for e in extras %}
|
||||||
|
<div class="extra-card">
|
||||||
|
{% if e.photo %}
|
||||||
|
<img src="/uploads/{{ e.photo }}" alt="{{ e.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="extra-card-no-photo">📦</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="extra-card-body">
|
||||||
|
<div class="extra-card-name">{{ e.name }}</div>
|
||||||
|
{% if e.price %}<div class="extra-card-price">{{ "{:.2f}".format(e.price).replace(".",",") }} kr.</div>{% endif %}
|
||||||
|
<div class="extra-card-meta">
|
||||||
|
{% if e.category %}{{ e.category }}{% endif %}
|
||||||
|
{% if e.note %} · {{ e.note }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">Ingen ekstra varer tilføjet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if extras %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Ekstra varer — prissammenfatning</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Vare</th><th>Kategori</th><th class="num">Pris</th><th>Note</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% set extra_total = namespace(v=0) %}
|
||||||
|
{% for e in extras %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.name }}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">{{ e.category or '—' }}</td>
|
||||||
|
<td class="num">{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">{{ e.note or '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% if e.price %}{% set extra_total.v = extra_total.v + e.price %}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if extra_total.v > 0 %}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Total ekstra</td>
|
||||||
|
<td class="num">{{ "{:.2f}".format(extra_total.v).replace(".",",") }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
604
templates/index.html
Normal file
604
templates/index.html
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||||
|
<title>Citti Indkøbsseddel — Uge 23-25</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f5f2ed; --card: #ffffff; --border: #e2ddd6; --text: #1a1814;
|
||||||
|
--muted: #8a857c; --accent: #c84b2f; --accent-light: #f7ede9;
|
||||||
|
--green: #2d6a4f; --green-light: #e8f4ee;
|
||||||
|
--amber: #92580a; --amber-light: #fef3e2;
|
||||||
|
--blue: #1d4e89; --blue-light: #e8f0f9;
|
||||||
|
--mono: 'DM Mono', monospace; --sans: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--sans); background: var(--bg); color: var(--text); font-size: 15px; line-height: 1.5; padding: 1.5rem 1rem 6rem; }
|
||||||
|
.page { max-width: 860px; margin: 0 auto; }
|
||||||
|
|
||||||
|
header { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: 1rem; margin-bottom: 1.5rem; padding-bottom: 1.25rem; border-bottom: 2px solid var(--text); }
|
||||||
|
.header-left h1 { font-size: 1.75rem; font-weight: 600; letter-spacing: -0.03em; line-height: 1.1; margin-bottom: 0.4rem; }
|
||||||
|
.header-left h1 span { color: var(--accent); }
|
||||||
|
.header-meta { display: flex; flex-wrap: wrap; gap: 0.4rem 1.25rem; font-size: 12px; color: var(--muted); }
|
||||||
|
.header-actions { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
||||||
|
.btn { background: var(--text); color: white; border: none; border-radius: 8px; padding: 8px 14px; font-size: 13px; font-family: var(--sans); cursor: pointer; text-decoration: none; display: inline-block; font-weight: 500; white-space: nowrap; }
|
||||||
|
.btn-cat { background: var(--accent); }
|
||||||
|
.savings-card { background: var(--text); color: white; border-radius: 10px; padding: 0.875rem 1.1rem; text-align: right; }
|
||||||
|
.savings-card .label { font-size: 10px; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 3px; }
|
||||||
|
.savings-card .amount { font-size: 1.75rem; font-weight: 600; font-family: var(--mono); letter-spacing: -0.02em; color: #7de8b0; }
|
||||||
|
.savings-card .sublabel { font-size: 10px; opacity: 0.5; }
|
||||||
|
|
||||||
|
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 1.5rem; }
|
||||||
|
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 0.75rem; }
|
||||||
|
.stat .s-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 3px; }
|
||||||
|
.stat .s-val { font-size: 1.35rem; font-weight: 600; font-family: var(--mono); letter-spacing: -0.02em; }
|
||||||
|
.stat .s-sub { font-size: 10px; color: var(--muted); margin-top: 1px; }
|
||||||
|
|
||||||
|
.section { margin-bottom: 1.25rem; }
|
||||||
|
.section-header { display: flex; align-items: center; gap: 8px; padding: 0.55rem 0.875rem; background: var(--text); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||||
|
.section-header .emoji { font-size: 15px; }
|
||||||
|
.section-header .est { margin-left: auto; font-family: var(--mono); font-size: 11px; opacity: 0.6; }
|
||||||
|
.section-header .sec-add { background: rgba(255,255,255,0.18); border: none; color: white; width: 22px; height: 22px; border-radius: 50%; font-size: 15px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: background 0.15s; }
|
||||||
|
.section-header .sec-add:hover { background: rgba(255,255,255,0.32); }
|
||||||
|
.section-header .sec-del { background: none; border: none; color: rgba(255,255,255,0.4); font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; flex-shrink: 0; transition: color 0.15s; }
|
||||||
|
.section-header .sec-del:hover { color: rgba(255,255,255,0.9); }
|
||||||
|
.section-header.custom-sec { background: var(--accent); }
|
||||||
|
|
||||||
|
.items-table { background: var(--card); border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px; overflow: hidden; }
|
||||||
|
.item-row { display: grid; grid-template-columns: 28px 1fr 56px 82px 86px; align-items: center; border-bottom: 1px solid var(--border); transition: background 0.15s; }
|
||||||
|
.item-row:last-child { border-bottom: none; }
|
||||||
|
.item-row:hover { background: #faf9f7; }
|
||||||
|
.item-row.checked { opacity: 0.4; }
|
||||||
|
.item-row.checked .item-name { text-decoration: line-through; }
|
||||||
|
.item-check { display: flex; align-items: center; justify-content: center; padding: 0.75rem 0 0.75rem 0.875rem; }
|
||||||
|
.item-check input[type=checkbox] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.item-name { padding: 0.7rem 0.5rem; font-size: 14px; }
|
||||||
|
.item-name .note { font-size: 11px; color: var(--muted); display: block; margin-top: 1px; }
|
||||||
|
.badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 3px; margin-left: 5px; vertical-align: middle; font-weight: 500; }
|
||||||
|
.badge-card { background: var(--blue-light); color: var(--blue); }
|
||||||
|
.badge-kasse { background: var(--amber-light); color: var(--amber); }
|
||||||
|
.item-qty { padding: 0.7rem 0.5rem; font-size: 11px; color: var(--muted); font-family: var(--mono); text-align: center; }
|
||||||
|
.item-est { padding: 0.7rem 0.5rem; font-size: 12px; font-family: var(--mono); color: var(--muted); text-align: right; }
|
||||||
|
.item-actual { padding: 0.45rem 0.75rem 0.45rem 0.5rem; }
|
||||||
|
.item-actual input { width: 100%; border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; font-size: 13px; font-family: var(--mono); background: var(--bg); color: var(--text); text-align: right; outline: none; transition: border-color 0.15s, background 0.15s; }
|
||||||
|
.item-actual input:focus { border-color: var(--accent); background: white; }
|
||||||
|
.item-actual input.has-value { background: var(--green-light); border-color: #a8d5bc; color: var(--green); font-weight: 500; }
|
||||||
|
|
||||||
|
.col-headers { display: grid; grid-template-columns: 28px 1fr 56px 82px 86px; background: #f0ece6; border: 1px solid var(--border); border-top: none; padding: 3px 0; }
|
||||||
|
.col-h { font-size: 9px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.07em; font-weight: 500; }
|
||||||
|
.col-h:nth-child(1) { padding-left: 0.875rem; }
|
||||||
|
.col-h:nth-child(2) { padding-left: 0.5rem; }
|
||||||
|
.col-h:nth-child(3) { text-align: center; padding: 0 0.5rem; }
|
||||||
|
.col-h:nth-child(4) { text-align: right; padding: 0 0.5rem; }
|
||||||
|
.col-h:nth-child(5) { text-align: right; padding: 0 0.75rem 0 0.5rem; }
|
||||||
|
|
||||||
|
/* Extra rows within a section */
|
||||||
|
.extra-item-row { display: grid; grid-template-columns: 28px 1fr auto auto; align-items: center; border-top: 1px dashed var(--border); background: #fdfcfb; }
|
||||||
|
.extra-item-row .item-name { font-size: 13px; color: var(--text); }
|
||||||
|
.extra-item-row .eir-price { padding: 0.65rem 0.5rem; font-size: 13px; font-family: var(--mono); font-weight: 600; color: var(--accent); text-align: right; white-space: nowrap; }
|
||||||
|
.extra-item-row .eir-del { padding: 0.65rem 0.75rem 0.65rem 0; }
|
||||||
|
.extra-item-row .eir-del button { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; line-height: 1; padding: 2px 4px; }
|
||||||
|
.extra-item-row .eir-del button:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Empty section placeholder */
|
||||||
|
.empty-sec { padding: 0.875rem 1rem; font-size: 13px; color: var(--muted); font-style: italic; border-top: 1px dashed var(--border); background: #fdfcfb; }
|
||||||
|
|
||||||
|
/* Rema */
|
||||||
|
.rema-section { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.1rem; margin-bottom: 1.25rem; }
|
||||||
|
.rema-title { font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 0.75rem; }
|
||||||
|
.rema-items { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.rema-item { display: flex; align-items: center; gap: 6px; font-size: 13px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.rema-item:hover { background: #eae6e0; }
|
||||||
|
.rema-item.checked { opacity: 0.4; text-decoration: line-through; }
|
||||||
|
.rema-item input { width: 14px; height: 14px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Notes */
|
||||||
|
.notes-section { background: var(--amber-light); border: 1px solid #f0d8a0; border-radius: 8px; padding: 0.875rem 1.1rem; margin-bottom: 1.25rem; }
|
||||||
|
.notes-title { font-size: 12px; font-weight: 500; color: var(--amber); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.6rem; }
|
||||||
|
.notes-list { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.notes-list li { font-size: 13px; color: #6b4a0a; list-style: none; padding-left: 1.25rem; position: relative; }
|
||||||
|
.notes-list li::before { content: '→'; position: absolute; left: 0; color: var(--amber); }
|
||||||
|
|
||||||
|
/* Summary */
|
||||||
|
.summary-section { background: var(--text); color: white; border-radius: 12px; padding: 1.25rem; margin-bottom: 1.25rem; }
|
||||||
|
.summary-title { font-size: 12px; opacity: 0.5; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1rem; }
|
||||||
|
.summary-grid { display: grid; grid-template-columns: 1fr 90px 90px; font-size: 13px; }
|
||||||
|
.sg-h { font-size: 10px; opacity: 0.4; text-transform: uppercase; letter-spacing: 0.06em; padding: 0 0 6px; }
|
||||||
|
.sg-h.num { text-align: right; }
|
||||||
|
.sg-label { padding: 4px 0; opacity: 0.75; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||||
|
.sg-est { padding: 4px 0; text-align: right; font-family: var(--mono); opacity: 0.5; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||||
|
.sg-actual { padding: 4px 0; text-align: right; font-family: var(--mono); color: #7de8b0; border-top: 1px solid rgba(255,255,255,0.08); }
|
||||||
|
.sg-total { padding: 8px 0 0; font-weight: 600; font-size: 14px; border-top: 2px solid rgba(255,255,255,0.2); margin-top: 3px; }
|
||||||
|
.sg-total-est { padding: 8px 0 0; text-align: right; font-family: var(--mono); font-weight: 600; font-size: 14px; border-top: 2px solid rgba(255,255,255,0.2); margin-top: 3px; }
|
||||||
|
.sg-total-actual { padding: 8px 0 0; text-align: right; font-family: var(--mono); font-weight: 600; font-size: 14px; color: #7de8b0; border-top: 2px solid rgba(255,255,255,0.2); margin-top: 3px; }
|
||||||
|
|
||||||
|
footer { text-align: center; font-size: 11px; color: var(--muted); padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* FAB */
|
||||||
|
.fab { position: fixed; bottom: 1.5rem; right: 1.5rem; background: var(--accent); color: white; border: none; border-radius: 28px; padding: 0 20px; height: 52px; font-size: 14px; font-weight: 600; font-family: var(--sans); cursor: pointer; box-shadow: 0 4px 16px rgba(200,75,47,0.35); display: flex; align-items: center; gap: 8px; z-index: 100; transition: transform 0.15s; }
|
||||||
|
.fab:hover { transform: scale(1.04); }
|
||||||
|
.fab .fab-icon { font-size: 20px; font-weight: 300; }
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: flex-end; justify-content: center; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: white; border-radius: 16px 16px 0 0; padding: 1.5rem 1.25rem 2rem; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; }
|
||||||
|
.modal-title { font-size: 18px; font-weight: 600; margin-bottom: 1.25rem; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--muted); padding: 0; line-height: 1; flex-shrink: 0; }
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
.form-group label { display: block; font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin-bottom: 5px; }
|
||||||
|
.form-group input, .form-group textarea { width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; font-size: 15px; font-family: var(--sans); background: var(--bg); color: var(--text); outline: none; transition: border-color 0.15s; }
|
||||||
|
.form-group input:focus, .form-group textarea:focus { border-color: var(--accent); background: white; }
|
||||||
|
.form-group .emoji-input { font-size: 22px; width: 60px; text-align: center; padding: 8px; }
|
||||||
|
.photo-preview { margin-top: 8px; max-width: 100px; max-height: 100px; border-radius: 8px; display: none; border: 1px solid var(--border); }
|
||||||
|
.btn-submit { width: 100%; background: var(--accent); color: white; border: none; border-radius: 10px; padding: 14px; font-size: 16px; font-weight: 600; font-family: var(--sans); cursor: pointer; margin-top: 0.5rem; }
|
||||||
|
.btn-submit:active { opacity: 0.85; }
|
||||||
|
.modal-context { background: var(--accent-light); border-radius: 8px; padding: 8px 12px; font-size: 13px; color: var(--accent); font-weight: 500; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.saving-indicator { position: fixed; top: 0.75rem; right: 0.75rem; background: var(--green); color: white; border-radius: 6px; padding: 4px 10px; font-size: 12px; font-family: var(--mono); opacity: 0; transition: opacity 0.3s; z-index: 50; pointer-events: none; }
|
||||||
|
.saving-indicator.show { opacity: 1; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
header { grid-template-columns: 1fr; }
|
||||||
|
.header-actions { flex-direction: row; align-items: center; }
|
||||||
|
.item-row { grid-template-columns: 28px 1fr 50px 78px; }
|
||||||
|
.item-est { display: none; }
|
||||||
|
.col-headers { grid-template-columns: 28px 1fr 50px 78px; }
|
||||||
|
.col-h:nth-child(4) { display: none; }
|
||||||
|
.extra-item-row { grid-template-columns: 28px 1fr auto auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<div class="saving-indicator" id="save-indicator">Gemt ✓</div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Citti <span>Indkøbsseddel</span></h1>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span><strong>Uge 23–25</strong> · 03.06–23.06.2026</span>
|
||||||
|
<span>Man–lør 08–20</span>
|
||||||
|
<span>Langberger Weg 4, Flensburg</span>
|
||||||
|
<span>1 EUR ≈ 7,46 DKK</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-cat" onclick="openCatModal()">+ Kategori</button>
|
||||||
|
<a href="/analyse" class="btn">📊 Analyse</a>
|
||||||
|
</div>
|
||||||
|
<div class="savings-card">
|
||||||
|
<div class="label">Estimeret besparelse</div>
|
||||||
|
<div class="amount" id="total-savings">~1.800</div>
|
||||||
|
<div class="sublabel">kr. vs. dansk pris</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="s-label">Aftensmadsdage</div>
|
||||||
|
<div class="s-val">~40</div>
|
||||||
|
<div class="s-sub">fra én tur</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="s-label">Kødportioner</div>
|
||||||
|
<div class="s-val">24</div>
|
||||||
|
<div class="s-sub">à 500g i fryseren</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="s-label">Estimeret total</div>
|
||||||
|
<div class="s-val" style="font-size:1.15rem">~4.400</div>
|
||||||
|
<div class="s-sub">kr. inkl. Rema</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="s-label">Faktisk betalt</div>
|
||||||
|
<div class="s-val" style="font-size:1.15rem" id="stat-actual">—</div>
|
||||||
|
<div class="s-sub">kr. hos Citti</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FASTE SEKTIONER -->
|
||||||
|
{% for sec in sections %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="emoji">{{ sec.emoji }}</span> {{ sec.title }}
|
||||||
|
<span class="est">est. ~{{ "{:,.0f}".format(sec.est).replace(",",".") }} kr.</span>
|
||||||
|
<button class="sec-add" onclick="openItemModal('{{ sec.id }}', '{{ sec.emoji }} {{ sec.title }}')" title="Tilføj vare">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-headers">
|
||||||
|
<div class="col-h"></div>
|
||||||
|
<div class="col-h">Vare</div>
|
||||||
|
<div class="col-h">Antal</div>
|
||||||
|
<div class="col-h">Estimat</div>
|
||||||
|
<div class="col-h">Faktisk</div>
|
||||||
|
</div>
|
||||||
|
<div class="items-table" id="sec-{{ sec.id }}">
|
||||||
|
{% for item in sec.varer %}
|
||||||
|
{% set s = saved.get(item.key, {}) %}
|
||||||
|
<div class="item-row{% if s.get('checked') %} checked{% endif %}" data-key="{{ item.key }}" data-est="{{ item.est }}">
|
||||||
|
<div class="item-check"><input type="checkbox" {% if s.get('checked') %}checked{% endif %} onchange="toggleRow(this)"></div>
|
||||||
|
<div class="item-name">
|
||||||
|
{{ item.name }}
|
||||||
|
{% if item.get('badges') %}{% for b in item.badges %}{% if b == 'card' %}<span class="badge badge-card">CITTI CARD</span>{% endif %}{% if b == 'kasse' %}<span class="badge badge-kasse">Hel kasse</span>{% endif %}{% endfor %}{% endif %}
|
||||||
|
{% if item.get('note') %}<span class="note">{{ item.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="item-qty">{{ item.qty }}</div>
|
||||||
|
<div class="item-est">{{ "{:.2f}".format(item.est).replace(".",",") if item.est else "—" }}</div>
|
||||||
|
<div class="item-actual">
|
||||||
|
<input type="number" placeholder="—" value="{{ s.get('price') or '' }}" oninput="updateActual(this)" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for e in by_sec.get(sec.id, []) %}
|
||||||
|
<div class="extra-item-row" id="extra-{{ e.id }}">
|
||||||
|
<div></div>
|
||||||
|
<div class="item-name">
|
||||||
|
{{ e.name }}
|
||||||
|
{% if e.note %}<span class="note">{{ e.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="eir-price">{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}</div>
|
||||||
|
<div class="eir-del"><button onclick="deleteExtra({{ e.id }}, this)" title="Slet">×</button></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- DYNAMISKE SEKTIONER -->
|
||||||
|
<div id="custom-sections">
|
||||||
|
{% for cs in custom_secs %}
|
||||||
|
{% set sk = "custom-" + cs.id|string %}
|
||||||
|
<div class="section" id="csec-{{ cs.id }}">
|
||||||
|
<div class="section-header custom-sec">
|
||||||
|
<span class="emoji">{{ cs.emoji }}</span> {{ cs.title }}
|
||||||
|
<span class="est">{{ by_sec.get(sk, [])|length }} vare{{ 'r' if by_sec.get(sk, [])|length != 1 else '' }}</span>
|
||||||
|
<button class="sec-add" onclick="openItemModal('{{ sk }}', '{{ cs.emoji }} {{ cs.title }}')" title="Tilføj vare">+</button>
|
||||||
|
<button class="sec-del" onclick="deleteCategory({{ cs.id }})" title="Slet kategori">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="items-table" id="sec-{{ sk }}">
|
||||||
|
{% set sk_extras = by_sec.get(sk, []) %}
|
||||||
|
{% if sk_extras %}
|
||||||
|
{% for e in sk_extras %}
|
||||||
|
<div class="extra-item-row" id="extra-{{ e.id }}">
|
||||||
|
<div></div>
|
||||||
|
<div class="item-name">{{ e.name }}{% if e.note %}<span class="note">{{ e.note }}</span>{% endif %}</div>
|
||||||
|
<div class="eir-price">{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}</div>
|
||||||
|
<div class="eir-del"><button onclick="deleteExtra({{ e.id }}, this)" title="Slet">×</button></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-sec">Ingen varer endnu — tryk + for at tilføje</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REMA -->
|
||||||
|
<div class="rema-section">
|
||||||
|
<div class="rema-title">🛒 Køb i Rema1000 / Netto</div>
|
||||||
|
<div class="rema-items">
|
||||||
|
{% for item in ["Frisk frugt & grøntsager","Løg & hvidløg","Kartofler","Mælk, æg, smør","Brød & pålæg","Mel, gær, bagepulver","Vaniljesukker"] %}
|
||||||
|
<label class="rema-item"><input type="checkbox" onchange="this.closest('.rema-item').classList.toggle('checked')"> {{ item }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EKSTRA VARER (ingen kategori) -->
|
||||||
|
{% set loose = by_sec.get('', []) %}
|
||||||
|
{% if loose %}
|
||||||
|
<div class="section" id="sec-loose">
|
||||||
|
<div class="section-header" style="background:#5a5550">
|
||||||
|
<span class="emoji">📦</span> Diverse — uden kategori
|
||||||
|
<span class="est">{{ loose|length }} vare{{ 'r' if loose|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="items-table" id="sec-table-loose">
|
||||||
|
{% for e in loose %}
|
||||||
|
<div class="extra-item-row" id="extra-{{ e.id }}">
|
||||||
|
<div></div>
|
||||||
|
<div class="item-name">{{ e.name }}{% if e.note %}<span class="note">{{ e.note }}</span>{% endif %}</div>
|
||||||
|
<div class="eir-price">{% if e.price %}{{ "{:.2f}".format(e.price).replace(".",",") }}{% else %}—{% endif %}</div>
|
||||||
|
<div class="eir-del"><button onclick="deleteExtra({{ e.id }}, this)" title="Slet">×</button></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- NOTER -->
|
||||||
|
<div class="notes-section">
|
||||||
|
<div class="notes-title">⚠ Noter</div>
|
||||||
|
<ul class="notes-list">
|
||||||
|
<li>Varer med "Hel kasse" sælges kun i hele kasser</li>
|
||||||
|
<li>CITTI CARD er gratis — hent ved kassen</li>
|
||||||
|
<li>Hakket oksekød: spørg i køddisken</li>
|
||||||
|
<li>Karrypasta: tjek den asiatiske afdeling</li>
|
||||||
|
<li>Husk fryseposer + marker hjemmefra — 500g portioner</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SAMMENFATNING -->
|
||||||
|
<div class="summary-section">
|
||||||
|
<div class="summary-title">Prissammenfatning</div>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="sg-h">Kategori</div><div class="sg-h num">Estimat</div><div class="sg-h num">Faktisk</div>
|
||||||
|
{% set sec_ids = ['koed','frost','mejeri','pasta','groent','konserves','indisk','mex','drikke','hus'] %}
|
||||||
|
{% set sec_labels = ['Kød & fisk','Frostgrønt','Mejeri & frost','Pasta (frisk)','Grønt & tørvarer','Konserves','Indisk & karry','Mexicansk & asiatisk','Drikkevarer','Husholdning'] %}
|
||||||
|
{% set sec_ests = [1580,100,185,160,245,185,160,185,530,535] %}
|
||||||
|
{% for i in range(sec_ids|length) %}
|
||||||
|
<div class="sg-label">{{ sec_labels[i] }}</div>
|
||||||
|
<div class="sg-est">~{{ sec_ests[i] }}</div>
|
||||||
|
<div class="sg-actual" id="s-{{ sec_ids[i] }}">—</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="sg-total">Total Citti</div><div class="sg-total-est">~4.065</div><div class="sg-total-actual" id="s-total-citti">—</div>
|
||||||
|
<div class="sg-label" style="opacity:0.5">Rema1000 supplement</div><div class="sg-est">~350</div><div class="sg-actual">—</div>
|
||||||
|
<div class="sg-total">Samlet total</div><div class="sg-total-est">~4.400</div><div class="sg-total-actual" id="s-total-all">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>Citti Flensborg · Langberger Weg 4 · Tilbud 03.06–23.06.2026</footer>
|
||||||
|
|
||||||
|
</div><!-- /page -->
|
||||||
|
|
||||||
|
<!-- FAB: tilføj item uden kategori -->
|
||||||
|
<button class="fab" onclick="openItemModal('', 'Tilføj vare')" title="Tilføj vare">
|
||||||
|
<span class="fab-icon">+</span> Tilføj vare
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ITEM MODAL -->
|
||||||
|
<div class="modal-overlay" id="item-modal" onclick="if(event.target===this)closeItemModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">
|
||||||
|
<span id="item-modal-title">Tilføj vare</span>
|
||||||
|
<button class="modal-close" onclick="closeItemModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-context" id="item-modal-ctx" style="display:none"></div>
|
||||||
|
<form id="add-form" onsubmit="submitItem(event)">
|
||||||
|
<input type="hidden" name="section_key" id="item-section-key">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Varenavn *</label>
|
||||||
|
<input type="text" name="name" placeholder="fx. Parmigiano Reggiano 1kg" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pris (DKK)</label>
|
||||||
|
<input type="text" name="price" placeholder="fx. 89,99" inputmode="decimal">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Note</label>
|
||||||
|
<input type="text" name="note" placeholder="fx. 'billigere end DK'" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Foto</label>
|
||||||
|
<input type="file" name="photo" accept="image/*" capture="environment" onchange="previewPhoto(this)">
|
||||||
|
<img id="photo-preview" class="photo-preview" alt="preview">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-submit">Tilføj vare</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KATEGORI MODAL -->
|
||||||
|
<div class="modal-overlay" id="cat-modal" onclick="if(event.target===this)closeCatModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">
|
||||||
|
Tilføj kategori
|
||||||
|
<button class="modal-close" onclick="closeCatModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="cat-form" onsubmit="submitCategory(event)">
|
||||||
|
<div class="form-group" style="display:flex;gap:12px;align-items:flex-end">
|
||||||
|
<div style="flex-shrink:0">
|
||||||
|
<label>Emoji</label>
|
||||||
|
<input class="emoji-input" type="text" name="emoji" value="📦" maxlength="2">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1">
|
||||||
|
<label>Kategorinavn *</label>
|
||||||
|
<input type="text" name="title" placeholder="fx. Ost & delikatesser" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-submit">Opret kategori</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const SEC_IDS = ['koed','frost','mejeri','pasta','groent','konserves','indisk','mex','drikke','hus'];
|
||||||
|
const DK_SAVINGS_RATIO = 0.35;
|
||||||
|
let saveTimer = null;
|
||||||
|
|
||||||
|
function showSaved() {
|
||||||
|
const el = document.getElementById('save-indicator');
|
||||||
|
el.classList.add('show');
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => el.classList.remove('show'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(cb) {
|
||||||
|
const row = cb.closest('.item-row');
|
||||||
|
row.classList.toggle('checked', cb.checked);
|
||||||
|
const price = parseFloat(row.querySelector('.item-actual input').value) || null;
|
||||||
|
saveToServer(row.dataset.key, price, cb.checked ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimers = {};
|
||||||
|
function updateActual(input) {
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
input.classList.toggle('has-value', !isNaN(val) && val > 0);
|
||||||
|
const row = input.closest('.item-row');
|
||||||
|
const key = row.dataset.key;
|
||||||
|
const checked = row.querySelector('input[type=checkbox]').checked ? 1 : 0;
|
||||||
|
clearTimeout(debounceTimers[key]);
|
||||||
|
debounceTimers[key] = setTimeout(() => saveToServer(key, isNaN(val) ? null : val, checked), 600);
|
||||||
|
recalcAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToServer(key, price, checked) {
|
||||||
|
fetch('/api/price', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({key, price, checked})
|
||||||
|
}).then(() => showSaved());
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcAll() {
|
||||||
|
const sections = document.querySelectorAll('.items-table');
|
||||||
|
let totalActual = 0; let hasAny = false;
|
||||||
|
sections.forEach((sec, i) => {
|
||||||
|
const inputs = sec.querySelectorAll('.item-row .item-actual input');
|
||||||
|
let s = 0; let has = false;
|
||||||
|
inputs.forEach(inp => { const v = parseFloat(inp.value); if (!isNaN(v) && v > 0) { s += v; has = true; }});
|
||||||
|
const el = document.getElementById('s-' + SEC_IDS[i]);
|
||||||
|
if (el) el.textContent = has ? Math.round(s).toLocaleString('da-DK') : '—';
|
||||||
|
if (has) { totalActual += s; hasAny = true; }
|
||||||
|
});
|
||||||
|
if (hasAny) {
|
||||||
|
document.getElementById('s-total-citti').textContent = Math.round(totalActual).toLocaleString('da-DK');
|
||||||
|
document.getElementById('s-total-all').textContent = Math.round(totalActual + 350).toLocaleString('da-DK');
|
||||||
|
document.getElementById('stat-actual').textContent = Math.round(totalActual).toLocaleString('da-DK');
|
||||||
|
document.getElementById('total-savings').textContent = Math.round(totalActual * DK_SAVINGS_RATIO).toLocaleString('da-DK');
|
||||||
|
} else {
|
||||||
|
['s-total-citti','s-total-all','stat-actual'].forEach(id => document.getElementById(id).textContent = '—');
|
||||||
|
document.getElementById('total-savings').textContent = '~1.800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item modal
|
||||||
|
let activeSectionKey = '';
|
||||||
|
|
||||||
|
function openItemModal(sectionKey, sectionTitle) {
|
||||||
|
activeSectionKey = sectionKey || '';
|
||||||
|
document.getElementById('item-section-key').value = activeSectionKey;
|
||||||
|
document.getElementById('item-modal-title').textContent = sectionKey ? `Tilføj til ${sectionTitle}` : 'Tilføj vare';
|
||||||
|
const ctx = document.getElementById('item-modal-ctx');
|
||||||
|
if (sectionKey) {
|
||||||
|
ctx.textContent = sectionTitle;
|
||||||
|
ctx.style.display = '';
|
||||||
|
} else {
|
||||||
|
ctx.style.display = 'none';
|
||||||
|
}
|
||||||
|
document.getElementById('item-modal').classList.add('open');
|
||||||
|
setTimeout(() => document.querySelector('#add-form input[name=name]').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeItemModal() {
|
||||||
|
document.getElementById('item-modal').classList.remove('open');
|
||||||
|
document.getElementById('add-form').reset();
|
||||||
|
document.getElementById('photo-preview').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPhoto(input) {
|
||||||
|
const p = document.getElementById('photo-preview');
|
||||||
|
if (input.files && input.files[0]) { p.src = URL.createObjectURL(input.files[0]); p.style.display = 'block'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitItem(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const btn = form.querySelector('button[type=submit]');
|
||||||
|
btn.textContent = 'Gemmer...'; btn.disabled = true;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const res = await fetch('/api/item', {method: 'POST', body: fd});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
appendExtraToSection(data);
|
||||||
|
closeItemModal();
|
||||||
|
}
|
||||||
|
btn.textContent = 'Tilføj vare'; btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendExtraToSection(data) {
|
||||||
|
const sk = data.section_key || '';
|
||||||
|
const tableId = sk ? `sec-${sk}` : 'sec-table-loose';
|
||||||
|
let table = document.getElementById(tableId);
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
if (sk) return; // section not rendered yet (shouldn't happen)
|
||||||
|
// create loose section
|
||||||
|
const sec = document.createElement('div');
|
||||||
|
sec.className = 'section'; sec.id = 'sec-loose';
|
||||||
|
sec.innerHTML = `<div class="section-header" style="background:#5a5550"><span class="emoji">📦</span> Diverse — uden kategori<span class="est">0 varer</span></div><div class="items-table" id="sec-table-loose"></div>`;
|
||||||
|
document.querySelector('.notes-section').before(sec);
|
||||||
|
table = document.getElementById('sec-table-loose');
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove empty placeholder
|
||||||
|
const ph = table.querySelector('.empty-sec');
|
||||||
|
if (ph) ph.remove();
|
||||||
|
|
||||||
|
const priceStr = data.price ? data.price.toFixed(2).replace('.', ',') : '—';
|
||||||
|
const noteHtml = data.note ? `<span class="note">${data.note}</span>` : '';
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'extra-item-row'; row.id = `extra-${data.id}`;
|
||||||
|
row.innerHTML = `<div></div><div class="item-name">${data.name}${noteHtml}</div><div class="eir-price">${priceStr}</div><div class="eir-del"><button onclick="deleteExtra(${data.id}, this)" title="Slet">×</button></div>`;
|
||||||
|
table.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteExtra(id, btn) {
|
||||||
|
if (!confirm('Slet denne vare?')) return;
|
||||||
|
await fetch(`/api/item/${id}`, {method: 'DELETE'});
|
||||||
|
document.getElementById(`extra-${id}`).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category modal
|
||||||
|
function openCatModal() {
|
||||||
|
document.getElementById('cat-modal').classList.add('open');
|
||||||
|
setTimeout(() => document.querySelector('#cat-form input[name=title]').focus(), 100);
|
||||||
|
}
|
||||||
|
function closeCatModal() {
|
||||||
|
document.getElementById('cat-modal').classList.remove('open');
|
||||||
|
document.getElementById('cat-form').reset();
|
||||||
|
document.querySelector('#cat-form input[name=emoji]').value = '📦';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCategory(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const btn = form.querySelector('button[type=submit]');
|
||||||
|
btn.textContent = 'Opretter...'; btn.disabled = true;
|
||||||
|
const emoji = form.emoji.value.trim() || '📦';
|
||||||
|
const title = form.title.value.trim();
|
||||||
|
const res = await fetch('/api/category', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({emoji, title})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
appendCustomSection(data);
|
||||||
|
closeCatModal();
|
||||||
|
}
|
||||||
|
btn.textContent = 'Opret kategori'; btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCustomSection(data) {
|
||||||
|
const container = document.getElementById('custom-sections');
|
||||||
|
const sec = document.createElement('div');
|
||||||
|
sec.className = 'section'; sec.id = `csec-${data.id}`;
|
||||||
|
sec.innerHTML = `
|
||||||
|
<div class="section-header custom-sec">
|
||||||
|
<span class="emoji">${data.emoji}</span> ${data.title}
|
||||||
|
<span class="est">0 varer</span>
|
||||||
|
<button class="sec-add" onclick="openItemModal('${data.key}', '${data.emoji} ${data.title}')" title="Tilføj vare">+</button>
|
||||||
|
<button class="sec-del" onclick="deleteCategory(${data.id})" title="Slet kategori">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="items-table" id="sec-${data.key}">
|
||||||
|
<div class="empty-sec">Ingen varer endnu — tryk + for at tilføje</div>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id) {
|
||||||
|
if (!confirm('Slet kategori og alle dens varer?')) return;
|
||||||
|
await fetch(`/api/category/${id}`, {method: 'DELETE'});
|
||||||
|
document.getElementById(`csec-${id}`).remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
recalcAll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user