All checks were successful
Build and Deploy citti / build-and-deploy (push) Successful in 26s
Each item row now has an editable qty input showing the planned amount (e.g. "6 × ½ kg"). Changes are auto-saved via debounce to the prices table and highlighted in accent color when modified from the default. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
627 lines
33 KiB
HTML
627 lines
33 KiB
HTML
<!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 72px 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.35rem 0.4rem; }
|
||
.item-qty input { width: 100%; border: 1px solid transparent; border-radius: 5px; padding: 4px 5px; font-size: 11px; font-family: var(--mono); background: transparent; color: var(--muted); text-align: center; outline: none; transition: border-color 0.15s, background 0.15s; cursor: text; }
|
||
.item-qty input:hover { border-color: var(--border); background: var(--bg); }
|
||
.item-qty input:focus { border-color: var(--accent); background: white; color: var(--text); }
|
||
.item-qty input.modified { color: var(--accent); font-weight: 500; }
|
||
.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 72px 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 64px 78px; }
|
||
.item-est { display: none; }
|
||
.col-headers { grid-template-columns: 28px 1fr 64px 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">
|
||
<input type="text" value="{{ s.get('qty') or item.qty }}" placeholder="{{ item.qty }}"
|
||
oninput="updateQty(this)" onchange="updateQty(this)"
|
||
{% if s.get('qty') and s.get('qty') != item.qty %}class="modified"{% endif %}>
|
||
</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) {
|
||
const row = document.querySelector(`.item-row[data-key="${key}"]`);
|
||
const qtyInput = row ? row.querySelector('.item-qty input') : null;
|
||
const qty = qtyInput ? qtyInput.value.trim() || null : null;
|
||
fetch('/api/price', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({key, price, checked, qty})
|
||
}).then(() => showSaved());
|
||
}
|
||
|
||
function updateQty(input) {
|
||
const row = input.closest('.item-row');
|
||
const key = row.dataset.key;
|
||
const defaultQty = input.placeholder;
|
||
input.classList.toggle('modified', input.value.trim() !== '' && input.value.trim() !== defaultQty);
|
||
const price = parseFloat(row.querySelector('.item-actual input').value);
|
||
const checked = row.querySelector('input[type=checkbox]').checked ? 1 : 0;
|
||
clearTimeout(debounceTimers[key + '_qty']);
|
||
debounceTimers[key + '_qty'] = setTimeout(() => saveToServer(key, isNaN(price) ? null : price, checked), 600);
|
||
}
|
||
|
||
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>
|