feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>404 – Seite nicht gefunden</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center bg-slate-50 p-4">
|
||||||
|
|
||||||
|
<div class="crm-card text-center max-w-md" x-data="{}">
|
||||||
|
<h1 class="text-6xl font-bold text-slate-900 mb-2">404</h1>
|
||||||
|
<p class="text-xl text-slate-700 mb-2">Seite nicht gefunden</p>
|
||||||
|
<p class="text-sm text-slate-500 mb-6">
|
||||||
|
Die angeforderte URL existiert nicht oder du bist nicht berechtigt.
|
||||||
|
</p>
|
||||||
|
<a href="/dashboard.html" class="btn-primary inline-block mb-2">Zum Dashboard</a>
|
||||||
|
<a href="/app.html" class="btn-secondary inline-block">Oder zur App-Startseite</a>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-400 mt-6">
|
||||||
|
Falls du eine bestimmte Page suchst: Accounts, Contacts, Pipeline oder Activities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Account Detail</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html" class="active">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Account-Detail</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="accountDetail()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-12">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2">Lade Account …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && account">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="crm-card mb-4">
|
||||||
|
<div class="flex items-start justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900" x-text="account?.name"></h2>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">
|
||||||
|
<span class="crm-badge badge-blue" x-text="account?.industry || '–'"></span>
|
||||||
|
<span class="crm-badge badge-gray ml-1" x-text="account?.size || '–'"></span>
|
||||||
|
<span class="ml-2">Owner #<span x-text="account?.owner_id"></span></span>
|
||||||
|
</p>
|
||||||
|
<p class="text-blue-600 underline mt-2" x-show="account?.website">
|
||||||
|
<a :href="account?.website" target="_blank" x-text="account?.website"></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEdit()" class="btn-secondary">Bearbeiten</button>
|
||||||
|
<button @click="confirmDelete()" class="btn-danger">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex border-b border-slate-200 mb-4" role="tablist">
|
||||||
|
<button @click="tab='info'" :class="tabBtn('info')">Info</button>
|
||||||
|
<button @click="loadContacts()" :class="tabBtn('contacts')">Contacts</button>
|
||||||
|
<button @click="loadDeals()" :class="tabBtn('deals')">Deals</button>
|
||||||
|
<button @click="loadActivities()" :class="tabBtn('activities')">Activities</button>
|
||||||
|
<button @click="loadNotes()" :class="tabBtn('notes')">Notes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div x-show="tab === 'info'" class="crm-card">
|
||||||
|
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><dt class="text-slate-500">Name</dt><dd class="font-medium" x-text="account?.name"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Branche</dt><dd x-text="account?.industry || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Größe</dt><dd x-text="account?.size || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Website</dt><dd x-text="account?.website || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Org-ID</dt><dd x-text="account?.org_id"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Owner</dt><dd x-text="account?.owner_id"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Erstellt</dt><dd x-text="formatDateTime(account?.created_at)"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Aktualisiert</dt><dd x-text="formatDateTime(account?.updated_at)"></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts -->
|
||||||
|
<div x-show="tab === 'contacts'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
<table class="crm-table" x-show="!loadingSub">
|
||||||
|
<thead><tr><th>Name</th><th>Email</th><th>Phone</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="c in subLists.contacts" :key="c.id">
|
||||||
|
<tr class="cursor-pointer"
|
||||||
|
@click="window.location.href = '/contacts-detail.html?id=' + c.id">
|
||||||
|
<td x-text="(c.first_name || '') + ' ' + (c.last_name || '')"></td>
|
||||||
|
<td x-text="c.email || '–'"></td>
|
||||||
|
<td x-text="c.phone || '–'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loadingSub && subLists.contacts.length === 0">
|
||||||
|
<td colspan="3" class="text-center text-slate-500 py-3">Keine Contacts.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deals -->
|
||||||
|
<div x-show="tab === 'deals'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||||
|
<table class="crm-table" x-show="!loadingSub">
|
||||||
|
<thead><tr><th>Title</th><th>Stage</th><th>Value</th><th>Close</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="d in subLists.deals" :key="d.id">
|
||||||
|
<tr>
|
||||||
|
<td x-text="d.title"></td>
|
||||||
|
<td><span class="crm-badge badge-blue" x-text="d.stage"></span></td>
|
||||||
|
<td x-text="formatCurrency(d.value)"></td>
|
||||||
|
<td x-text="d.close_date || '–'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loadingSub && subLists.deals.length === 0">
|
||||||
|
<td colspan="4" class="text-center text-slate-500 py-3">Keine Deals.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activities -->
|
||||||
|
<div x-show="tab === 'activities'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||||
|
<ul class="divide-y divide-slate-100" x-show="!loadingSub">
|
||||||
|
<template x-for="a in subLists.activities" :key="a.id">
|
||||||
|
<li class="py-2 flex items-start gap-3">
|
||||||
|
<span class="crm-badge badge-blue" x-text="a.type"></span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium" x-text="a.subject"></p>
|
||||||
|
<p class="text-xs text-slate-500" x-text="a.body || ''"></p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li x-show="!loadingSub && subLists.activities.length === 0"
|
||||||
|
class="text-center text-slate-500 py-3 text-sm">Keine Activities.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div x-show="tab === 'notes'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||||
|
<ul class="space-y-2" x-show="!loadingSub">
|
||||||
|
<template x-for="n in subLists.notes" :key="n.id">
|
||||||
|
<li class="border-l-4 border-blue-500 pl-3 py-1">
|
||||||
|
<p class="text-sm" x-text="n.body"></p>
|
||||||
|
<p class="text-xs text-slate-400" x-text="formatDateTime(n.created_at)"></p>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li x-show="!loadingSub && subLists.notes.length === 0"
|
||||||
|
class="text-center text-slate-500 py-3 text-sm">Keine Notes.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
<div x-show="showEdit" x-cloak class="crm-modal-backdrop" @click.self="closeEdit()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Account bearbeiten</h2>
|
||||||
|
<form @submit.prevent="submitEdit()">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Name *</label>
|
||||||
|
<input type="text" required x-model="editForm.name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Website</label>
|
||||||
|
<input type="text" x-model="editForm.website" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Branche</label>
|
||||||
|
<input type="text" x-model="editForm.industry" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Größe</label>
|
||||||
|
<input type="text" x-model="editForm.size" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="editError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="editError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeEdit()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="saving" class="btn-primary"
|
||||||
|
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function accountDetail() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
account: null,
|
||||||
|
loading: true,
|
||||||
|
tab: 'info',
|
||||||
|
subLists: { contacts: [], deals: [], activities: [], notes: [] },
|
||||||
|
loadingSub: false,
|
||||||
|
showEdit: false,
|
||||||
|
editForm: { name: '', website: '', industry: '', size: '' },
|
||||||
|
editError: '',
|
||||||
|
saving: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
this.id = params.get('id');
|
||||||
|
if (!this.id) { window.location.href = '/accounts.html'; return; }
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
this.account = await api.get(`/accounts/${this.id}`);
|
||||||
|
this.editForm = {
|
||||||
|
name: this.account.name || '',
|
||||||
|
website: this.account.website || '',
|
||||||
|
industry: this.account.industry || '',
|
||||||
|
size: this.account.size || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Fehler beim Laden.');
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadContacts() {
|
||||||
|
this.tab = 'contacts';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/contacts/?account_id=${this.id}&limit=200`);
|
||||||
|
this.subLists.contacts = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
async loadDeals() {
|
||||||
|
this.tab = 'deals';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/deals/?account_id=${this.id}&limit=200`);
|
||||||
|
// Backend may not have account_id filter; client-filter as fallback
|
||||||
|
const list = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
this.subLists.deals = list.filter(d => String(d.account_id) === String(this.id));
|
||||||
|
} finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
async loadActivities() {
|
||||||
|
this.tab = 'activities';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/activities/?account_id=${this.id}&limit=200`);
|
||||||
|
this.subLists.activities = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
async loadNotes() {
|
||||||
|
this.tab = 'notes';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/notes/?parent_type=account&parent_id=${this.id}`);
|
||||||
|
this.subLists.notes = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
// /notes may not be paginated; show empty on error
|
||||||
|
this.subLists.notes = [];
|
||||||
|
} finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
tabBtn(t) {
|
||||||
|
return this.tab === t
|
||||||
|
? 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-blue-600 text-blue-600'
|
||||||
|
: 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-transparent text-slate-500 hover:text-slate-700';
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit() { this.showEdit = true; this.editError = ''; },
|
||||||
|
closeEdit() { this.showEdit = false; },
|
||||||
|
async submitEdit() {
|
||||||
|
this.saving = true;
|
||||||
|
this.editError = '';
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const payload = { name: this.editForm.name };
|
||||||
|
if (this.editForm.website) payload.website = this.editForm.website;
|
||||||
|
if (this.editForm.industry) payload.industry = this.editForm.industry;
|
||||||
|
if (this.editForm.size) payload.size = this.editForm.size;
|
||||||
|
await api.patch(`/accounts/${this.id}`, payload);
|
||||||
|
Alpine.store('notifications').success('Account aktualisiert.');
|
||||||
|
this.showEdit = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.editError = err.message || 'Fehler beim Speichern.';
|
||||||
|
} finally { this.saving = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmDelete() {
|
||||||
|
if (!confirm('Account wirklich löschen (Soft-Delete)?')) return;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
await api.del(`/accounts/${this.id}`);
|
||||||
|
Alpine.store('notifications').success('Account gelöscht.');
|
||||||
|
window.location.href = '/accounts.html';
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Löschen fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatCurrency(v) {
|
||||||
|
return Number(v || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||||
|
},
|
||||||
|
formatDateTime(iso) {
|
||||||
|
if (!iso) return '–';
|
||||||
|
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Accounts</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
<script type="module" src="/components/account-list.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<!-- Toast container -->
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Layout shell -->
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html" class="active">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Accounts</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="accountList()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex-1 min-w-[12rem]">
|
||||||
|
<label class="crm-label" for="q">Suche (Name)</label>
|
||||||
|
<input id="q" type="text" x-model="q" @keyup.enter="search()"
|
||||||
|
class="crm-input" placeholder="z.B. Acme Corp" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label" for="industry">Branche</label>
|
||||||
|
<select id="industry" x-model="industry" class="crm-input">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<template x-for="i in industries" :key="i">
|
||||||
|
<option :value="i" x-text="i"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label" for="size">Größe</label>
|
||||||
|
<select id="size" x-model="size" class="crm-input">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<template x-for="s in sizes" :key="s">
|
||||||
|
<option :value="s" x-text="s"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button @click="search()" class="btn-primary">Suchen</button>
|
||||||
|
<button @click="openCreate()" class="btn-primary ml-auto">+ New Account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2 text-sm">Lade Accounts …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div x-show="!loading" class="overflow-x-auto">
|
||||||
|
<table class="crm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Branche</th>
|
||||||
|
<th>Größe</th>
|
||||||
|
<th>Website</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="a in accounts" :key="a.id">
|
||||||
|
<tr class="cursor-pointer"
|
||||||
|
@click="window.location.href = '/accounts-detail.html?id=' + a.id">
|
||||||
|
<td class="font-medium" x-text="a.name"></td>
|
||||||
|
<td><span class="crm-badge badge-blue" x-text="a.industry || '–'"></span></td>
|
||||||
|
<td x-text="a.size || '–'"></td>
|
||||||
|
<td class="text-blue-600 underline" x-text="a.website || '–'"></td>
|
||||||
|
<td x-text="a.owner_id"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && accounts.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-slate-500 py-6">
|
||||||
|
Keine Accounts gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
|
||||||
|
x-show="!loading">
|
||||||
|
<span x-text="pageInfo"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
|
||||||
|
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div x-show="showCreate" x-cloak class="crm-modal-backdrop"
|
||||||
|
@click.self="closeCreate()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Neuer Account</h2>
|
||||||
|
<form @submit.prevent="submitCreate()">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Name *</label>
|
||||||
|
<input type="text" required minlength="1" maxlength="255"
|
||||||
|
x-model="createForm.name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Website</label>
|
||||||
|
<input type="text" maxlength="512"
|
||||||
|
x-model="createForm.website" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Branche</label>
|
||||||
|
<select x-model="createForm.industry" class="crm-input">
|
||||||
|
<option value="">(keine)</option>
|
||||||
|
<template x-for="i in industries" :key="i">
|
||||||
|
<option :value="i" x-text="i"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Größe</label>
|
||||||
|
<select x-model="createForm.size" class="crm-input">
|
||||||
|
<option value="">(keine)</option>
|
||||||
|
<template x-for="s in sizes" :key="s">
|
||||||
|
<option :value="s" x-text="s"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="createError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="creating" class="btn-primary">
|
||||||
|
<span x-text="creating ? 'Speichere …' : 'Anlegen'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Activities</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
<script type="module" src="/components/activity-list.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html" class="active">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Activities</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="activityList()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Typ</label>
|
||||||
|
<select x-model="type" class="crm-input">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<template x-for="t in types" :key="t">
|
||||||
|
<option :value="t" x-text="t"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Overdue</label>
|
||||||
|
<select x-model="overdue" class="crm-input">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="true">Nur Overdue</option>
|
||||||
|
<option value="false">Nicht Overdue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Status</label>
|
||||||
|
<select x-model="completed" class="crm-input">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="false">Offen</option>
|
||||||
|
<option value="true">Erledigt</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button @click="applyFilters()" class="btn-primary">Filtern</button>
|
||||||
|
<button @click="openCreate()" class="btn-primary ml-auto">+ New Activity</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2 text-sm">Lade Activities …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div x-show="!loading" class="overflow-x-auto">
|
||||||
|
<table class="crm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Fällig</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="a in activities" :key="a.id">
|
||||||
|
<tr class="cursor-pointer"
|
||||||
|
:class="{ 'row-overdue': isOverdue(a) }"
|
||||||
|
@click="openDetail(a)">
|
||||||
|
<td><span class="crm-badge" :class="typeBadge(a.type)" x-text="a.type"></span></td>
|
||||||
|
<td class="font-medium" x-text="a.subject"></td>
|
||||||
|
<td x-text="formatDateTime(a.due_date)"></td>
|
||||||
|
<td x-text="a.owner_id"></td>
|
||||||
|
<td>
|
||||||
|
<span x-show="isCompleted(a)" class="crm-badge badge-green">Erledigt</span>
|
||||||
|
<span x-show="!isCompleted(a) && isOverdue(a)" class="crm-badge badge-red">Overdue</span>
|
||||||
|
<span x-show="!isCompleted(a) && !isOverdue(a)" class="crm-badge badge-yellow">Offen</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && activities.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-slate-500 py-6">
|
||||||
|
Keine Activities gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
|
||||||
|
x-show="!loading">
|
||||||
|
<span x-text="pageInfo"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
|
||||||
|
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div x-show="showCreate" x-cloak class="crm-modal-backdrop" @click.self="closeCreate()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Neue Activity</h2>
|
||||||
|
<form @submit.prevent="submitCreate()">
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Typ *</label>
|
||||||
|
<select x-model="createForm.type" class="crm-input" required>
|
||||||
|
<template x-for="t in types" :key="t">
|
||||||
|
<option :value="t" x-text="t"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Fällig am</label>
|
||||||
|
<input type="datetime-local"
|
||||||
|
x-model="createForm.due_date" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Subject *</label>
|
||||||
|
<input type="text" required maxlength="255"
|
||||||
|
x-model="createForm.subject" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Body</label>
|
||||||
|
<textarea x-model="createForm.body" class="crm-input" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500 mb-3">
|
||||||
|
Polymorph-Parent: mindestens eine ID angeben.
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Account-ID</label>
|
||||||
|
<input type="number" min="1" x-model="createForm.account_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Contact-ID</label>
|
||||||
|
<input type="number" min="1" x-model="createForm.contact_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Deal-ID</label>
|
||||||
|
<input type="number" min="1" x-model="createForm.deal_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="createError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="creating" class="btn-primary"
|
||||||
|
x-text="creating ? 'Speichere …' : 'Anlegen'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail modal -->
|
||||||
|
<div x-show="showDetail" x-cloak class="crm-modal-backdrop" @click.self="closeDetail()">
|
||||||
|
<div class="crm-modal p-6" x-show="selected">
|
||||||
|
<h2 class="text-lg font-semibold mb-2" x-text="selected?.subject"></h2>
|
||||||
|
<p class="text-xs text-slate-500 mb-3">
|
||||||
|
<span class="crm-badge" :class="typeBadge(selected?.type)" x-text="selected?.type"></span>
|
||||||
|
<span class="ml-2">Fällig: <span x-text="formatDateTime(selected?.due_date)"></span></span>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-slate-700 mb-4" x-text="selected?.body || '(kein Body)'"></p>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeDetail()" class="btn-secondary">Schließen</button>
|
||||||
|
<button x-show="!selected?.completed_at" @click="completeSelected()"
|
||||||
|
:disabled="completing" class="btn-primary"
|
||||||
|
x-text="completing ? 'Speichere …' : 'Als erledigt markieren'"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Dashboard</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS via CDN (Dev) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- App stores + dashboard component -->
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
<script type="module" src="/components/app-shell.js"></script>
|
||||||
|
<script type="module" src="/components/dashboard-kpis.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<!-- === TOAST CONTAINER === -->
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === LAYOUT SHELL === -->
|
||||||
|
<!-- Auth-Gate + appShell: store.js's init() redirects to /index.html when no JWT. -->
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
<div x-data="appShell('dashboard')" x-cloak class="flex min-h-screen flex-1">
|
||||||
|
|
||||||
|
<!-- === SIDEBAR === -->
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html" :class="{ active: active === 'dashboard' }">Dashboard</a>
|
||||||
|
<a href="/accounts.html" :class="{ active: active === 'accounts' }">Accounts</a>
|
||||||
|
<a href="/contacts.html" :class="{ active: active === 'contacts' }">Contacts</a>
|
||||||
|
<a href="/pipeline.html" :class="{ active: active === 'pipeline' }">Pipeline</a>
|
||||||
|
<a href="/activities.html" :class="{ active: active === 'activities' }">Activities</a>
|
||||||
|
<a href="/settings-profile.html":class="{ active: active === 'settings-profile' }">Profil</a>
|
||||||
|
<a href="/settings-users.html" :class="{ active: active === 'settings-users' }"
|
||||||
|
x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" :class="{ active: active === 'settings-org' }"
|
||||||
|
x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- === MAIN AREA === -->
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
|
||||||
|
<!-- Top navigation -->
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800" x-text="title"></h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
Angemeldet als
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- === DASHBOARD CONTENT (default landing) === -->
|
||||||
|
<main class="flex-1 p-6" x-data="dashboardKpis()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2 text-sm">Lade Dashboard …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div x-show="error" class="crm-card text-red-700 bg-red-50">
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && !error">
|
||||||
|
|
||||||
|
<!-- KPI Grid -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Kennzahlen</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Open Deals</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900" x-text="kpis.open_deals ?? '–'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Pipeline Value</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900"
|
||||||
|
x-text="formatCurrency(kpis.pipeline_value)"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Won this Month</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900" x-text="kpis.won_this_month ?? '–'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Conversion Rate</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900"
|
||||||
|
x-text="formatPercent(kpis.conversion_rate)"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Schnellaktionen</h2>
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<a href="/accounts.html" class="btn-primary">+ New Account</a>
|
||||||
|
<a href="/contacts.html" class="btn-primary">+ New Contact</a>
|
||||||
|
<a href="/pipeline.html" class="btn-primary">+ New Deal</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity feed -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Letzte Aktivitäten</h2>
|
||||||
|
<div class="crm-card">
|
||||||
|
<template x-for="a in feed" :key="a.id">
|
||||||
|
<div class="py-2 border-b border-slate-100 last:border-0">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="crm-badge" :class="badgeColor(a.type)" x-text="a.type"></span>
|
||||||
|
<span class="ml-2 font-medium text-slate-800" x-text="a.subject"></span>
|
||||||
|
</p>
|
||||||
|
<span class="text-xs text-slate-400" x-text="formatRelative(a.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-1" x-text="a.body || ''"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="feed.length === 0" class="text-sm text-slate-500 text-center py-4">
|
||||||
|
Keine Aktivitäten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Account-List Component (Alpine.js)
|
||||||
|
*
|
||||||
|
* Used in: accounts.html
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Paginated table of accounts
|
||||||
|
* - Search by name (?q=)
|
||||||
|
* - Filter by industry (?industry=) and size (?size=)
|
||||||
|
* - "+ New Account" modal that POSTs to /api/v1/accounts/
|
||||||
|
* - Row click → /accounts-detail.html?id=…
|
||||||
|
*
|
||||||
|
* Backend: GET /api/v1/accounts/?skip=&limit=&q=&industry=&size=&owner_id=
|
||||||
|
* POST /api/v1/accounts/ → AccountOut
|
||||||
|
*
|
||||||
|
* R-5: x-text only. All user-controlled strings go through x-text or
|
||||||
|
* textContent (never x-html / innerHTML).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/js/api.js';
|
||||||
|
|
||||||
|
export function accountList() {
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
accounts: [],
|
||||||
|
total: 0,
|
||||||
|
skip: 0,
|
||||||
|
limit: 20,
|
||||||
|
q: '',
|
||||||
|
industry: '',
|
||||||
|
size: '',
|
||||||
|
ownerId: '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Create-modal state
|
||||||
|
showCreate: false,
|
||||||
|
createForm: this._initCreateForm(),
|
||||||
|
createError: '',
|
||||||
|
creating: false,
|
||||||
|
|
||||||
|
// Lookup values (could be hard-coded; kept here for clarity)
|
||||||
|
industries: ['technology', 'finance', 'healthcare', 'retail', 'manufacturing', 'other'],
|
||||||
|
sizes: ['startup', 'smb', 'midmarket', 'enterprise'],
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('skip', String(this.skip));
|
||||||
|
params.set('limit', String(this.limit));
|
||||||
|
if (this.q) params.set('q', this.q);
|
||||||
|
if (this.industry) params.set('industry', this.industry);
|
||||||
|
if (this.size) params.set('size', this.size);
|
||||||
|
if (this.ownerId) params.set('owner_id', this.ownerId);
|
||||||
|
|
||||||
|
const res = await api.get(`/accounts/?${params.toString()}`);
|
||||||
|
this.accounts = Array.isArray(res) ? res : (res && res.items) || [];
|
||||||
|
this.total = (res && res.total) || this.accounts.length;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Fehler beim Laden der Accounts.';
|
||||||
|
this._notify('error', this.error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
this.skip = 0;
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async nextPage() {
|
||||||
|
if (this.skip + this.limit < this.total) {
|
||||||
|
this.skip += this.limit;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async prevPage() {
|
||||||
|
if (this.skip - this.limit >= 0) {
|
||||||
|
this.skip -= this.limit;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get pageInfo() {
|
||||||
|
const from = this.total === 0 ? 0 : this.skip + 1;
|
||||||
|
const to = Math.min(this.skip + this.limit, this.total);
|
||||||
|
return `${from}–${to} von ${this.total}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Create modal ===
|
||||||
|
openCreate() {
|
||||||
|
this.createForm = this._initCreateForm();
|
||||||
|
this.createError = '';
|
||||||
|
this.showCreate = true;
|
||||||
|
},
|
||||||
|
closeCreate() {
|
||||||
|
this.showCreate = false;
|
||||||
|
},
|
||||||
|
async submitCreate() {
|
||||||
|
this.creating = true;
|
||||||
|
this.createError = '';
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: this.createForm.name,
|
||||||
|
};
|
||||||
|
if (this.createForm.website) payload.website = this.createForm.website;
|
||||||
|
if (this.createForm.industry) payload.industry = this.createForm.industry;
|
||||||
|
if (this.createForm.size) payload.size = this.createForm.size;
|
||||||
|
|
||||||
|
await api.post('/accounts/', payload);
|
||||||
|
this._notify('success', 'Account angelegt.');
|
||||||
|
this.showCreate = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Internals ===
|
||||||
|
_initCreateForm() {
|
||||||
|
return { name: '', website: '', industry: '', size: '' };
|
||||||
|
},
|
||||||
|
_notify(type, message) {
|
||||||
|
if (window.Alpine) {
|
||||||
|
Alpine.store('notifications').push(message, type);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.data('accountList', accountList);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default accountList;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Activity-List Component (Alpine.js)
|
||||||
|
*
|
||||||
|
* Used in: activities.html
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Paginated table of activities
|
||||||
|
* - Filter by type, owner, overdue, completed
|
||||||
|
* - "+ New Activity" modal that POSTs to /api/v1/activities/
|
||||||
|
* - Row click opens a detail modal with a "Complete" button
|
||||||
|
*
|
||||||
|
* Backend: GET /api/v1/activities/?skip=&limit=&type=&owner_id=&overdue=&completed=
|
||||||
|
* POST /api/v1/activities/
|
||||||
|
* PATCH /api/v1/activities/{id}/complete
|
||||||
|
*
|
||||||
|
* R-5: x-text only. Even activity bodies go through x-text, never x-html.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/js/api.js';
|
||||||
|
|
||||||
|
export const ACTIVITY_TYPES = ['call', 'email', 'meeting', 'task', 'note'];
|
||||||
|
|
||||||
|
export const TYPE_BADGE = {
|
||||||
|
call: 'badge-blue',
|
||||||
|
email: 'badge-yellow',
|
||||||
|
meeting: 'badge-green',
|
||||||
|
task: 'badge-gray',
|
||||||
|
note: 'badge-gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function activityList() {
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
activities: [],
|
||||||
|
total: 0,
|
||||||
|
skip: 0,
|
||||||
|
limit: 20,
|
||||||
|
type: '',
|
||||||
|
ownerId: '',
|
||||||
|
overdue: '', // '', 'true', 'false'
|
||||||
|
completed: '', // '', 'true', 'false'
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Create-modal state
|
||||||
|
showCreate: false,
|
||||||
|
createForm: this._initForm(),
|
||||||
|
createError: '',
|
||||||
|
creating: false,
|
||||||
|
|
||||||
|
// Detail-modal state
|
||||||
|
showDetail: false,
|
||||||
|
selected: null,
|
||||||
|
completing: false,
|
||||||
|
|
||||||
|
// Lookups
|
||||||
|
types: ACTIVITY_TYPES.slice(),
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('skip', String(this.skip));
|
||||||
|
params.set('limit', String(this.limit));
|
||||||
|
if (this.type) params.set('type', this.type);
|
||||||
|
if (this.ownerId) params.set('owner_id', this.ownerId);
|
||||||
|
if (this.overdue) params.set('overdue', this.overdue);
|
||||||
|
if (this.completed) params.set('completed', this.completed);
|
||||||
|
|
||||||
|
const res = await api.get(`/activities/?${params.toString()}`);
|
||||||
|
this.activities = Array.isArray(res) ? res : (res && res.items) || [];
|
||||||
|
this.total = (res && res.total) || this.activities.length;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Fehler beim Laden der Activities.';
|
||||||
|
this._notify('error', this.error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyFilters() {
|
||||||
|
this.skip = 0;
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async nextPage() {
|
||||||
|
if (this.skip + this.limit < this.total) {
|
||||||
|
this.skip += this.limit;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async prevPage() {
|
||||||
|
if (this.skip - this.limit >= 0) {
|
||||||
|
this.skip -= this.limit;
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get pageInfo() {
|
||||||
|
const from = this.total === 0 ? 0 : this.skip + 1;
|
||||||
|
const to = Math.min(this.skip + this.limit, this.total);
|
||||||
|
return `${from}–${to} von ${this.total}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Row helpers ===
|
||||||
|
isOverdue(a) {
|
||||||
|
if (a.completed_at) return false;
|
||||||
|
if (!a.due_date) return false;
|
||||||
|
return new Date(a.due_date) < new Date();
|
||||||
|
},
|
||||||
|
isCompleted(a) { return !!a.completed_at; },
|
||||||
|
typeBadge(t) { return TYPE_BADGE[t] || 'badge-gray'; },
|
||||||
|
|
||||||
|
formatDateTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try { return new Date(iso).toLocaleString('de-DE'); }
|
||||||
|
catch { return iso; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Create modal ===
|
||||||
|
openCreate() {
|
||||||
|
this.createForm = this._initForm();
|
||||||
|
this.createError = '';
|
||||||
|
this.showCreate = true;
|
||||||
|
},
|
||||||
|
closeCreate() { this.showCreate = false; },
|
||||||
|
async submitCreate() {
|
||||||
|
this.creating = true;
|
||||||
|
this.createError = '';
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
type: this.createForm.type,
|
||||||
|
subject: this.createForm.subject,
|
||||||
|
};
|
||||||
|
if (this.createForm.body) payload.body = this.createForm.body;
|
||||||
|
if (this.createForm.due_date) payload.due_date = this.createForm.due_date;
|
||||||
|
if (this.createForm.account_id) payload.account_id = Number(this.createForm.account_id);
|
||||||
|
if (this.createForm.contact_id) payload.contact_id = Number(this.createForm.contact_id);
|
||||||
|
if (this.createForm.deal_id) payload.deal_id = Number(this.createForm.deal_id);
|
||||||
|
|
||||||
|
await api.post('/activities/', payload);
|
||||||
|
this._notify('success', 'Activity angelegt.');
|
||||||
|
this.showCreate = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Detail / Complete ===
|
||||||
|
openDetail(a) {
|
||||||
|
this.selected = a;
|
||||||
|
this.showDetail = true;
|
||||||
|
},
|
||||||
|
closeDetail() {
|
||||||
|
this.showDetail = false;
|
||||||
|
this.selected = null;
|
||||||
|
},
|
||||||
|
async completeSelected() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
this.completing = true;
|
||||||
|
try {
|
||||||
|
await api.patch(`/activities/${this.selected.id}/complete`, { outcome: '' });
|
||||||
|
this._notify('success', 'Activity abgeschlossen.');
|
||||||
|
this.closeDetail();
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this._notify('error', err.message || 'Fehler beim Abschließen.');
|
||||||
|
} finally {
|
||||||
|
this.completing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Internals ===
|
||||||
|
_initForm() {
|
||||||
|
return {
|
||||||
|
type: 'task',
|
||||||
|
subject: '',
|
||||||
|
body: '',
|
||||||
|
due_date: '',
|
||||||
|
account_id: '',
|
||||||
|
contact_id: '',
|
||||||
|
deal_id: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
_notify(type, message) {
|
||||||
|
if (window.Alpine) Alpine.store('notifications').push(message, type);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.data('activityList', activityList);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default activityList;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* app-shell.js — Layout-Shell for the protected area.
|
||||||
|
*
|
||||||
|
* Each protected page (dashboard, accounts, etc.) renders the sidebar +
|
||||||
|
* topbar via x-data="appShell('<page-key>')". The shell owns:
|
||||||
|
* - the active nav state
|
||||||
|
* - the title text (derived from the page key)
|
||||||
|
* - the Logout button handler (delegates to the auth store)
|
||||||
|
* - the admin-only nav items (uses $store.auth.isAdmin)
|
||||||
|
*
|
||||||
|
* The auth-gate itself (redirect to /index.html when no JWT) is enforced
|
||||||
|
* by store.js on the `Alpine.store('auth').init()` call, which every
|
||||||
|
* protected page invokes from its outermost x-data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TITLES = {
|
||||||
|
'dashboard': 'Dashboard',
|
||||||
|
'accounts': 'Accounts',
|
||||||
|
'accounts-detail': 'Account-Detail',
|
||||||
|
'contacts': 'Contacts',
|
||||||
|
'contacts-detail': 'Contact-Detail',
|
||||||
|
'pipeline': 'Pipeline',
|
||||||
|
'activities': 'Activities',
|
||||||
|
'settings-profile': 'Mein Profil',
|
||||||
|
'settings-users': 'User-Verwaltung',
|
||||||
|
'settings-org': 'Org-Einstellungen',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function appShell(active) {
|
||||||
|
return {
|
||||||
|
active: active || 'dashboard',
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return TITLES[this.active] || 'CRM';
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await Alpine.store('auth').logout();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global registration for the inline `x-data="appShell('…')"` usage.
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('appShell', (active) => appShell(active));
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard-KPIs + Activity-Feed Component (Alpine.js)
|
||||||
|
*
|
||||||
|
* Used in: dashboard.html and app.html (default landing page)
|
||||||
|
*
|
||||||
|
* Loads:
|
||||||
|
* GET /api/v1/dashboard/kpis → { open_deals_count, pipeline_value, won_this_month, conversion_rate }
|
||||||
|
* GET /api/v1/dashboard/feed?limit=20 → ActivityFeedItem[]
|
||||||
|
*
|
||||||
|
* Renders: 4 KPI cards + an activity feed list.
|
||||||
|
*
|
||||||
|
* R-5: x-text only. User-controlled strings are not interpolated as HTML.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/js/api.js';
|
||||||
|
|
||||||
|
export function dashboardKpis() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
kpis: null,
|
||||||
|
feed: [],
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
// Fetch KPIs and feed in parallel
|
||||||
|
const [kpis, feed] = await Promise.all([
|
||||||
|
api.get('/dashboard/kpis'),
|
||||||
|
api.get('/dashboard/feed?limit=20'),
|
||||||
|
]);
|
||||||
|
this.kpis = kpis;
|
||||||
|
this.feed = Array.isArray(feed) ? feed : (feed && feed.items) || [];
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Fehler beim Laden der Dashboard-Daten.';
|
||||||
|
if (window.Alpine) {
|
||||||
|
Alpine.store('notifications').error(this.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatCurrency(value) {
|
||||||
|
if (value === null || value === undefined) return '–';
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return '–';
|
||||||
|
return num.toLocaleString('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
formatPercent(value) {
|
||||||
|
if (value === null || value === undefined) return '–';
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return '–';
|
||||||
|
// Backend may return 0-1 or 0-100. Heuristic: >1 → already in %.
|
||||||
|
const pct = num > 1 ? num : num * 100;
|
||||||
|
return pct.toFixed(1) + ' %';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE');
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register globally for `x-data="dashboardKpis()"`
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.data('dashboardKpis', dashboardKpis);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default dashboardKpis;
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Deal-Kanban Component (Alpine.js)
|
||||||
|
*
|
||||||
|
* Used in: pipeline.html
|
||||||
|
*
|
||||||
|
* Loads all deals and groups them client-side by stage into 6 Kanban columns:
|
||||||
|
* lead → qualified → proposal → negotiation → won → lost
|
||||||
|
*
|
||||||
|
* Drag-and-drop: uses HTML5 Drag API. On drop, PATCH /api/v1/deals/{id}/stage
|
||||||
|
* with the new stage. Backend: { stage, reason? } per DealStageUpdate schema.
|
||||||
|
*
|
||||||
|
* The pipeline-summary endpoint (GET /deals/pipeline) is also fetched for
|
||||||
|
* aggregate counts/values shown in each column header.
|
||||||
|
*
|
||||||
|
* R-5: x-text only. No x-html anywhere — including dynamic deal titles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '/js/api.js';
|
||||||
|
|
||||||
|
export const STAGES = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'];
|
||||||
|
|
||||||
|
export const STAGE_LABELS = {
|
||||||
|
lead: 'Lead',
|
||||||
|
qualified: 'Qualified',
|
||||||
|
proposal: 'Proposal',
|
||||||
|
negotiation: 'Negotiation',
|
||||||
|
won: 'Won',
|
||||||
|
lost: 'Lost',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STAGE_BADGE = {
|
||||||
|
lead: 'badge-gray',
|
||||||
|
qualified: 'badge-blue',
|
||||||
|
proposal: 'badge-yellow',
|
||||||
|
negotiation: 'badge-yellow',
|
||||||
|
won: 'badge-green',
|
||||||
|
lost: 'badge-red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function dealKanban() {
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
stages: STAGES.slice(),
|
||||||
|
dealsByStage: {}, // { stage: DealOut[] }
|
||||||
|
pipelineSummary: [], // [{stage, count, total_value}] from /deals/pipeline
|
||||||
|
ownerId: '',
|
||||||
|
dragOverStage: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
// Build URL with optional owner_id filter
|
||||||
|
let url = '/deals/?skip=0&limit=200';
|
||||||
|
if (this.ownerId) url += `&owner_id=${encodeURIComponent(this.ownerId)}`;
|
||||||
|
|
||||||
|
const [deals, summary] = await Promise.all([
|
||||||
|
api.get(url),
|
||||||
|
api.get('/deals/pipeline').catch(() => []), // optional
|
||||||
|
]);
|
||||||
|
|
||||||
|
const list = Array.isArray(deals) ? deals : (deals && deals.items) || [];
|
||||||
|
this.dealsByStage = {};
|
||||||
|
this.stages.forEach(s => { this.dealsByStage[s] = []; });
|
||||||
|
for (const d of list) {
|
||||||
|
if (this.dealsByStage[d.stage]) {
|
||||||
|
this.dealsByStage[d.stage].push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pipelineSummary = Array.isArray(summary) ? summary : [];
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Fehler beim Laden der Pipeline.';
|
||||||
|
this._notify('error', this.error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async filterByOwner() {
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Drag-and-Drop ===
|
||||||
|
onDragStart(event, dealId) {
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', String(dealId));
|
||||||
|
},
|
||||||
|
onDragOver(event, stage) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
this.dragOverStage = stage;
|
||||||
|
},
|
||||||
|
onDragLeave(stage) {
|
||||||
|
if (this.dragOverStage === stage) this.dragOverStage = null;
|
||||||
|
},
|
||||||
|
async onDrop(event, newStage) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.dragOverStage = null;
|
||||||
|
const dealId = event.dataTransfer.getData('text/plain');
|
||||||
|
if (!dealId) return;
|
||||||
|
|
||||||
|
// Find the deal in its current column
|
||||||
|
let currentStage = null;
|
||||||
|
let deal = null;
|
||||||
|
for (const s of this.stages) {
|
||||||
|
const idx = this.dealsByStage[s].findIndex(d => String(d.id) === String(dealId));
|
||||||
|
if (idx >= 0) { currentStage = s; deal = this.dealsByStage[s][idx]; break; }
|
||||||
|
}
|
||||||
|
if (!deal || currentStage === newStage) return;
|
||||||
|
|
||||||
|
// Optimistic UI move
|
||||||
|
this.dealsByStage[currentStage] = this.dealsByStage[currentStage].filter(d => d.id !== deal.id);
|
||||||
|
const oldDeal = { ...deal };
|
||||||
|
deal.stage = newStage;
|
||||||
|
this.dealsByStage[newStage].push(deal);
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await api.patch(`/deals/${dealId}/stage`, { stage: newStage });
|
||||||
|
this._notify('success', `Deal "${oldDeal.title}" → ${STAGE_LABELS[newStage]}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on failure
|
||||||
|
this.dealsByStage[newStage] = this.dealsByStage[newStage].filter(d => d.id !== deal.id);
|
||||||
|
oldDeal.stage = currentStage;
|
||||||
|
this.dealsByStage[currentStage].push(oldDeal);
|
||||||
|
this._notify('error', err.message || 'Stage-Update fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Create modal ===
|
||||||
|
showCreate: false,
|
||||||
|
createForm: this._initForm(),
|
||||||
|
creating: false,
|
||||||
|
createError: '',
|
||||||
|
|
||||||
|
openCreate() {
|
||||||
|
this.createForm = this._initForm();
|
||||||
|
this.createError = '';
|
||||||
|
this.showCreate = true;
|
||||||
|
},
|
||||||
|
closeCreate() { this.showCreate = false; },
|
||||||
|
async submitCreate() {
|
||||||
|
this.creating = true;
|
||||||
|
this.createError = '';
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title: this.createForm.title,
|
||||||
|
value: Number(this.createForm.value) || 0,
|
||||||
|
currency: this.createForm.currency || 'EUR',
|
||||||
|
stage: this.createForm.stage || 'lead',
|
||||||
|
account_id: Number(this.createForm.account_id),
|
||||||
|
};
|
||||||
|
if (this.createForm.close_date) payload.close_date = this.createForm.close_date;
|
||||||
|
await api.post('/deals/', payload);
|
||||||
|
this._notify('success', 'Deal angelegt.');
|
||||||
|
this.showCreate = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
stageLabel(s) { return STAGE_LABELS[s] || s; },
|
||||||
|
stageBadge(s) { return STAGE_BADGE[s] || 'badge-gray'; },
|
||||||
|
columnCount(s) { return (this.dealsByStage[s] || []).length; },
|
||||||
|
columnValue(s) {
|
||||||
|
const sum = (this.dealsByStage[s] || []).reduce(
|
||||||
|
(acc, d) => acc + Number(d.value || 0), 0);
|
||||||
|
return sum.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||||
|
},
|
||||||
|
formatCurrency(v) {
|
||||||
|
return Number(v || 0).toLocaleString('de-DE', {
|
||||||
|
style: 'currency', currency: 'EUR', maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try { return new Date(iso).toLocaleDateString('de-DE'); }
|
||||||
|
catch { return iso; }
|
||||||
|
},
|
||||||
|
_initForm() {
|
||||||
|
return { title: '', value: 0, currency: 'EUR', stage: 'lead', account_id: '', close_date: '' };
|
||||||
|
},
|
||||||
|
_notify(type, message) {
|
||||||
|
if (window.Alpine) Alpine.store('notifications').push(message, type);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.data('dealKanban', dealKanban);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default dealKanban;
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Contact Detail</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html" class="active">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Contact-Detail</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="contactDetail()" x-init="init()">
|
||||||
|
|
||||||
|
<div x-show="loading" class="crm-card text-center py-12">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2">Lade Contact …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && contact">
|
||||||
|
<div class="crm-card mb-4">
|
||||||
|
<div class="flex items-start justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900"
|
||||||
|
x-text="(contact?.first_name || '') + ' ' + (contact?.last_name || '')"></h2>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">
|
||||||
|
<span x-text="contact?.email || '–'"></span>
|
||||||
|
<span class="mx-2">·</span>
|
||||||
|
<span x-text="contact?.phone || '–'"></span>
|
||||||
|
<span class="mx-2">·</span>
|
||||||
|
<span>Account #<span x-text="contact?.account_id || '–'"></span></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="openEdit()" class="btn-secondary">Bearbeiten</button>
|
||||||
|
<button @click="confirmDelete()" class="btn-danger">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex border-b border-slate-200 mb-4">
|
||||||
|
<button @click="tab='info'" :class="tabBtn('info')">Info</button>
|
||||||
|
<button @click="loadActivities()" :class="tabBtn('activities')">Activities</button>
|
||||||
|
<button @click="loadNotes()" :class="tabBtn('notes')">Notes</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'info'" class="crm-card">
|
||||||
|
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><dt class="text-slate-500">Vorname</dt><dd class="font-medium" x-text="contact?.first_name"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Nachname</dt><dd class="font-medium" x-text="contact?.last_name"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">E-Mail</dt><dd x-text="contact?.email || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Telefon</dt><dd x-text="contact?.phone || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Account</dt><dd x-text="contact?.account_id || '–'"></dd></div>
|
||||||
|
<div><dt class="text-slate-500">Owner</dt><dd x-text="contact?.owner_id"></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'activities'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||||
|
<ul class="divide-y divide-slate-100" x-show="!loadingSub">
|
||||||
|
<template x-for="a in subLists.activities" :key="a.id">
|
||||||
|
<li class="py-2 flex items-start gap-3">
|
||||||
|
<span class="crm-badge badge-blue" x-text="a.type"></span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium" x-text="a.subject"></p>
|
||||||
|
<p class="text-xs text-slate-500" x-text="a.body || ''"></p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li x-show="!loadingSub && subLists.activities.length === 0"
|
||||||
|
class="text-center text-slate-500 py-3 text-sm">Keine Activities.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'notes'" class="crm-card">
|
||||||
|
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||||
|
<ul class="space-y-2" x-show="!loadingSub">
|
||||||
|
<template x-for="n in subLists.notes" :key="n.id">
|
||||||
|
<li class="border-l-4 border-blue-500 pl-3 py-1">
|
||||||
|
<p class="text-sm" x-text="n.body"></p>
|
||||||
|
<p class="text-xs text-slate-400" x-text="formatDateTime(n.created_at)"></p>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li x-show="!loadingSub && subLists.notes.length === 0"
|
||||||
|
class="text-center text-slate-500 py-3 text-sm">Keine Notes.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
<div x-show="showEdit" x-cloak class="crm-modal-backdrop" @click.self="closeEdit()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Contact bearbeiten</h2>
|
||||||
|
<form @submit.prevent="submitEdit()">
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Vorname *</label>
|
||||||
|
<input type="text" required x-model="editForm.first_name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Nachname *</label>
|
||||||
|
<input type="text" required x-model="editForm.last_name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">E-Mail</label>
|
||||||
|
<input type="email" x-model="editForm.email" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Telefon</label>
|
||||||
|
<input type="text" maxlength="64" x-model="editForm.phone" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Account-ID</label>
|
||||||
|
<input type="number" min="1" x-model="editForm.account_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div x-show="editError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="editError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeEdit()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="saving" class="btn-primary"
|
||||||
|
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function contactDetail() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
contact: null,
|
||||||
|
loading: true,
|
||||||
|
tab: 'info',
|
||||||
|
subLists: { activities: [], notes: [] },
|
||||||
|
loadingSub: false,
|
||||||
|
showEdit: false,
|
||||||
|
editForm: { first_name: '', last_name: '', email: '', phone: '', account_id: '' },
|
||||||
|
editError: '',
|
||||||
|
saving: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
this.id = params.get('id');
|
||||||
|
if (!this.id) { window.location.href = '/contacts.html'; return; }
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
this.contact = await api.get(`/contacts/${this.id}`);
|
||||||
|
this.editForm = {
|
||||||
|
first_name: this.contact.first_name || '',
|
||||||
|
last_name: this.contact.last_name || '',
|
||||||
|
email: this.contact.email || '',
|
||||||
|
phone: this.contact.phone || '',
|
||||||
|
account_id: this.contact.account_id || '',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Fehler.');
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
async loadActivities() {
|
||||||
|
this.tab = 'activities';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/activities/?contact_id=${this.id}&limit=200`);
|
||||||
|
this.subLists.activities = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
async loadNotes() {
|
||||||
|
this.tab = 'notes';
|
||||||
|
this.loadingSub = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get(`/notes/?parent_type=contact&parent_id=${this.id}`);
|
||||||
|
this.subLists.notes = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} catch { this.subLists.notes = []; } finally { this.loadingSub = false; }
|
||||||
|
},
|
||||||
|
tabBtn(t) {
|
||||||
|
return this.tab === t
|
||||||
|
? 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-blue-600 text-blue-600'
|
||||||
|
: 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-transparent text-slate-500 hover:text-slate-700';
|
||||||
|
},
|
||||||
|
openEdit() { this.showEdit = true; this.editError = ''; },
|
||||||
|
closeEdit() { this.showEdit = false; },
|
||||||
|
async submitEdit() {
|
||||||
|
this.saving = true; this.editError = '';
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const p = { first_name: this.editForm.first_name, last_name: this.editForm.last_name };
|
||||||
|
if (this.editForm.email) p.email = this.editForm.email;
|
||||||
|
if (this.editForm.phone) p.phone = this.editForm.phone;
|
||||||
|
if (this.editForm.account_id) p.account_id = Number(this.editForm.account_id);
|
||||||
|
await api.patch(`/contacts/${this.id}`, p);
|
||||||
|
Alpine.store('notifications').success('Contact aktualisiert.');
|
||||||
|
this.showEdit = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.editError = err.message || 'Fehler.';
|
||||||
|
} finally { this.saving = false; }
|
||||||
|
},
|
||||||
|
async confirmDelete() {
|
||||||
|
if (!confirm('Contact wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
await api.del(`/contacts/${this.id}`);
|
||||||
|
Alpine.store('notifications').success('Contact gelöscht.');
|
||||||
|
window.location.href = '/contacts.html';
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Fehler.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatDateTime(iso) {
|
||||||
|
if (!iso) return '–';
|
||||||
|
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Contacts</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html" class="active">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Contacts</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="contactList()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
|
||||||
|
<div class="flex-1 min-w-[12rem]">
|
||||||
|
<label class="crm-label">Suche (Name / E-Mail)</label>
|
||||||
|
<input type="text" x-model="q" @keyup.enter="search()"
|
||||||
|
class="crm-input" placeholder="z.B. Müller oder max@…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Account-ID</label>
|
||||||
|
<input type="number" min="1" x-model="accountId"
|
||||||
|
class="crm-input" placeholder="optional" />
|
||||||
|
</div>
|
||||||
|
<button @click="search()" class="btn-primary">Suchen</button>
|
||||||
|
<button @click="openCreate()" class="btn-primary ml-auto">+ New Contact</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2 text-sm">Lade Contacts …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div x-show="!loading" class="overflow-x-auto">
|
||||||
|
<table class="crm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Account-ID</th>
|
||||||
|
<th>Owner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="c in contacts" :key="c.id">
|
||||||
|
<tr class="cursor-pointer"
|
||||||
|
@click="window.location.href = '/contacts-detail.html?id=' + c.id">
|
||||||
|
<td class="font-medium"
|
||||||
|
x-text="(c.first_name || '') + ' ' + (c.last_name || '')"></td>
|
||||||
|
<td x-text="c.email || '–'"></td>
|
||||||
|
<td x-text="c.phone || '–'"></td>
|
||||||
|
<td x-text="c.account_id || '–'"></td>
|
||||||
|
<td x-text="c.owner_id"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && contacts.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-slate-500 py-6">
|
||||||
|
Keine Contacts gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
|
||||||
|
x-show="!loading">
|
||||||
|
<span x-text="pageInfo"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
|
||||||
|
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div x-show="showCreate" x-cloak class="crm-modal-backdrop"
|
||||||
|
@click.self="closeCreate()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Neuer Contact</h2>
|
||||||
|
<form @submit.prevent="submitCreate()">
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Vorname *</label>
|
||||||
|
<input type="text" required maxlength="128"
|
||||||
|
x-model="createForm.first_name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Nachname *</label>
|
||||||
|
<input type="text" required maxlength="128"
|
||||||
|
x-model="createForm.last_name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">E-Mail</label>
|
||||||
|
<input type="email" x-model="createForm.email" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Telefon</label>
|
||||||
|
<input type="text" maxlength="64"
|
||||||
|
x-model="createForm.phone" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Account-ID (optional)</label>
|
||||||
|
<input type="number" min="1"
|
||||||
|
x-model="createForm.account_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="createError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="creating" class="btn-primary"
|
||||||
|
x-text="creating ? 'Speichere …' : 'Anlegen'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function contactList() {
|
||||||
|
return {
|
||||||
|
contacts: [],
|
||||||
|
total: 0,
|
||||||
|
skip: 0,
|
||||||
|
limit: 20,
|
||||||
|
q: '',
|
||||||
|
accountId: '',
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
showCreate: false,
|
||||||
|
createForm: { first_name: '', last_name: '', email: '', phone: '', account_id: '' },
|
||||||
|
createError: '',
|
||||||
|
creating: false,
|
||||||
|
|
||||||
|
async init() { await this.load(); },
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('skip', String(this.skip));
|
||||||
|
params.set('limit', String(this.limit));
|
||||||
|
if (this.q) params.set('q', this.q);
|
||||||
|
if (this.accountId) params.set('account_id', String(this.accountId));
|
||||||
|
const r = await api.get(`/contacts/?${params}`);
|
||||||
|
this.contacts = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
this.total = (r && r.total) || this.contacts.length;
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
Alpine.store('notifications').error(err.message || 'Lade-Fehler.');
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
async search() { this.skip = 0; await this.load(); },
|
||||||
|
async nextPage() { if (this.skip + this.limit < this.total) { this.skip += this.limit; await this.load(); } },
|
||||||
|
async prevPage() { if (this.skip - this.limit >= 0) { this.skip -= this.limit; await this.load(); } },
|
||||||
|
get pageInfo() {
|
||||||
|
const from = this.total === 0 ? 0 : this.skip + 1;
|
||||||
|
const to = Math.min(this.skip + this.limit, this.total);
|
||||||
|
return `${from}–${to} von ${this.total}`;
|
||||||
|
},
|
||||||
|
openCreate() { this.createForm = { first_name: '', last_name: '', email: '', phone: '', account_id: '' }; this.createError = ''; this.showCreate = true; },
|
||||||
|
closeCreate() { this.showCreate = false; },
|
||||||
|
async submitCreate() {
|
||||||
|
this.creating = true; this.createError = '';
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const p = { first_name: this.createForm.first_name, last_name: this.createForm.last_name };
|
||||||
|
if (this.createForm.email) p.email = this.createForm.email;
|
||||||
|
if (this.createForm.phone) p.phone = this.createForm.phone;
|
||||||
|
if (this.createForm.account_id) p.account_id = Number(this.createForm.account_id);
|
||||||
|
await api.post('/contacts/', p);
|
||||||
|
Alpine.store('notifications').success('Contact angelegt.');
|
||||||
|
this.showCreate = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.createError = err.message || 'Fehler beim Anlegen.';
|
||||||
|
} finally { this.creating = false; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* === CRM Frontend Custom Styles === */
|
||||||
|
/* Tailwind-CDN provides utility classes; this file adds app-specific overrides. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--crm-primary: #2563eb; /* blue-600 */
|
||||||
|
--crm-primary-dark: #1d4ed8;
|
||||||
|
--crm-success: #16a34a; /* green-600 */
|
||||||
|
--crm-danger: #dc2626; /* red-600 */
|
||||||
|
--crm-warning: #d97706; /* amber-600 */
|
||||||
|
--crm-bg: #f8fafc; /* slate-50 */
|
||||||
|
--crm-card: #ffffff;
|
||||||
|
--crm-border: #e2e8f0; /* slate-200 */
|
||||||
|
--crm-text: #0f172a; /* slate-900 */
|
||||||
|
--crm-muted: #64748b; /* slate-500 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Body === */
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: var(--crm-bg);
|
||||||
|
color: var(--crm-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Buttons (primary-color override) === */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--crm-primary);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background-color: var(--crm-primary-dark); }
|
||||||
|
.btn-primary:disabled { background-color: #94a3b8; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #fff;
|
||||||
|
color: var(--crm-text);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background-color: #f1f5f9; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--crm-danger);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background-color: #b91c1c; }
|
||||||
|
|
||||||
|
/* === Cards === */
|
||||||
|
.crm-card {
|
||||||
|
background: var(--crm-card);
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Form fields === */
|
||||||
|
.crm-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.crm-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--crm-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--crm-text);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Loading spinner (Alpine x-show) === */
|
||||||
|
.crm-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
border-top-color: var(--crm-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: crm-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes crm-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Toast Notifications (top-right) === */
|
||||||
|
.crm-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-toast {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: crm-toast-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crm-toast.toast-success { background-color: var(--crm-success); }
|
||||||
|
.crm-toast.toast-error { background-color: var(--crm-danger); }
|
||||||
|
.crm-toast.toast-info { background-color: var(--crm-primary); }
|
||||||
|
.crm-toast.toast-warning { background-color: var(--crm-warning); }
|
||||||
|
|
||||||
|
@keyframes crm-toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Tables === */
|
||||||
|
.crm-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.crm-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--crm-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--crm-border);
|
||||||
|
}
|
||||||
|
.crm-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--crm-border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.crm-table tr:last-child td { border-bottom: none; }
|
||||||
|
.crm-table tr:hover td { background: #f8fafc; }
|
||||||
|
.crm-table tr.row-overdue td { background: #fef2f2; }
|
||||||
|
.crm-table tr.row-overdue:hover td { background: #fee2e2; }
|
||||||
|
|
||||||
|
/* === Badges === */
|
||||||
|
.crm-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-blue { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-green { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-yellow { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-gray { background: #f1f5f9; color: #475569; }
|
||||||
|
|
||||||
|
/* === Sidebar (app.html) === */
|
||||||
|
.crm-sidebar {
|
||||||
|
width: 16rem;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #cbd5e1;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.crm-sidebar a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.crm-sidebar a:hover { background: #1e293b; color: #fff; }
|
||||||
|
.crm-sidebar a.active {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #fff;
|
||||||
|
border-left-color: var(--crm-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Kanban (pipeline.html) === */
|
||||||
|
.crm-kanban {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.crm-kanban-col {
|
||||||
|
flex: 0 0 16rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 12rem;
|
||||||
|
}
|
||||||
|
.crm-kanban-col.drag-over { background: #e0e7ff; outline: 2px dashed var(--crm-primary); }
|
||||||
|
.crm-kanban-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--crm-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.crm-kanban-card:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
/* === Modal (Alpine x-show) === */
|
||||||
|
.crm-modal-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.crm-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Utility === */
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Dashboard</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS via CDN (Dev) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- App stores + dashboard component -->
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
<script type="module" src="/components/app-shell.js"></script>
|
||||||
|
<script type="module" src="/components/dashboard-kpis.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<!-- === TOAST CONTAINER === -->
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === LAYOUT SHELL === -->
|
||||||
|
<!-- Auth-Gate + appShell: store.js's init() redirects to /index.html when no JWT. -->
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
<div x-data="appShell('dashboard')" x-cloak class="flex min-h-screen flex-1">
|
||||||
|
|
||||||
|
<!-- === SIDEBAR === -->
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html" :class="{ active: active === 'dashboard' }">Dashboard</a>
|
||||||
|
<a href="/accounts.html" :class="{ active: active === 'accounts' }">Accounts</a>
|
||||||
|
<a href="/contacts.html" :class="{ active: active === 'contacts' }">Contacts</a>
|
||||||
|
<a href="/pipeline.html" :class="{ active: active === 'pipeline' }">Pipeline</a>
|
||||||
|
<a href="/activities.html" :class="{ active: active === 'activities' }">Activities</a>
|
||||||
|
<a href="/settings-profile.html":class="{ active: active === 'settings-profile' }">Profil</a>
|
||||||
|
<a href="/settings-users.html" :class="{ active: active === 'settings-users' }"
|
||||||
|
x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" :class="{ active: active === 'settings-org' }"
|
||||||
|
x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- === MAIN AREA === -->
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
|
||||||
|
<!-- Top navigation -->
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800" x-text="title"></h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
Angemeldet als
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- === DASHBOARD CONTENT (default landing) === -->
|
||||||
|
<main class="flex-1 p-6" x-data="dashboardKpis()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
<p class="text-slate-500 mt-2 text-sm">Lade Dashboard …</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div x-show="error" class="crm-card text-red-700 bg-red-50">
|
||||||
|
<span x-text="error"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && !error">
|
||||||
|
|
||||||
|
<!-- KPI Grid -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Kennzahlen</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Open Deals</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900" x-text="kpis.open_deals ?? '–'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Pipeline Value</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900"
|
||||||
|
x-text="formatCurrency(kpis.pipeline_value)"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Won this Month</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900" x-text="kpis.won_this_month ?? '–'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="crm-card">
|
||||||
|
<p class="text-xs text-slate-500">Conversion Rate</p>
|
||||||
|
<p class="text-2xl font-bold text-slate-900"
|
||||||
|
x-text="formatPercent(kpis.conversion_rate)"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Schnellaktionen</h2>
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<a href="/accounts.html" class="btn-primary">+ New Account</a>
|
||||||
|
<a href="/contacts.html" class="btn-primary">+ New Contact</a>
|
||||||
|
<a href="/pipeline.html" class="btn-primary">+ New Deal</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity feed -->
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Letzte Aktivitäten</h2>
|
||||||
|
<div class="crm-card">
|
||||||
|
<template x-for="a in feed" :key="a.id">
|
||||||
|
<div class="py-2 border-b border-slate-100 last:border-0">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="crm-badge" :class="badgeColor(a.type)" x-text="a.type"></span>
|
||||||
|
<span class="ml-2 font-medium text-slate-800" x-text="a.subject"></span>
|
||||||
|
</p>
|
||||||
|
<span class="text-xs text-slate-400" x-text="formatRelative(a.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 mt-1" x-text="a.body || ''"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p x-show="feed.length === 0" class="text-sm text-slate-500 text-center py-4">
|
||||||
|
Keine Aktivitäten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Anmeldung</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS via CDN (Dev) — nonced bundle required for Prod (v1.1) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- App-specific overrides -->
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
|
||||||
|
<!-- Alpine.js (deferred so DOM is parsed first) -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- App stores + components (ES modules) -->
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center bg-slate-50 p-4">
|
||||||
|
|
||||||
|
<!-- Toast container (top-right) -->
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="authForm()" x-cloak class="w-full max-w-md">
|
||||||
|
<div class="crm-card">
|
||||||
|
|
||||||
|
<!-- Logo / Title -->
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">CRM System</h1>
|
||||||
|
<p class="text-sm text-slate-500 mt-1" x-text="subtitle"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab switcher -->
|
||||||
|
<div class="flex border-b border-slate-200 mb-6" role="tablist">
|
||||||
|
<button type="button"
|
||||||
|
role="tab"
|
||||||
|
@click="switchTab('login')"
|
||||||
|
:class="tab === 'login'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||||
|
class="flex-1 py-2 px-4 text-sm font-medium border-b-2 transition-colors">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
role="tab"
|
||||||
|
@click="switchTab('register')"
|
||||||
|
:class="tab === 'register'
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700'"
|
||||||
|
class="flex-1 py-2 px-4 text-sm font-medium border-b-2 transition-colors">
|
||||||
|
Registrieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div x-show="error" x-cloak
|
||||||
|
class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded"
|
||||||
|
x-text="error"></div>
|
||||||
|
|
||||||
|
<!-- LOGIN FORM -->
|
||||||
|
<form x-show="tab === 'login'" @submit.prevent="submitLogin()" novalidate>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="crm-label" for="login-email">E-Mail</label>
|
||||||
|
<input id="login-email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
required
|
||||||
|
x-model="loginEmail"
|
||||||
|
class="crm-input"
|
||||||
|
:disabled="loading" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="crm-label" for="login-password">Passwort</label>
|
||||||
|
<input id="login-password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
minlength="1"
|
||||||
|
x-model="loginPassword"
|
||||||
|
class="crm-input"
|
||||||
|
:disabled="loading" />
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
:disabled="loading || !loginEmail || !loginPassword"
|
||||||
|
class="btn-primary w-full flex items-center justify-center gap-2">
|
||||||
|
<span x-show="loading" class="crm-spinner"></span>
|
||||||
|
<span x-text="loading ? 'Anmelden …' : 'Anmelden'"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- REGISTER FORM -->
|
||||||
|
<form x-show="tab === 'register'" @submit.prevent="submitRegister()" novalidate>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="crm-label" for="reg-name">Name</label>
|
||||||
|
<input id="reg-name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minlength="1"
|
||||||
|
maxlength="255"
|
||||||
|
x-model="regName"
|
||||||
|
class="crm-input"
|
||||||
|
:disabled="loading" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="crm-label" for="reg-email">E-Mail</label>
|
||||||
|
<input id="reg-email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
x-model="regEmail"
|
||||||
|
class="crm-input"
|
||||||
|
:disabled="loading" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="crm-label" for="reg-password">Passwort</label>
|
||||||
|
<input id="reg-password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
maxlength="128"
|
||||||
|
autocomplete="new-password"
|
||||||
|
x-model="regPassword"
|
||||||
|
class="crm-input"
|
||||||
|
:disabled="loading" />
|
||||||
|
<p class="text-xs text-slate-500 mt-1">Mindestens 8 Zeichen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="crm-label" for="reg-role">Rolle (optional)</label>
|
||||||
|
<select id="reg-role" x-model="regRole" class="crm-input" :disabled="loading">
|
||||||
|
<option value="sales_rep">Sales-Rep (Standard)</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">
|
||||||
|
Hinweis: Bei der ersten Registrierung (Bootstrap) wird meistens ein
|
||||||
|
Admin-Konto angelegt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
:disabled="loading || !regEmail || !regPassword || !regName"
|
||||||
|
class="btn-primary w-full flex items-center justify-center gap-2">
|
||||||
|
<span x-show="loading" class="crm-spinner"></span>
|
||||||
|
<span x-text="loading ? 'Registrieren …' : 'Konto erstellen'"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-400 mt-6 text-center">
|
||||||
|
API-Endpoint: <code>/api/v1/auth/*</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page-level Alpine component -->
|
||||||
|
<script>
|
||||||
|
function authForm() {
|
||||||
|
return {
|
||||||
|
tab: 'login',
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
loginEmail: '',
|
||||||
|
loginPassword: '',
|
||||||
|
regName: '',
|
||||||
|
regEmail: '',
|
||||||
|
regPassword: '',
|
||||||
|
regRole: 'sales_rep',
|
||||||
|
|
||||||
|
get subtitle() {
|
||||||
|
return this.tab === 'login'
|
||||||
|
? 'Bitte mit E-Mail und Passwort anmelden.'
|
||||||
|
: 'Neues Konto anlegen.';
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTab(t) {
|
||||||
|
this.tab = t;
|
||||||
|
this.error = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitLogin() {
|
||||||
|
this.error = '';
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const auth = Alpine.store('auth');
|
||||||
|
await auth.login(this.loginEmail.trim(), this.loginPassword);
|
||||||
|
// Erfolg → redirect
|
||||||
|
window.location.href = '/app.html';
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Anmeldung fehlgeschlagen.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitRegister() {
|
||||||
|
this.error = '';
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const auth = Alpine.store('auth');
|
||||||
|
await auth.register({
|
||||||
|
email: this.regEmail.trim(),
|
||||||
|
password: this.regPassword,
|
||||||
|
name: this.regName.trim(),
|
||||||
|
role: this.regRole,
|
||||||
|
});
|
||||||
|
window.location.href = '/app.html';
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Registrierung fehlgeschlagen.';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* CRM Frontend API-Wrapper
|
||||||
|
*
|
||||||
|
* Lightweight fetch() wrapper with:
|
||||||
|
* - Automatic JWT injection (Authorization: Bearer <token> from localStorage)
|
||||||
|
* - 401 handling: clears token + redirects to /index.html
|
||||||
|
* - JSON-only requests (FormData→x-www-form-urlencoded via postForm)
|
||||||
|
* - ApiError class with status and parsed body for granular error handling
|
||||||
|
*
|
||||||
|
* All pages import this via <script type="module"> and use `api.get/post/patch/del`.
|
||||||
|
*
|
||||||
|
* NOTE: No x-html, no eval, no untrusted-string-template — XSS-safe by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(response, body) {
|
||||||
|
const message = body?.detail || body?.error?.message || `HTTP ${response.status}`;
|
||||||
|
super(typeof message === 'string' ? message : JSON.stringify(message));
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = response.status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the JWT from localStorage.
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function getToken() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('jwt');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the JWT in localStorage. Caller is responsible for also updating
|
||||||
|
* Alpine.store('auth') so the UI stays in sync.
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
export function setToken(token) {
|
||||||
|
try {
|
||||||
|
if (token) localStorage.setItem('jwt', token);
|
||||||
|
else localStorage.removeItem('jwt');
|
||||||
|
} catch {
|
||||||
|
// localStorage may be disabled in privacy mode — swallow silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core fetch wrapper. Adds JWT, handles 401 (redirect to login), parses JSON.
|
||||||
|
*
|
||||||
|
* @param {string} path Path under API_BASE, e.g. "/accounts/42"
|
||||||
|
* @param {object} [options] fetch() options, with `body` already stringified if needed
|
||||||
|
* @returns {Promise<any>} parsed JSON body, or null for 204
|
||||||
|
* @throws {ApiError} on non-2xx status
|
||||||
|
*/
|
||||||
|
export async function apiFetch(path, options = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip Content-Type for FormData/URLSearchParams bodies (let the browser set it)
|
||||||
|
if (options.body instanceof URLSearchParams || options.body instanceof FormData) {
|
||||||
|
delete headers['Content-Type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
} catch (networkErr) {
|
||||||
|
throw new ApiError(
|
||||||
|
{ status: 0, statusText: 'Network Error' },
|
||||||
|
{ detail: `Netzwerkfehler: ${networkErr.message}` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401 → token invalid, force re-login (but not on the login page itself)
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
// Avoid redirect-loop when already on the login screen
|
||||||
|
if (!window.location.pathname.endsWith('/index.html') &&
|
||||||
|
window.location.pathname !== '/' &&
|
||||||
|
!window.location.pathname.endsWith('/')) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
throw new ApiError(response, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content (e.g. DELETE)
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse JSON for everything else
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = { detail: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(response, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience API. Pages import `api` and call `api.get('/accounts')` etc.
|
||||||
|
*/
|
||||||
|
export const api = {
|
||||||
|
/** GET request */
|
||||||
|
get: (path, options = {}) => apiFetch(path, { ...options, method: 'GET' }),
|
||||||
|
|
||||||
|
/** POST JSON */
|
||||||
|
post: (path, data, options = {}) =>
|
||||||
|
apiFetch(path, { ...options, method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
/** PATCH JSON */
|
||||||
|
patch: (path, data, options = {}) =>
|
||||||
|
apiFetch(path, { ...options, method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
/** DELETE (no body) */
|
||||||
|
del: (path, options = {}) => apiFetch(path, { ...options, method: 'DELETE' }),
|
||||||
|
|
||||||
|
/** POST application/x-www-form-urlencoded (for OAuth2-style login if needed) */
|
||||||
|
postForm: (path, params, options = {}) => {
|
||||||
|
let body;
|
||||||
|
if (params instanceof URLSearchParams) {
|
||||||
|
body = params;
|
||||||
|
} else {
|
||||||
|
body = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params || {})) {
|
||||||
|
if (v !== undefined && v !== null) body.append(k, String(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiFetch(path, { ...options, method: 'POST', body });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Auth helper functions (login, register, logout, refresh).
|
||||||
|
*
|
||||||
|
* These are plain functions that can be called from anywhere (Alpine-stores,
|
||||||
|
* page-level handlers). They do NOT depend on Alpine — only on api.js and
|
||||||
|
* localStorage.
|
||||||
|
*
|
||||||
|
* The Alpine store in store.js wraps these so the UI can react to state changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api, setToken, clearToken, getToken } from './api.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with email + password.
|
||||||
|
* Backend: POST /api/v1/auth/login → {access_token, token_type, expires_in}
|
||||||
|
*
|
||||||
|
* @param {string} email
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<{access_token: string, token_type: string, expires_in: number}>}
|
||||||
|
*/
|
||||||
|
export async function login(email, password) {
|
||||||
|
const res = await api.post('/auth/login', { email, password });
|
||||||
|
if (res && res.access_token) {
|
||||||
|
setToken(res.access_token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user (first-admin bootstrap or admin invites a sales-rep).
|
||||||
|
* Backend: POST /api/v1/auth/register → {user, access_token, token_type, expires_in}
|
||||||
|
*
|
||||||
|
* @param {{email: string, password: string, name: string, role?: string}} payload
|
||||||
|
*/
|
||||||
|
export async function register(payload) {
|
||||||
|
const res = await api.post('/auth/register', payload);
|
||||||
|
if (res && res.access_token) {
|
||||||
|
setToken(res.access_token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout: tell the backend (best-effort), clear local JWT, redirect to login.
|
||||||
|
* Backend: POST /api/v1/auth/logout → {message: "logged out"}
|
||||||
|
*/
|
||||||
|
export async function logout() {
|
||||||
|
if (getToken()) {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout', {});
|
||||||
|
} catch {
|
||||||
|
// best-effort — even if the backend is down, we still clear the local token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearToken();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the JWT using the current (still-valid) token.
|
||||||
|
* Backend: POST /api/v1/auth/refresh → {access_token, token_type, expires_in}
|
||||||
|
*
|
||||||
|
* @returns {Promise<{access_token: string, token_type: string, expires_in: number}>}
|
||||||
|
*/
|
||||||
|
export async function refreshToken() {
|
||||||
|
const res = await api.post('/auth/refresh', {});
|
||||||
|
if (res && res.access_token) {
|
||||||
|
setToken(res.access_token);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the currently-authenticated user's profile.
|
||||||
|
* Backend: GET /api/v1/users/me → UserOut
|
||||||
|
*
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function fetchCurrentUser() {
|
||||||
|
return api.get('/users/me');
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Toast-Notification Component (Alpine.js)
|
||||||
|
*
|
||||||
|
* Renders the global notification queue from $store.notifications as a stack
|
||||||
|
* of toasts in the top-right corner. Pages include this component exactly once,
|
||||||
|
* typically in a shared layout fragment (e.g. app.html and index.html).
|
||||||
|
*
|
||||||
|
* Usage in HTML:
|
||||||
|
* <div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
* <template x-for="n in items" :key="n.id">
|
||||||
|
* <div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
* @click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
* </template>
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* R-5: x-text only, never x-html.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function toastContainer() {
|
||||||
|
return {
|
||||||
|
get items() {
|
||||||
|
return window.Alpine ? Alpine.store('notifications').items : [];
|
||||||
|
},
|
||||||
|
dismiss(id) {
|
||||||
|
if (window.Alpine) Alpine.store('notifications').dismiss(id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the component as a global Alpine.data() factory. Pages can use
|
||||||
|
// `x-data="toastContainer()"` after this module is imported.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
if (window.Alpine && !Alpine.$data_registry) {
|
||||||
|
// no-op: Alpine 3 stores factories on the global namespace
|
||||||
|
}
|
||||||
|
// Alpine 3: Alpine.data(name, factory) registers globally for x-data="name"
|
||||||
|
if (window.Alpine) {
|
||||||
|
window.Alpine.data('toastContainer', toastContainer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default toastContainer;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Alpine.js global stores for the CRM frontend.
|
||||||
|
*
|
||||||
|
* Stores:
|
||||||
|
* - $store.auth: current user, JWT, isAuthenticated, login/logout helpers
|
||||||
|
* - $store.notifications: toast queue (success/error/info/warning)
|
||||||
|
*
|
||||||
|
* Pages access these as $store.auth.user, $store.notifications.success(msg), etc.
|
||||||
|
*
|
||||||
|
* IMPORTANT: This file is loaded as a module AFTER Alpine's CDN script. The
|
||||||
|
* `alpine:init` event fires once Alpine is ready, at which point we register
|
||||||
|
* the stores. Until then, $store.auth etc. are undefined.
|
||||||
|
*
|
||||||
|
* R-5: never use x-html. All user-derived strings go through x-text or
|
||||||
|
* interpolation in templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api, setToken, clearToken, getToken } from './api.js';
|
||||||
|
import { login as authLogin, register as authRegister,
|
||||||
|
logout as authLogout, refreshToken as authRefresh,
|
||||||
|
fetchCurrentUser as authFetchMe } from './auth.js';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
// === AUTH STORE =========================================================
|
||||||
|
Alpine.store('auth', {
|
||||||
|
token: getToken(),
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: !!getToken(),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
/** Called from x-init on protected pages */
|
||||||
|
async init() {
|
||||||
|
if (!this.isAuthenticated) {
|
||||||
|
// Auth-gate: redirect to login
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.fetchUser();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUser() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
this.user = await authFetchMe();
|
||||||
|
} catch (err) {
|
||||||
|
// 401 is already handled by api.js (token cleared, redirect).
|
||||||
|
// For other errors, store them and log out.
|
||||||
|
this.error = err.message || 'Failed to load user';
|
||||||
|
await this.logout();
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(email, password) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
await authLogin(email, password);
|
||||||
|
this.token = getToken();
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
await this.fetchUser();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Login failed';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(payload) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
await authRegister(payload);
|
||||||
|
this.token = getToken();
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
await this.fetchUser();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Registration failed';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await authLogout();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
} finally {
|
||||||
|
this.token = null;
|
||||||
|
this.user = null;
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
clearToken();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
await authRefresh();
|
||||||
|
this.token = getToken();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Token refresh failed';
|
||||||
|
await this.logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Convenience: is the current user an admin? */
|
||||||
|
get isAdmin() {
|
||||||
|
return this.user && this.user.role === 'admin';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// === NOTIFICATIONS STORE ================================================
|
||||||
|
Alpine.store('notifications', {
|
||||||
|
items: [],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a new toast.
|
||||||
|
* @param {string} message
|
||||||
|
* @param {'info'|'success'|'error'|'warning'} [type='info']
|
||||||
|
* @param {number} [timeout=5000] ms before auto-dismiss (0 = no auto-dismiss)
|
||||||
|
*/
|
||||||
|
push(message, type = 'info', timeout = 5000) {
|
||||||
|
const id = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
this.items.push({ id, message: String(message), type });
|
||||||
|
if (timeout > 0) {
|
||||||
|
setTimeout(() => this.dismiss(id), timeout);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss(id) {
|
||||||
|
this.items = this.items.filter(n => n.id !== id);
|
||||||
|
},
|
||||||
|
|
||||||
|
success(msg, timeout) { return this.push(msg, 'success', timeout); },
|
||||||
|
error(msg, timeout) { return this.push(msg, 'error', timeout); },
|
||||||
|
info(msg, timeout) { return this.push(msg, 'info', timeout); },
|
||||||
|
warning(msg, timeout) { return this.push(msg, 'warning', timeout); },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose a tiny global helper so non-module code (e.g. inline page handlers)
|
||||||
|
// can call notifications without an explicit import.
|
||||||
|
window.crmNotify = (msg, type) => {
|
||||||
|
if (window.Alpine && window.Alpine.store('notifications')) {
|
||||||
|
return window.Alpine.store('notifications').push(msg, type || 'info');
|
||||||
|
}
|
||||||
|
console.warn('crmNotify called before Alpine was ready:', msg);
|
||||||
|
};
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Pipeline</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
<script type="module" src="/components/deal-kanban.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html" class="active">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Pipeline</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="dealKanban()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-sm text-slate-600"
|
||||||
|
x-show="!loading">
|
||||||
|
Ziehe Karten zwischen Spalten, um die Stage zu ändern.
|
||||||
|
</div>
|
||||||
|
<div x-show="loading" class="crm-spinner"></div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<input type="number" min="1" x-model="ownerId" placeholder="Owner-ID (optional)"
|
||||||
|
class="crm-input" style="width: 12rem;" />
|
||||||
|
<button @click="filterByOwner()" class="btn-secondary">Filtern</button>
|
||||||
|
<button @click="openCreate()" class="btn-primary">+ New Deal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="mb-4 p-3 bg-red-50 text-red-700 rounded text-sm"
|
||||||
|
x-text="error"></div>
|
||||||
|
|
||||||
|
<!-- Kanban board -->
|
||||||
|
<div class="crm-kanban">
|
||||||
|
<template x-for="stage in stages" :key="stage">
|
||||||
|
<div class="crm-kanban-col"
|
||||||
|
:class="{ 'drag-over': dragOverStage === stage }"
|
||||||
|
@dragover.prevent="onDragOver($event, stage)"
|
||||||
|
@dragleave="onDragLeave(stage)"
|
||||||
|
@drop="onDrop($event, stage)">
|
||||||
|
<div class="flex items-center justify-between mb-2 px-1">
|
||||||
|
<h3 class="font-semibold text-slate-700 text-sm" x-text="stageLabel(stage)"></h3>
|
||||||
|
<span class="crm-badge badge-gray text-xs" x-text="columnCount(stage)"></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-500 px-1 mb-2" x-text="columnValue(stage)"></p>
|
||||||
|
|
||||||
|
<template x-for="deal in (dealsByStage[stage] || [])" :key="deal.id">
|
||||||
|
<div class="crm-kanban-card"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart($event, deal.id)">
|
||||||
|
<p class="font-medium text-slate-900" x-text="deal.title"></p>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">
|
||||||
|
<span x-text="formatCurrency(deal.value)"></span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-1" x-show="deal.close_date">
|
||||||
|
Close: <span x-text="formatDate(deal.close_date)"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="columnCount(stage) === 0"
|
||||||
|
class="text-center text-xs text-slate-400 py-4">
|
||||||
|
Keine Deals
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div x-show="showCreate" x-cloak class="crm-modal-backdrop" @click.self="closeCreate()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Neuer Deal</h2>
|
||||||
|
<form @submit.prevent="submitCreate()">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Titel *</label>
|
||||||
|
<input type="text" required maxlength="255"
|
||||||
|
x-model="createForm.title" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Wert (€)</label>
|
||||||
|
<input type="number" min="0" step="0.01"
|
||||||
|
x-model.number="createForm.value" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Währung</label>
|
||||||
|
<input type="text" maxlength="3" minlength="3"
|
||||||
|
x-model="createForm.currency" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Stage</label>
|
||||||
|
<select x-model="createForm.stage" class="crm-input">
|
||||||
|
<template x-for="s in stages" :key="s">
|
||||||
|
<option :value="s" x-text="stageLabel(s)"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Account-ID *</label>
|
||||||
|
<input type="number" min="1" required
|
||||||
|
x-model="createForm.account_id" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Close-Datum</label>
|
||||||
|
<input type="date" x-model="createForm.close_date" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="createError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="creating" class="btn-primary"
|
||||||
|
x-text="creating ? 'Speichere …' : 'Anlegen'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Org-Einstellungen</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" class="active" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Org-Einstellungen (Admin)</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6 max-w-2xl" x-data="orgSettings()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Admin-Gate -->
|
||||||
|
<div x-show="checked && !$store.auth.isAdmin" class="crm-card text-center py-8">
|
||||||
|
<p class="text-red-600 font-medium">Zugriff verweigert.</p>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">Diese Seite ist nur für Admins.</p>
|
||||||
|
<a href="/app.html" class="btn-primary inline-block mt-4">Zum Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!checked" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="checked && $store.auth.isAdmin" class="crm-card">
|
||||||
|
|
||||||
|
<!-- Hinweis: Coming in v1.1 -->
|
||||||
|
<div class="mb-4 p-4 bg-amber-50 border border-amber-200 text-amber-800 text-sm rounded">
|
||||||
|
<strong>Hinweis:</strong> Das Bearbeiten von Organisationseinstellungen ist
|
||||||
|
für v1.1 geplant. In v1 ist der <code>PATCH /api/v1/orgs/{id}</code>-Endpoint
|
||||||
|
noch nicht verfügbar. Die Form ist deshalb deaktiviert (nur Read-View).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Aktuelle Organisation</h2>
|
||||||
|
|
||||||
|
<div x-show="loading" class="text-center py-6">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && org" class="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Org-ID</label>
|
||||||
|
<input type="text" disabled :value="org?.id || ''" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Name</label>
|
||||||
|
<input type="text" disabled :value="org?.name || ''" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Logo-URL</label>
|
||||||
|
<input type="text" disabled :value="org?.logo_url || '(nicht gesetzt)'" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Default-Currency</label>
|
||||||
|
<input type="text" disabled :value="org?.default_currency || 'EUR'" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-slate-400 mt-4">
|
||||||
|
Quelle: <code>GET /api/v1/orgs/{id}</code> (read-only in v1).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function orgSettings() {
|
||||||
|
return {
|
||||||
|
checked: false,
|
||||||
|
org: null,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const auth = Alpine.store('auth');
|
||||||
|
if (!auth.user) { try { await auth.fetchUser(); } catch {} }
|
||||||
|
this.checked = true;
|
||||||
|
if (!auth.isAdmin) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
// Read own org — backend: GET /orgs/{id} where id = current_user.org_id
|
||||||
|
const orgId = auth.user && auth.user.org_id;
|
||||||
|
if (orgId) {
|
||||||
|
try { this.org = await api.get(`/orgs/${orgId}`); }
|
||||||
|
catch {
|
||||||
|
// Fallback: synthesize from user
|
||||||
|
this.org = { id: orgId, name: '(Org-Name unbekannt)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Lade-Fehler.');
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – Mein Profil</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html" class="active">Profil</a>
|
||||||
|
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">Mein Profil</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6 max-w-2xl" x-data="profileForm()" x-init="init()">
|
||||||
|
|
||||||
|
<div x-show="loading" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading && user" class="crm-card">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Profil bearbeiten</h2>
|
||||||
|
|
||||||
|
<!-- Read-only fields -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">E-Mail (nicht änderbar)</label>
|
||||||
|
<input type="email" disabled :value="user?.email || ''" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="crm-label">Rolle (nicht änderbar)</label>
|
||||||
|
<input type="text" disabled :value="user?.role || ''" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit()">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Name *</label>
|
||||||
|
<input type="text" required minlength="1" maxlength="255"
|
||||||
|
x-model="form.name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Avatar-URL</label>
|
||||||
|
<input type="text" maxlength="1024"
|
||||||
|
x-model="form.avatar_url" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="notif" x-model="form.email_notifications" />
|
||||||
|
<label for="notif" class="text-sm">E-Mail-Benachrichtigungen erhalten</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="error"></div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
|
<button type="submit" :disabled="saving" class="btn-primary"
|
||||||
|
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function profileForm() {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
form: { name: '', avatar_url: '', email_notifications: true },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// wait for auth store to load user
|
||||||
|
const auth = Alpine.store('auth');
|
||||||
|
if (!auth.user) {
|
||||||
|
try { await auth.fetchUser(); } catch { /* 401 → redirect */ }
|
||||||
|
}
|
||||||
|
this.user = auth.user;
|
||||||
|
if (this.user) {
|
||||||
|
this.form.name = this.user.name || '';
|
||||||
|
this.form.avatar_url = this.user.avatar_url || '';
|
||||||
|
this.form.email_notifications = this.user.email_notifications !== false;
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
this.saving = true; this.error = '';
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const payload = {
|
||||||
|
name: this.form.name,
|
||||||
|
email_notifications: this.form.email_notifications,
|
||||||
|
};
|
||||||
|
if (this.form.avatar_url) payload.avatar_url = this.form.avatar_url;
|
||||||
|
const updated = await api.patch(`/users/${this.user.id}`, payload);
|
||||||
|
Alpine.store('auth').user = updated;
|
||||||
|
Alpine.store('notifications').success('Profil aktualisiert.');
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message || 'Fehler beim Speichern.';
|
||||||
|
} finally { this.saving = false; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>CRM – User-Verwaltung</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<script type="module" src="/js/store.js"></script>
|
||||||
|
<script type="module" src="/js/components/notifications.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-slate-50">
|
||||||
|
|
||||||
|
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||||
|
<template x-for="n in items" :key="n.id">
|
||||||
|
<div class="crm-toast" :class="'toast-' + n.type"
|
||||||
|
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||||
|
|
||||||
|
<aside class="crm-sidebar">
|
||||||
|
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||||
|
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||||
|
</div>
|
||||||
|
<nav class="mt-2">
|
||||||
|
<a href="/app.html">Dashboard</a>
|
||||||
|
<a href="/accounts.html">Accounts</a>
|
||||||
|
<a href="/contacts.html">Contacts</a>
|
||||||
|
<a href="/pipeline.html">Pipeline</a>
|
||||||
|
<a href="/activities.html">Activities</a>
|
||||||
|
<a href="/settings-profile.html">Profil</a>
|
||||||
|
<a href="/settings-users.html" class="active" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||||
|
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800">User-Verwaltung (Admin)</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="text-slate-600">
|
||||||
|
<strong class="text-slate-900"
|
||||||
|
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||||
|
</span>
|
||||||
|
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-6" x-data="userAdmin()" x-init="init()">
|
||||||
|
|
||||||
|
<!-- Admin-Gate -->
|
||||||
|
<div x-show="checked && !$store.auth.isAdmin" class="crm-card text-center py-8">
|
||||||
|
<p class="text-red-600 font-medium">Zugriff verweigert.</p>
|
||||||
|
<p class="text-slate-500 text-sm mt-1">Diese Seite ist nur für Admins.</p>
|
||||||
|
<a href="/app.html" class="btn-primary inline-block mt-4">Zum Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!checked" class="crm-card text-center py-8">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="checked && $store.auth.isAdmin">
|
||||||
|
<div class="flex justify-end mb-4">
|
||||||
|
<button @click="openCreate()" class="btn-primary">+ New User</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="loading" class="crm-card text-center py-6">
|
||||||
|
<div class="crm-spinner mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="!loading" class="overflow-x-auto">
|
||||||
|
<table class="crm-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>E-Mail</th><th>Rolle</th><th>Aktionen</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="u in users" :key="u.id">
|
||||||
|
<tr>
|
||||||
|
<td x-text="u.id"></td>
|
||||||
|
<td x-text="u.name"></td>
|
||||||
|
<td x-text="u.email"></td>
|
||||||
|
<td><span class="crm-badge badge-blue" x-text="u.role"></span></td>
|
||||||
|
<td class="space-x-1">
|
||||||
|
<button @click="openEdit(u)" class="btn-secondary text-xs py-1 px-2">Edit</button>
|
||||||
|
<button @click="confirmDelete(u)" class="btn-danger text-xs py-1 px-2">Del</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr x-show="!loading && users.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-slate-500 py-4">Keine User.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create / Edit modal -->
|
||||||
|
<div x-show="showForm" x-cloak class="crm-modal-backdrop" @click.self="closeForm()">
|
||||||
|
<div class="crm-modal p-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4" x-text="formTitle"></h2>
|
||||||
|
<form @submit.prevent="submitForm()">
|
||||||
|
<div class="mb-3" x-show="!editing">
|
||||||
|
<label class="crm-label">E-Mail *</label>
|
||||||
|
<input type="email" required :disabled="editing"
|
||||||
|
x-model="form.email" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" x-show="!editing">
|
||||||
|
<label class="crm-label">Passwort * (min. 8 Zeichen)</label>
|
||||||
|
<input type="password" required minlength="8" maxlength="128"
|
||||||
|
:disabled="editing"
|
||||||
|
x-model="form.password" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Name *</label>
|
||||||
|
<input type="text" required maxlength="255"
|
||||||
|
x-model="form.name" class="crm-input" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="crm-label">Rolle</label>
|
||||||
|
<select x-model="form.role" class="crm-input">
|
||||||
|
<option value="sales_rep">Sales-Rep</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div x-show="formError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||||
|
x-text="formError"></div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" @click="closeForm()" class="btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" :disabled="saving" class="btn-primary"
|
||||||
|
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function userAdmin() {
|
||||||
|
return {
|
||||||
|
checked: false,
|
||||||
|
users: [],
|
||||||
|
loading: false,
|
||||||
|
showForm: false,
|
||||||
|
editing: false,
|
||||||
|
saving: false,
|
||||||
|
formError: '',
|
||||||
|
form: { id: null, email: '', password: '', name: '', role: 'sales_rep' },
|
||||||
|
|
||||||
|
get formTitle() { return this.editing ? 'User bearbeiten' : 'Neuen User anlegen'; },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// wait for auth
|
||||||
|
const auth = Alpine.store('auth');
|
||||||
|
if (!auth.user) { try { await auth.fetchUser(); } catch {} }
|
||||||
|
this.checked = true;
|
||||||
|
if (!auth.isAdmin) return; // admin-gate shows message
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
const r = await api.get('/users/?limit=200');
|
||||||
|
this.users = Array.isArray(r) ? r : (r.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Lade-Fehler.');
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
openCreate() {
|
||||||
|
this.editing = false;
|
||||||
|
this.form = { id: null, email: '', password: '', name: '', role: 'sales_rep' };
|
||||||
|
this.formError = '';
|
||||||
|
this.showForm = true;
|
||||||
|
},
|
||||||
|
openEdit(u) {
|
||||||
|
this.editing = true;
|
||||||
|
this.form = { id: u.id, email: u.email, password: '', name: u.name, role: u.role };
|
||||||
|
this.formError = '';
|
||||||
|
this.showForm = true;
|
||||||
|
},
|
||||||
|
closeForm() { this.showForm = false; },
|
||||||
|
|
||||||
|
async submitForm() {
|
||||||
|
this.saving = true; this.formError = '';
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
if (this.editing) {
|
||||||
|
const p = { name: this.form.name, role: this.form.role };
|
||||||
|
await api.patch(`/users/${this.form.id}`, p);
|
||||||
|
Alpine.store('notifications').success('User aktualisiert.');
|
||||||
|
} else {
|
||||||
|
await api.post('/users/', {
|
||||||
|
email: this.form.email,
|
||||||
|
password: this.form.password,
|
||||||
|
name: this.form.name,
|
||||||
|
role: this.form.role,
|
||||||
|
});
|
||||||
|
Alpine.store('notifications').success('User angelegt.');
|
||||||
|
}
|
||||||
|
this.showForm = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
this.formError = err.message || 'Fehler.';
|
||||||
|
} finally { this.saving = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmDelete(u) {
|
||||||
|
if (!confirm(`User "${u.name}" wirklich löschen?`)) return;
|
||||||
|
try {
|
||||||
|
const { api } = await import('/js/api.js');
|
||||||
|
await api.del(`/users/${u.id}`);
|
||||||
|
Alpine.store('notifications').success('User gelöscht.');
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
Alpine.store('notifications').error(err.message || 'Fehler.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Frontend-Asset Tests: verify JS modules and CSS are syntactically valid
|
||||||
|
and export / register the expected symbols.
|
||||||
|
|
||||||
|
These tests perform static text-level checks on the JS / CSS files — no
|
||||||
|
runtime execution, no nodejs dependency. They are sufficient to catch:
|
||||||
|
- Missing exports (e.g. `export { api, ApiError }` removed from api.js)
|
||||||
|
- Missing global Alpine.data() registrations for the components
|
||||||
|
- CSS variables removed from the custom-overrides file
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
WEBUI = Path(__file__).resolve().parent.parent / "app" / "webui"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(rel: str) -> str:
|
||||||
|
p = WEBUI / rel
|
||||||
|
assert p.exists(), f"Missing asset: {p}"
|
||||||
|
return p.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# api.js
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_api_js_exports_api_and_ApiError():
|
||||||
|
content = _read("js/api.js")
|
||||||
|
assert "export class ApiError" in content
|
||||||
|
assert "export const api" in content or "export default api" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_js_uses_localStorage_for_jwt():
|
||||||
|
"""R-1 / architecture: JWT must live in localStorage."""
|
||||||
|
content = _read("js/api.js")
|
||||||
|
assert "localStorage" in content
|
||||||
|
assert "jwt" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_js_handles_401_redirect():
|
||||||
|
"""401 must clear the token and redirect to /index.html."""
|
||||||
|
content = _read("js/api.js")
|
||||||
|
assert "401" in content
|
||||||
|
assert "/index.html" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_js_handles_204_no_content():
|
||||||
|
content = _read("js/api.js")
|
||||||
|
assert "204" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# store.js
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_store_js_defines_auth_and_notifications():
|
||||||
|
content = _read("js/store.js")
|
||||||
|
assert "Alpine.store('auth'" in content
|
||||||
|
assert "Alpine.store('notifications'" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_js_auth_has_login_and_logout():
|
||||||
|
content = _read("js/store.js")
|
||||||
|
assert "login" in content
|
||||||
|
assert "logout" in content
|
||||||
|
assert "fetchUser" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_js_notifications_has_push_and_dismiss():
|
||||||
|
content = _read("js/store.js")
|
||||||
|
assert "push" in content
|
||||||
|
assert "dismiss" in content
|
||||||
|
assert "success" in content
|
||||||
|
assert "error" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# app.css
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_app_css_contains_tailwind_overrides():
|
||||||
|
content = _read("css/app.css")
|
||||||
|
# The file defines custom CSS variables (--crm-primary, etc.)
|
||||||
|
assert "--crm-primary" in content
|
||||||
|
assert "--crm-success" in content
|
||||||
|
assert "--crm-danger" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_css_has_loading_spinner():
|
||||||
|
content = _read("css/app.css")
|
||||||
|
assert "crm-spinner" in content
|
||||||
|
assert "@keyframes crm-spin" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_css_has_toast_styles():
|
||||||
|
content = _read("css/app.css")
|
||||||
|
assert "crm-toast" in content
|
||||||
|
assert "toast-success" in content
|
||||||
|
assert "toast-error" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alpine components
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COMPONENTS = [
|
||||||
|
("account-list.js", "accountList"),
|
||||||
|
("deal-kanban.js", "dealKanban"),
|
||||||
|
("activity-list.js", "activityList"),
|
||||||
|
("dashboard-kpis.js", "dashboardKpis"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filename,factory", COMPONENTS)
|
||||||
|
def test_alpine_component_defined(filename, factory):
|
||||||
|
"""Each component file must define a named factory and register it
|
||||||
|
globally with Alpine.data(name, factory)."""
|
||||||
|
content = _read(f"components/{filename}")
|
||||||
|
# factory function definition
|
||||||
|
assert f"export function {factory}" in content, \
|
||||||
|
f"{filename} missing `export function {factory}()`"
|
||||||
|
# Alpine.data() registration
|
||||||
|
assert f"Alpine.data('{factory}'" in content, \
|
||||||
|
f"{filename} missing `Alpine.data('{factory}', …)` registration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deal_kanban_handles_drag_and_drop():
|
||||||
|
content = _read("components/deal-kanban.js")
|
||||||
|
assert "draggable" in content or "dataTransfer" in content
|
||||||
|
assert "onDrop" in content
|
||||||
|
assert "/deals/" in content and "/stage" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_activity_list_supports_overdue_filter():
|
||||||
|
content = _read("components/activity-list.js")
|
||||||
|
assert "overdue" in content
|
||||||
|
assert "/activities/" in content
|
||||||
|
assert "/complete" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_kpis_loads_both_kpis_and_feed():
|
||||||
|
content = _read("components/dashboard-kpis.js")
|
||||||
|
assert "/dashboard/kpis" in content
|
||||||
|
assert "/dashboard/feed" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_list_uses_pagination():
|
||||||
|
content = _read("components/account-list.js")
|
||||||
|
assert "skip" in content
|
||||||
|
assert "limit" in content
|
||||||
|
assert "q" in content
|
||||||
|
assert "/accounts/" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# notifications.js (toast component)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_notifications_js_registers_toast_container():
|
||||||
|
content = _read("js/components/notifications.js")
|
||||||
|
assert "toastContainer" in content
|
||||||
|
assert "Alpine.data" in content
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# auth.js
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_auth_js_exports_login_logout_register():
|
||||||
|
content = _read("js/auth.js")
|
||||||
|
assert "export async function login" in content
|
||||||
|
assert "export async function logout" in content
|
||||||
|
assert "export async function register" in content
|
||||||
|
assert "export async function refreshToken" in content
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Frontend-Smoke-Tests: verify all 13 HTML pages exist and contain the
|
||||||
|
expected keywords/structure.
|
||||||
|
|
||||||
|
These tests read the HTML files directly from `app/webui/` (rather than
|
||||||
|
hitting the FastAPI server) because the static-file mount is added in a
|
||||||
|
later phase by the orchestrator. Once that mount is in place, the same
|
||||||
|
checks could be re-implemented as `httpx.AsyncClient.get("/…")`.
|
||||||
|
|
||||||
|
Each test loads exactly one page and asserts:
|
||||||
|
- file exists and is non-empty
|
||||||
|
- contains the page's title text (so a typo / removal is caught)
|
||||||
|
- has Tailwind-CDN, Alpine-CDN, and the app.css link
|
||||||
|
- has no x-html attribute (R-5)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
WEBUI_DIR = Path(__file__).resolve().parent.parent / "app" / "webui"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name: str) -> str:
|
||||||
|
"""Read a file from the webui directory."""
|
||||||
|
p = WEBUI_DIR / name
|
||||||
|
assert p.exists(), f"Expected frontend file {p} to exist"
|
||||||
|
return p.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Each page: (filename, [required substrings])
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PAGES = [
|
||||||
|
("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]),
|
||||||
|
("app.html", ["Dashboard", "Logout", "Pipeline"]),
|
||||||
|
("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]),
|
||||||
|
("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]),
|
||||||
|
("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]),
|
||||||
|
("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]),
|
||||||
|
("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]),
|
||||||
|
("activities.html", ["Activities", "+ New Activity", "Fällig"]),
|
||||||
|
("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]),
|
||||||
|
("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]),
|
||||||
|
("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]),
|
||||||
|
("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]),
|
||||||
|
# Dashboard: ships inside app.html. We test the marker text directly there.
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filename,required", PAGES)
|
||||||
|
def test_frontend_page_loads(filename, required):
|
||||||
|
"""Each HTML page must exist and contain its identifying substrings."""
|
||||||
|
content = _read(filename)
|
||||||
|
assert len(content) > 200, f"{filename} seems empty or too small"
|
||||||
|
for needle in required:
|
||||||
|
assert needle in content, f"{filename} missing required string {needle!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_html_contains_login_and_register():
|
||||||
|
"""Index page (auth screen) must show both Login and Register tabs."""
|
||||||
|
content = _read("index.html")
|
||||||
|
assert "Login" in content
|
||||||
|
assert "Registrieren" in content
|
||||||
|
# Alpine-CDN required
|
||||||
|
assert "alpinejs" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_html_contains_dashboard_and_logout():
|
||||||
|
"""app.html is the post-login shell — must show Dashboard + Logout."""
|
||||||
|
content = _read("app.html")
|
||||||
|
assert "Dashboard" in content
|
||||||
|
assert "Logout" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_13_pages_exist():
|
||||||
|
"""Exactly 13 HTML pages must be present (index + app + 10 + 404)."""
|
||||||
|
pages = sorted(p.name for p in WEBUI_DIR.glob("*.html"))
|
||||||
|
assert len(pages) == 13, f"Expected 13 pages, found {len(pages)}: {pages}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_section_inside_app_html():
|
||||||
|
"""The dashboard content is rendered inside app.html (default landing)."""
|
||||||
|
content = _read("app.html")
|
||||||
|
# The 4 KPI labels from the dashboard section
|
||||||
|
assert "Open Deals" in content
|
||||||
|
assert "Pipeline Value" in content
|
||||||
|
assert "Won this Month" in content
|
||||||
|
assert "Conversion Rate" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_page_links_app_css():
|
||||||
|
"""Every page must reference /css/app.css."""
|
||||||
|
for filename, _ in PAGES:
|
||||||
|
content = _read(filename)
|
||||||
|
assert "/css/app.css" in content, f"{filename} missing /css/app.css link"
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_page_includes_alpine_cdn():
|
||||||
|
"""Every page must include the Alpine.js CDN script."""
|
||||||
|
for filename, _ in PAGES:
|
||||||
|
content = _read(filename)
|
||||||
|
assert "alpinejs" in content, f"{filename} missing Alpine.js CDN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_page_includes_tailwind_cdn():
|
||||||
|
"""Every page must include the Tailwind-CDN script (dev-mode)."""
|
||||||
|
for filename, _ in PAGES:
|
||||||
|
content = _read(filename)
|
||||||
|
assert "cdn.tailwindcss.com" in content, f"{filename} missing Tailwind CDN"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Frontend-Security Tests: XSS-mitigation, JWT-handling, CSP-middleware.
|
||||||
|
|
||||||
|
These tests enforce the rules from architecture Section 13 + task graph R-5:
|
||||||
|
|
||||||
|
- R-5: No `x-html` attribute anywhere in any HTML template (only x-text).
|
||||||
|
- R-1: JWT must be read from / written to localStorage (not cookies, not sessionStorage).
|
||||||
|
- 13.3: CSP-Header is configured in app/main.py (security_headers_middleware),
|
||||||
|
not in a reverse-proxy config file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
WEBUI = ROOT / "app" / "webui"
|
||||||
|
MAIN_PY = ROOT / "app" / "main.py"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R-5: no x-html in any HTML template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Match `x-html` as a whole word (attribute, not part of `x-htmlSomething`).
|
||||||
|
# We deliberately allow `x-html="..."` to fail loudly, and we also catch
|
||||||
|
# any camel-cased variant like `xHtml` for paranoia.
|
||||||
|
XHTML_PATTERN = re.compile(r"\bx[-_]?html\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_html_files() -> list[Path]:
|
||||||
|
return sorted(WEBUI.glob("*.html"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_x_html_in_alpine_templates():
|
||||||
|
"""R-5: no `x-html` attribute anywhere in any HTML file."""
|
||||||
|
offenders: list[tuple[str, int, str]] = []
|
||||||
|
for path in _all_html_files():
|
||||||
|
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
# Strip HTML comments to avoid false positives in documentation
|
||||||
|
stripped = re.sub(r"<!--.*?-->", "", line)
|
||||||
|
if XHTML_PATTERN.search(stripped):
|
||||||
|
offenders.append((path.name, lineno, line.strip()[:120]))
|
||||||
|
assert not offenders, (
|
||||||
|
"x-html usage is forbidden (R-5); offenders:\n"
|
||||||
|
+ "\n".join(f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_innerHTML_in_alpine_pages():
|
||||||
|
"""Defence in depth: also flag direct `innerHTML` assignments."""
|
||||||
|
for path in _all_html_files():
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
# allow within <script> blocks only if commented out / disabled
|
||||||
|
assert "innerHTML" not in content, \
|
||||||
|
f"{path.name} contains innerHTML which is forbidden (R-5)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_x_text_is_used_instead():
|
||||||
|
"""Sanity check: the auth/dashboard pages use x-text for user-derived strings."""
|
||||||
|
index = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||||
|
# The error message div should use x-text (so user input never gets HTML-parsed)
|
||||||
|
assert "x-text=\"error\"" in index or 'x-text="error"' in index
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# R-1: JWT in localStorage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_jwt_uses_localStorage():
|
||||||
|
"""api.js must read/write the JWT in localStorage, not cookies/sessionStorage."""
|
||||||
|
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
||||||
|
# Must read the token from localStorage
|
||||||
|
assert "localStorage.getItem('jwt')" in api
|
||||||
|
# Must persist the token back to localStorage on login
|
||||||
|
assert "localStorage.setItem('jwt'" in api
|
||||||
|
# Must clear on logout
|
||||||
|
assert "localStorage.removeItem('jwt')" in api
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_not_in_sessionStorage():
|
||||||
|
"""Defence in depth: do NOT use sessionStorage for the JWT."""
|
||||||
|
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
||||||
|
assert "sessionStorage" not in api, "api.js must not use sessionStorage for the JWT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_not_in_cookies():
|
||||||
|
"""Defence in depth: do NOT put the JWT in document.cookie."""
|
||||||
|
for js in WEBUI.rglob("*.js"):
|
||||||
|
content = js.read_text(encoding="utf-8")
|
||||||
|
assert "document.cookie" not in content, \
|
||||||
|
f"{js.relative_to(ROOT)} must not use document.cookie for the JWT"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13.3: CSP-Header in main.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_csp_header_in_main_py():
|
||||||
|
"""app/main.py must wire up a middleware (or response-header decorator)
|
||||||
|
that sets Content-Security-Policy. We don't run the server here — we
|
||||||
|
just verify the configuration is present in code."""
|
||||||
|
assert MAIN_PY.exists(), f"Missing {MAIN_PY}"
|
||||||
|
content = MAIN_PY.read_text(encoding="utf-8")
|
||||||
|
# The middleware / decorator must mention the CSP header value
|
||||||
|
assert "Content-Security-Policy" in content, \
|
||||||
|
"app/main.py does not set Content-Security-Policy"
|
||||||
|
# The header must allow the Tailwind CDN at minimum
|
||||||
|
assert "cdn.tailwindcss.com" in content, \
|
||||||
|
"CSP-Header must allow https://cdn.tailwindcss.com in script-src"
|
||||||
|
# And block scripts from arbitrary origins
|
||||||
|
assert "default-src 'self'" in content or 'default-src "self"' in content, \
|
||||||
|
"CSP-Header must set default-src 'self'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_blocks_object_embedding():
|
||||||
|
"""object-src 'none' is part of the agreed lockdown."""
|
||||||
|
content = MAIN_PY.read_text(encoding="utf-8")
|
||||||
|
assert "object-src 'none'" in content, \
|
||||||
|
"CSP-Header must set object-src 'none'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_uses_starlette_middleware():
|
||||||
|
"""Per architecture: CSP is set in a FastAPI/Starlette middleware,
|
||||||
|
not in nginx / a response handler."""
|
||||||
|
content = MAIN_PY.read_text(encoding="utf-8")
|
||||||
|
assert "@app.middleware(\"http\")" in content or '@app.middleware("http")' in content, \
|
||||||
|
"CSP-Header must be set in an @app.middleware(\"http\") decorator"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth-gate on protected pages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROTECTED_PAGES = [
|
||||||
|
"accounts.html",
|
||||||
|
"accounts-detail.html",
|
||||||
|
"contacts.html",
|
||||||
|
"contacts-detail.html",
|
||||||
|
"pipeline.html",
|
||||||
|
"activities.html",
|
||||||
|
"settings-profile.html",
|
||||||
|
"settings-users.html",
|
||||||
|
"settings-org.html",
|
||||||
|
"app.html",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filename", PROTECTED_PAGES)
|
||||||
|
def test_protected_page_has_auth_gate(filename):
|
||||||
|
"""Each non-public page must call Alpine.store('auth').init() in its
|
||||||
|
outermost x-data, which acts as the auth-gate."""
|
||||||
|
content = (WEBUI / filename).read_text(encoding="utf-8")
|
||||||
|
# The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"`
|
||||||
|
# or `x-init="init()"` on a component that checks the JWT.
|
||||||
|
assert "Alpine.store('auth').init()" in content or \
|
||||||
|
"$store.auth.init()" in content, \
|
||||||
|
f"{filename} has no Alpine.store('auth').init() auth-gate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_page_has_no_auth_gate():
|
||||||
|
"""index.html is the LOGIN page itself — it must NOT redirect to itself."""
|
||||||
|
content = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||||
|
# The auth store's init() redirects to /index.html when no JWT exists,
|
||||||
|
# so it must NOT be called from the login page.
|
||||||
|
assert "Alpine.store('auth').init()" not in content, \
|
||||||
|
"index.html (login page) must not call auth.init() (would cause loop)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_404_page_has_no_auth_gate():
|
||||||
|
"""404 page is reachable without auth (so users can find their way back)."""
|
||||||
|
content = (WEBUI / "404.html").read_text(encoding="utf-8")
|
||||||
|
# The 404 must not call auth.init() — it is intentionally public.
|
||||||
|
assert "Alpine.store('auth').init()" not in content, \
|
||||||
|
"404.html must not call auth.init() (public page)"
|
||||||
Reference in New Issue
Block a user