Back office: Postgres source-of-truth + read dashboard
Stand up the DB-first CRM backbone (architecture pivot: Postgres is the source of truth, Google Sheets becomes a one-way downstream mirror). - backoffice/ stack: smb-db (Postgres 16) + smb-crm (Flask/waitress service). - Schema mirrors the six Sheet tabs (clients, leads, projects, activity_log, bookings, invoices) with typed columns + updated_at triggers. - Service-account Sheets client (PyJWT) for the one-time import + future mirror. - import_from_sheets.py: idempotent seed of Postgres from the live Sheets. - Read dashboard (Leads & Clients tables) at onboard.mivanchenko.de/crm, behind the existing Caddy basic-auth; JSON API reads straight from Postgres. Deployed + verified: import seeded DB, dashboard/API live, no-auth blocked, onboarding form unaffected. Add/edit/delete + DB->Sheets sync + n8n ingest swap are the next steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>smb-crm — Back Office</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f8f8; --surface: #fff; --ink: #16302f; --muted: #5d716f;
|
||||
--teal: #0f8a7e; --teal-2: #0b6b62; --teal-soft: #e4f3f1; --line: #e3eae9;
|
||||
--shadow: 0 10px 30px rgba(16,48,47,.08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg); color: var(--ink); }
|
||||
header { background: var(--surface); border-bottom: 1px solid var(--line); padding: 16px 24px;
|
||||
display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 5; }
|
||||
header h1 { font-size: 1.15rem; margin: 0; }
|
||||
header .src { font-size: .74rem; color: var(--muted); font-weight: 500; }
|
||||
.wrap { max-width: 1280px; margin: 0 auto; padding: 22px 24px 60px; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 18px; }
|
||||
.tab { background: var(--surface); border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 9px 18px; font-size: .9rem; font-weight: 600; color: var(--muted); cursor: pointer; }
|
||||
.tab.active { background: var(--teal); color: #fff; border-color: var(--teal); }
|
||||
.tab .n { opacity: .7; font-weight: 500; margin-left: 5px; }
|
||||
.bar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.bar input { flex: 1; max-width: 340px; padding: 9px 13px; border: 1px solid var(--line);
|
||||
border-radius: 9px; font: inherit; font-size: .9rem; background: var(--surface); }
|
||||
.btn { background: var(--teal); color: #fff; border: 0; border-radius: 9px; padding: 9px 16px;
|
||||
font: inherit; font-size: .88rem; font-weight: 600; cursor: pointer; }
|
||||
.btn.ghost { background: var(--surface); color: var(--teal-2); border: 1px solid var(--line); }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: var(--shadow); overflow: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: 10px 13px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
th { background: #f0f5f4; color: var(--muted); font-size: .72rem; text-transform: uppercase;
|
||||
letter-spacing: .5px; position: sticky; top: 0; }
|
||||
td { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||
tr:hover td { background: #fafdfc; }
|
||||
.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); }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🗂️ smb-crm · Back Office</h1>
|
||||
<span class="src">Quelle: Postgres (Sheets = Spiegel) · <span id="now"></span></span>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-e="leads">Leads<span class="n" id="n-leads"></span></button>
|
||||
<button class="tab" data-e="clients">Clients<span class="n" id="n-clients"></span></button>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<input id="search" placeholder="Filtern …" />
|
||||
<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>
|
||||
|
||||
<script>
|
||||
const API = 'api';
|
||||
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'],
|
||||
};
|
||||
let current = 'leads';
|
||||
let cache = {};
|
||||
|
||||
const esc = s => (s==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
function showErr(t){ const m=document.getElementById('msg'); m.textContent=t; m.className='msg err'; }
|
||||
function clearErr(){ document.getElementById('msg').className='msg'; }
|
||||
|
||||
async function load(entity){
|
||||
clearErr();
|
||||
document.getElementById('table').innerHTML = '<div class="empty">Lädt …</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/${entity}`);
|
||||
if(!r.ok) throw new Error('HTTP '+r.status);
|
||||
const d = await r.json();
|
||||
cache[entity] = d.rows;
|
||||
document.getElementById('n-'+entity).textContent = d.count;
|
||||
render();
|
||||
} catch(e){
|
||||
showErr('Laden fehlgeschlagen: '+e.message);
|
||||
document.getElementById('table').innerHTML = '<div class="empty">—</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function render(){
|
||||
const cols = COLS[current];
|
||||
let rows = cache[current] || [];
|
||||
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('');
|
||||
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(t => t.onclick = () => {
|
||||
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
current = t.dataset.e;
|
||||
if(cache[current]) render(); else load(current);
|
||||
});
|
||||
document.getElementById('search').oninput = render;
|
||||
document.getElementById('refresh').onclick = () => load(current);
|
||||
document.getElementById('now').textContent = new Date().toLocaleString('de-DE');
|
||||
|
||||
load('leads'); load('clients');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user