feat(shopping): editable quantity field per item
All checks were successful
Build and Deploy citti / build-and-deploy (push) Successful in 26s
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:
13
app.py
13
app.py
@@ -103,7 +103,7 @@ def get_db():
|
|||||||
def init_db():
|
def init_db():
|
||||||
with get_db() as c:
|
with get_db() as c:
|
||||||
c.execute("""CREATE TABLE IF NOT EXISTS prices (
|
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 (
|
c.execute("""CREATE TABLE IF NOT EXISTS extras (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT, section_key TEXT, price REAL,
|
name TEXT, section_key TEXT, price REAL,
|
||||||
@@ -115,6 +115,10 @@ def init_db():
|
|||||||
c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT")
|
c.execute("ALTER TABLE extras ADD COLUMN section_key TEXT")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
c.execute("ALTER TABLE prices ADD COLUMN qty TEXT")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@@ -136,13 +140,14 @@ def save_price():
|
|||||||
key = d.get('key')
|
key = d.get('key')
|
||||||
price = d.get('price')
|
price = d.get('price')
|
||||||
checked = int(d.get('checked', 0))
|
checked = int(d.get('checked', 0))
|
||||||
|
qty = d.get('qty') or None
|
||||||
if not key:
|
if not key:
|
||||||
return jsonify(error='missing key'), 400
|
return jsonify(error='missing key'), 400
|
||||||
with get_db() as c:
|
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,
|
ON CONFLICT(key) DO UPDATE SET price=excluded.price,
|
||||||
checked=excluded.checked, updated=excluded.updated""",
|
checked=excluded.checked, qty=excluded.qty, updated=excluded.updated""",
|
||||||
(key, price, checked, datetime.now().isoformat()))
|
(key, price, checked, qty, datetime.now().isoformat()))
|
||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
.section-header.custom-sec { background: var(--accent); }
|
.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; }
|
.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:last-child { border-bottom: none; }
|
||||||
.item-row:hover { background: #faf9f7; }
|
.item-row:hover { background: #faf9f7; }
|
||||||
.item-row.checked { opacity: 0.4; }
|
.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 { 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-card { background: var(--blue-light); color: var(--blue); }
|
||||||
.badge-kasse { background: var(--amber-light); color: var(--amber); }
|
.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-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 { 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 { 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: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; }
|
.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 { 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(1) { padding-left: 0.875rem; }
|
||||||
.col-h:nth-child(2) { padding-left: 0.5rem; }
|
.col-h:nth-child(2) { padding-left: 0.5rem; }
|
||||||
@@ -144,9 +148,9 @@
|
|||||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
header { grid-template-columns: 1fr; }
|
header { grid-template-columns: 1fr; }
|
||||||
.header-actions { flex-direction: row; align-items: center; }
|
.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; }
|
.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; }
|
.col-h:nth-child(4) { display: none; }
|
||||||
.extra-item-row { grid-template-columns: 28px 1fr auto auto; }
|
.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('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 %}
|
{% if item.get('note') %}<span class="note">{{ item.note }}</span>{% endif %}
|
||||||
</div>
|
</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-est">{{ "{:.2f}".format(item.est).replace(".",",") if item.est else "—" }}</div>
|
||||||
<div class="item-actual">
|
<div class="item-actual">
|
||||||
<input type="number" placeholder="—" value="{{ s.get('price') or '' }}" oninput="updateActual(this)" step="0.01" min="0">
|
<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) {
|
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', {
|
fetch('/api/price', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({key, price, checked})
|
body: JSON.stringify({key, price, checked, qty})
|
||||||
}).then(() => showSaved());
|
}).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() {
|
function recalcAll() {
|
||||||
const sections = document.querySelectorAll('.items-table');
|
const sections = document.querySelectorAll('.items-table');
|
||||||
let totalActual = 0; let hasAny = false;
|
let totalActual = 0; let hasAny = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user