From 9f42e46d6c9da33d244261eaa9e793e6ea1d9664 Mon Sep 17 00:00:00 2001 From: rogalik27 Date: Thu, 25 Jun 2026 09:18:28 +0200 Subject: [PATCH] Back office: add / edit / delete for Leads & Clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Token-protected mutation endpoints (POST/PATCH/DELETE), audit-logged to activity_log. Token injected into the basic-auth-gated dashboard. - Add auto-generates IDs (next C-#### / L-), sets created/received/status defaults, and computes client renewal_date from start + billing cycle (parity with the onboarding workflow). - Dashboard: per-row edit (✎) and delete (🗑), "+ Neu" modal form per entity. Verified end-to-end: add lead/client, edit, delete, renewal compute, token gating (403), 404s. DB-only for now; DB->Sheets mirror is the next step. Co-Authored-By: Claude Opus 4.8 --- backoffice/app/app.py | 125 +++++++++++++++++++++++++++- backoffice/app/static/index.html | 138 +++++++++++++++++++++++++++++-- 2 files changed, 254 insertions(+), 9 deletions(-) diff --git a/backoffice/app/app.py b/backoffice/app/app.py index aa5d186..52616ad 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -6,16 +6,36 @@ Browser access is gated by Caddy basic-auth on onboard.mivanchenko.de; the machine-to-machine ingest path (n8n) is gated by the CRM_API_TOKEN header. """ import os +import re +import time from datetime import datetime, date from decimal import Decimal -from flask import Flask, jsonify, request, Response, send_from_directory +from flask import Flask, jsonify, request, Response from waitress import serve import db app = Flask(__name__, static_folder="static", static_url_path="") +CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") + +# Static dashboard, with the CRM token injected so the (basic-auth-gated) +# operator page can call the token-protected mutation endpoints. +with open(os.path.join(os.path.dirname(__file__), "static", "index.html")) as _f: + INDEX_HTML = _f.read().replace("__CRM_TOKEN__", CRM_TOKEN) + + +def authed(): + return bool(CRM_TOKEN) and request.headers.get("X-CRM-Token") == CRM_TOKEN + + +def log_activity(cur, client_id, action, detail, result="ok"): + cur.execute( + "INSERT INTO activity_log (ts, workflow, client_id, action, detail, result) " + "VALUES (now(), %s, %s, %s, %s, %s)", + ("back-office", client_id, action, detail, result)) + LIST_ORDER = { "leads": "received_at DESC NULLS LAST", "clients": "client_id", @@ -60,9 +80,110 @@ def list_entity(entity): return jsonify({"entity": entity, "count": len(rows), "rows": rows_json(rows)}) +def compute_renewal(start, cycle): + """Mirror the onboarding workflow: monthly -> +1 month, yearly -> +1 year.""" + cycle = (cycle or "monthly").lower() + if cycle == "monthly": + m = start.month % 12 + 1 + y = start.year + (1 if start.month == 12 else 0) + d = min(start.day, [31, 29 if y % 4 == 0 and (y % 100 or not y % 400) else 28, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1]) + return date(y, m, d) + if cycle == "yearly": + return date(start.year + 1, start.month, start.day) + return None + + +def next_client_id(cur): + cur.execute("SELECT client_id FROM clients") + mx = 0 + for r in cur.fetchall(): + m = re.match(r"C-(\d+)$", r["client_id"] or "") + if m: + mx = max(mx, int(m.group(1))) + return "C-%04d" % (mx + 1) + + +@app.post("/api/") +def add_entity(entity): + if entity not in db.TABLES: + return jsonify({"error": "unknown entity"}), 404 + if not authed(): + return jsonify({"error": "forbidden"}), 403 + spec = db.TABLES[entity] + pk = spec["cols"][0] if entity == "activity_log" else spec["pk"] + body = request.get_json(force=True, silent=True) or {} + row = db.coerce_row(entity, body) + with db.connect() as conn, conn.cursor() as cur: + if entity == "leads": + row["lead_id"] = row.get("lead_id") or "L-" + str(int(time.time() * 1000)) + row["received_at"] = row.get("received_at") or datetime.utcnow() + row["status"] = row.get("status") or "new" + if row.get("notified") is None: + row["notified"] = False + elif entity == "clients": + row["client_id"] = row.get("client_id") or next_client_id(cur) + row["created_at"] = row.get("created_at") or datetime.utcnow() + row["status"] = row.get("status") or "lead" + if not row.get("renewal_date") and row.get("start_date"): + row["renewal_date"] = compute_renewal( + row["start_date"], row.get("billing_cycle")) + elif not row.get(pk): + return jsonify({"error": f"{pk} required"}), 400 + cols = spec["cols"] + ph = ", ".join(["%s"] * len(cols)) + cur.execute(f"INSERT INTO {entity} ({', '.join(cols)}) VALUES ({ph})", + [row.get(c) for c in cols]) + log_activity(cur, row.get("client_id"), f"add {entity}", f"{pk}={row.get(pk)}") + conn.commit() + return jsonify({"added": row.get(pk)}), 201 + + +@app.patch("/api//") +def edit_entity(entity, ident): + if entity not in db.TABLES: + return jsonify({"error": "unknown entity"}), 404 + if not authed(): + return jsonify({"error": "forbidden"}), 403 + spec = db.TABLES[entity] + pk = spec["pk"] + body = request.get_json(force=True, silent=True) or {} + setcols = [c for c in body if c in spec["cols"] and c != pk] + if not setcols: + return jsonify({"error": "no editable fields"}), 400 + typed = db.coerce_row(entity, body) + setsql = ", ".join(f"{c} = %s" for c in setcols) + vals = [typed[c] for c in setcols] + [ident] + with db.connect() as conn, conn.cursor() as cur: + cur.execute(f"UPDATE {entity} SET {setsql} WHERE {pk} = %s RETURNING {pk}", vals) + if not cur.fetchone(): + return jsonify({"error": "not found"}), 404 + log_activity(cur, ident if entity == "clients" else None, + f"edit {entity}", f"{pk}={ident}: {', '.join(setcols)}") + conn.commit() + return jsonify({"updated": ident, "fields": setcols}) + + +@app.delete("/api//") +def delete_entity(entity, ident): + if entity not in db.TABLES: + return jsonify({"error": "unknown entity"}), 404 + if not authed(): + return jsonify({"error": "forbidden"}), 403 + pk = db.TABLES[entity]["pk"] + with db.connect() as conn, conn.cursor() as cur: + cur.execute(f"DELETE FROM {entity} WHERE {pk} = %s RETURNING {pk}", (ident,)) + if not cur.fetchone(): + return jsonify({"error": "not found"}), 404 + log_activity(cur, ident if entity == "clients" else None, + f"delete {entity}", f"{pk}={ident}") + conn.commit() + return jsonify({"deleted": ident}) + + @app.get("/") def index(): - return send_from_directory(app.static_folder, "index.html") + return Response(INDEX_HTML, mimetype="text/html") if __name__ == "__main__": diff --git a/backoffice/app/static/index.html b/backoffice/app/static/index.html index ad27ffc..e629ade 100644 --- a/backoffice/app/static/index.html +++ b/backoffice/app/static/index.html @@ -41,6 +41,27 @@ .pillv { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600; background: var(--teal-soft); color: var(--teal-2); } .empty { padding: 40px; text-align: center; color: var(--muted); } + .del { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; + padding: 3px 8px; font-size: .9rem; } + .del:hover { background: #fdecec; border-color: #f3c7c7; } + .act { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; + padding: 3px 8px; font-size: .9rem; margin-right: 4px; } + .act:hover { background: var(--teal-soft); border-color: var(--teal); } + .overlay { position: fixed; inset: 0; background: rgba(8,20,19,.5); display: none; + align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 20; overflow: auto; } + .overlay.open { display: flex; } + .modal { background: var(--surface); border-radius: 14px; width: min(620px, 100%); + box-shadow: 0 24px 60px rgba(0,0,0,.3); padding: 24px 26px; } + .modal h2 { margin: 0 0 16px; font-size: 1.15rem; } + .modal .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; } + .modal label { display: block; font-size: .78rem; color: var(--muted); font-weight: 600; margin-bottom: 4px; } + .modal .f { margin-bottom: 2px; } + .modal .f.wide { grid-column: 1 / -1; } + .modal input, .modal select, .modal textarea { width: 100%; padding: 9px 11px; font: inherit; + font-size: .88rem; border: 1px solid var(--line); border-radius: 8px; background: #fbfdfc; } + .modal textarea { resize: vertical; min-height: 52px; } + .modal .foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } + .modal .pk { font-size: .76rem; color: var(--muted); margin-bottom: 14px; } .msg { padding: 10px 14px; border-radius: 9px; font-size: .85rem; margin-bottom: 12px; display: none; } .msg.err { background: #fdecec; color: #9b2226; border: 1px solid #f3c7c7; display: block; } @@ -57,18 +78,33 @@
+
Lädt …
+
+ +
+