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. machine-to-machine ingest path (n8n) is gated by the CRM_API_TOKEN header.
""" """
import os import os
import re
import time
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal 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 from waitress import serve
import db import db
app = Flask(__name__, static_folder="static", static_url_path="") 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 = { LIST_ORDER = {
"leads": "received_at DESC NULLS LAST", "leads": "received_at DESC NULLS LAST",
"clients": "client_id", "clients": "client_id",
@@ -60,9 +80,110 @@ def list_entity(entity):
return jsonify({"entity": entity, "count": len(rows), "rows": rows_json(rows)}) 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("/") @app.get("/")
def index(): def index():
return send_from_directory(app.static_folder, "index.html") return Response(INDEX_HTML, mimetype="text/html")
if __name__ == "__main__": if __name__ == "__main__":
+127 -3
View File
@@ -41,6 +41,27 @@
.pillv { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600; .pillv { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600;
background: var(--teal-soft); color: var(--teal-2); } background: var(--teal-soft); color: var(--teal-2); }
.empty { padding: 40px; text-align: center; color: var(--muted); } .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 { 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; } .msg.err { background: #fdecec; color: #9b2226; border: 1px solid #f3c7c7; display: block; }
</style> </style>
@@ -57,18 +78,33 @@
</div> </div>
<div class="bar"> <div class="bar">
<input id="search" placeholder="Filtern …" /> <input id="search" placeholder="Filtern …" />
<button class="btn" id="add">+ Neu</button>
<button class="btn ghost" id="refresh">↻ Aktualisieren</button> <button class="btn ghost" id="refresh">↻ Aktualisieren</button>
</div> </div>
<div class="msg" id="msg"></div> <div class="msg" id="msg"></div>
<div class="card"><div id="table"><div class="empty">Lädt …</div></div></div> <div class="card"><div id="table"><div class="empty">Lädt …</div></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> <script>
const API = 'api'; const API = 'api';
const TOKEN = '__CRM_TOKEN__';
const COLS = { const COLS = {
leads: ['lead_id','received_at','client_id','source','name','contact','service_interest','message','status','notified'], 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'], 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 current = 'leads';
let cache = {}; let cache = {};
@@ -98,16 +134,104 @@
const q = document.getElementById('search').value.trim().toLowerCase(); const q = document.getElementById('search').value.trim().toLowerCase();
if(q) rows = rows.filter(r => cols.some(c => String(r[c]??'').toLowerCase().includes(q))); 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; } 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 head = '<tr>' + cols.map(c => `<th>${c}</th>`).join('') + '<th></th></tr>';
const body = rows.map(r => '<tr>' + cols.map(c => { const body = rows.map(r => {
const id = r[PK[current]];
const cells = cols.map(c => {
let v = r[c]; let v = r[c];
if(c==='status' || c==='tier') return `<td><span class="pillv">${esc(v)}</span></td>`; 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); if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16);
return `<td title="${esc(v)}">${esc(v)}</td>`; return `<td title="${esc(v)}">${esc(v)}</td>`;
}).join('') + '</tr>').join(''); }).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>`; 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(t => t.onclick = () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active')); document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active'); t.classList.add('active');