b84a5d4c6f
Remove the 1280px content cap and the table's overflow scroll; let cells wrap (word-break) instead of forcing a horizontal scrollbar, so rows span the full screen width and show full values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
13 KiB
HTML
252 lines
13 KiB
HTML
<!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: none; margin: 0; 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: hidden; }
|
|
table { width: 100%; border-collapse: collapse; font-size: .85rem; table-layout: auto; }
|
|
th, td { text-align: left; padding: 10px 13px; border-bottom: 1px solid var(--line); white-space: normal; }
|
|
th { background: #f0f5f4; color: var(--muted); font-size: .72rem; text-transform: uppercase;
|
|
letter-spacing: .5px; }
|
|
td { vertical-align: top; word-break: break-word; }
|
|
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); }
|
|
.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>
|
|
</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" 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>
|
|
// Derive the API base from this page's own path so it works whether the
|
|
// dashboard is opened at /crm or /crm/ (relative fetches break without the
|
|
// trailing slash).
|
|
const API = location.pathname.replace(/\/+$/, '') + '/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 = {};
|
|
|
|
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('') + '<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');
|
|
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>
|