Upload app/webui/accounts-detail.html
This commit is contained in:
@@ -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>
|
||||||
Reference in New Issue
Block a user