feat(phase-4c): frontend, 13 pages, alpine+tailwind, tests

This commit is contained in:
CRM Bot
2026-06-03 22:02:56 +00:00
parent 53cbcde729
commit 0f11e4a836
26 changed files with 4338 additions and 0 deletions
+29
View File
@@ -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>
+350
View File
@@ -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>
+189
View File
@@ -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>
+220
View File
@@ -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>
+145
View File
@@ -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>
+149
View File
@@ -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;
+206
View File
@@ -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;
+47
View File
@@ -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));
});
+85
View File
@@ -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;
+207
View File
@@ -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;
+268
View File
@@ -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>
+231
View File
@@ -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>
+255
View File
@@ -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; }
+145
View File
@@ -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>
+221
View File
@@ -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>
+158
View File
@@ -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;
+81
View File
@@ -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');
}
+44
View File
@@ -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;
+155
View File
@@ -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);
};
+167
View File
@@ -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>
+142
View File
@@ -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>
+148
View File
@@ -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>
+231
View File
@@ -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>