feat(shopping): editable quantity field per item
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>
This commit is contained in:
Henrik Jess Nielsen
2026-06-06 01:18:04 +02:00
parent 17f39c9503
commit 6ce940759c
2 changed files with 38 additions and 11 deletions

13
app.py
View File

@@ -103,7 +103,7 @@ def get_db():
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)""")
key TEXT PRIMARY KEY, price REAL, checked INT DEFAULT 0, qty TEXT, updated TEXT)""")
c.execute("""CREATE TABLE IF NOT EXISTS extras (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, section_key TEXT, price REAL,
@@ -115,6 +115,10 @@ def init_db():
c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT")
except Exception:
pass
try:
c.execute("ALTER TABLE prices ADD COLUMN qty TEXT")
except Exception:
pass
@app.route('/')
@@ -136,13 +140,14 @@ def save_price():
key = d.get('key')
price = d.get('price')
checked = int(d.get('checked', 0))
qty = d.get('qty') or None
if not key:
return jsonify(error='missing key'), 400
with get_db() as c:
c.execute("""INSERT INTO prices(key,price,checked,updated) VALUES(?,?,?,?)
c.execute("""INSERT INTO prices(key,price,checked,qty,updated) VALUES(?,?,?,?,?)
ON CONFLICT(key) DO UPDATE SET price=excluded.price,
checked=excluded.checked, updated=excluded.updated""",
(key, price, checked, datetime.now().isoformat()))
checked=excluded.checked, qty=excluded.qty, updated=excluded.updated""",
(key, price, checked, qty, datetime.now().isoformat()))
return jsonify(ok=True)

View File

@@ -47,7 +47,7 @@
.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 { 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; }
@@ -59,14 +59,18 @@
.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-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 56px 82px 86px; background: #f0ece6; border: 1px solid var(--border); border-top: none; padding: 3px 0; }
.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; }
@@ -144,9 +148,9 @@
.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-row { grid-template-columns: 28px 1fr 64px 78px; }
.item-est { display: none; }
.col-headers { grid-template-columns: 28px 1fr 50px 78px; }
.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; }
}
@@ -228,7 +232,11 @@
{% 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-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">
@@ -438,13 +446,27 @@ function updateActual(input) {
}
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})
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;