Add Flask shopping app with Nomad deployment
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:
Henrik Jess Nielsen
2026-06-06 00:54:07 +02:00
parent 9ef85c327f
commit 1a3476c4d3
9 changed files with 1287 additions and 0 deletions

View 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
View File

@@ -0,0 +1,5 @@
data.db
static/uploads/
__pycache__/
*.pyc
.env

19
Dockerfile Normal file
View 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
View 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
View 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": "350500g · 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": "100111 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": "8001000g", "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
View 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
View File

@@ -0,0 +1 @@
flask>=3.0

284
templates/analyse.html Normal file
View 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 2325 · 03.0623.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
View 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 2325</strong> · 03.0623.06.2026</span>
<span>Manlør 0820</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.0623.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>