Back office: add / edit / delete for Leads & Clients

- 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-<epoch>), 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 09:18:28 +02:00
parent 18e346d67b
commit 9f42e46d6c
2 changed files with 254 additions and 9 deletions
+123 -2
View File
@@ -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/<entity>")
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/<entity>/<path:ident>")
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/<entity>/<path:ident>")
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__":
+131 -7
View File
@@ -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; }
</style>
@@ -57,18 +78,33 @@
</div>
<div class="bar">
<input id="search" placeholder="Filtern …" />
<button class="btn" id="add">+ Neu</button>
<button class="btn ghost" id="refresh">↻ Aktualisieren</button>
</div>
<div class="msg" id="msg"></div>
<div class="card"><div id="table"><div class="empty">Lädt …</div></div></div>
</div>
<div class="overlay" id="overlay">
<div class="modal">
<h2 id="modalTitle">Bearbeiten</h2>
<div class="pk" id="modalPk"></div>
<div class="grid" id="modalFields"></div>
<div class="foot">
<button class="btn ghost" onclick="closeForm()">Abbrechen</button>
<button class="btn" id="saveBtn">Speichern</button>
</div>
</div>
</div>
<script>
const API = 'api';
const TOKEN = '__CRM_TOKEN__';
const COLS = {
leads: ['lead_id','received_at','client_id','source','name','contact','service_interest','message','status','notified'],
clients: ['client_id','business_name','owner_name','email','phone','niche','tier','status','billing_cycle','monthly_fee_eur','renewal_date','created_at'],
};
const PK = { leads: 'lead_id', clients: 'client_id' };
let current = 'leads';
let cache = {};
@@ -98,16 +134,104 @@
const q = document.getElementById('search').value.trim().toLowerCase();
if(q) rows = rows.filter(r => cols.some(c => String(r[c]??'').toLowerCase().includes(q)));
if(!rows.length){ document.getElementById('table').innerHTML = '<div class="empty">Keine Einträge.</div>'; return; }
const head = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '</tr>';
const body = rows.map(r => '<tr>' + cols.map(c => {
let v = r[c];
if(c==='status' || c==='tier') return `<td><span class="pillv">${esc(v)}</span></td>`;
if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16);
return `<td title="${esc(v)}">${esc(v)}</td>`;
}).join('') + '</tr>').join('');
const head = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '<th></th></tr>';
const body = rows.map(r => {
const id = r[PK[current]];
const cells = cols.map(c => {
let v = r[c];
if(c==='status' || c==='tier') return `<td><span class="pillv">${esc(v)}</span></td>`;
if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16);
return `<td title="${esc(v)}">${esc(v)}</td>`;
}).join('');
return `<tr>${cells}<td style="white-space:nowrap"><button class="act" title="Bearbeiten" onclick="openForm('${esc(id)}')">✎</button><button class="del" title="Löschen" onclick="del('${esc(id)}')">🗑</button></td></tr>`;
}).join('');
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
}
async function del(id){
if(!confirm(`${current.slice(0,-1).toUpperCase()}${id}" wirklich löschen?`)) return;
clearErr();
try {
const r = await fetch(`${API}/${current}/${encodeURIComponent(id)}`, {
method: 'DELETE', headers: { 'X-CRM-Token': TOKEN } });
if(!r.ok) throw new Error('HTTP '+r.status);
cache[current] = cache[current].filter(x => x[PK[current]] !== id);
document.getElementById('n-'+current).textContent = cache[current].length;
render();
} catch(e){ showErr('Löschen fehlgeschlagen: '+e.message); }
}
const FORM = {
leads: [
{k:'name'},{k:'contact'},{k:'client_id'},{k:'source'},
{k:'service_interest'},
{k:'status',t:'select',opts:['new','contacted','qualified','won','lost']},
{k:'notified',t:'select',opts:['false','true']},
{k:'message',t:'textarea',wide:true},
],
clients: [
{k:'business_name'},{k:'owner_name'},{k:'email'},{k:'phone'},{k:'niche'},
{k:'tier',t:'select',opts:['A','B']},
{k:'status',t:'select',opts:['lead','onboarding','active','churned']},
{k:'billing_cycle',t:'select',opts:['monthly','yearly','one-off']},
{k:'monthly_fee_eur'},{k:'domain'},
{k:'start_date',t:'date'},{k:'renewal_date',t:'date'},
{k:'services',wide:true},{k:'stack_notes',t:'textarea',wide:true},
{k:'vault_ref'},{k:'notes',t:'textarea',wide:true},
],
};
let editId = null;
function fieldHtml(f, val){
const v = val==null ? '' : String(val);
const lbl = `<label>${f.k}</label>`;
let input;
if(f.t==='select') input = `<select name="${f.k}">` +
f.opts.map(o => `<option${String(o)===v?' selected':''}>${o}</option>`).join('') + '</select>';
else if(f.t==='textarea') input = `<textarea name="${f.k}">${esc(v)}</textarea>`;
else if(f.t==='date') input = `<input type="date" name="${f.k}" value="${esc(v.slice(0,10))}" />`;
else input = `<input name="${f.k}" value="${esc(v)}" />`;
return `<div class="f${f.wide?' wide':''}">${lbl}${input}</div>`;
}
function openForm(id){
editId = id || null;
const row = id ? (cache[current]||[]).find(r => r[PK[current]] === id) || {} : {};
document.getElementById('modalTitle').textContent =
(id ? 'Bearbeiten' : 'Neu') + ' · ' + current.slice(0,-1);
document.getElementById('modalPk').textContent = id ? `${PK[current]}: ${id}` : 'neuer Eintrag — ID wird vergeben';
let norm = {...row};
if('notified' in norm) norm.notified = norm.notified ? 'true' : 'false';
document.getElementById('modalFields').innerHTML =
FORM[current].map(f => fieldHtml(f, norm[f.k])).join('');
document.getElementById('overlay').classList.add('open');
}
function closeForm(){ document.getElementById('overlay').classList.remove('open'); }
async function saveForm(){
const data = {};
FORM[current].forEach(f => {
const el = document.querySelector(`#modalFields [name="${f.k}"]`);
if(el) data[f.k] = el.value;
});
const btn = document.getElementById('saveBtn');
btn.disabled = true; clearErr();
try {
const url = editId ? `${API}/${current}/${encodeURIComponent(editId)}` : `${API}/${current}`;
const r = await fetch(url, {
method: editId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json', 'X-CRM-Token': TOKEN },
body: JSON.stringify(data) });
if(!r.ok) throw new Error('HTTP '+r.status);
closeForm();
await load(current);
} catch(e){ showErr('Speichern fehlgeschlagen: '+e.message); }
finally { btn.disabled = false; }
}
document.getElementById('saveBtn').onclick = saveForm;
document.getElementById('add').onclick = () => openForm(null);
document.getElementById('overlay').onclick = e => { if(e.target.id==='overlay') closeForm(); };
document.querySelectorAll('.tab').forEach(t => t.onclick = () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');