Files
foodlinkk-command-center/cockpit/templates/clients.html
T
2026-06-09 10:41:13 +00:00

541 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
{% endblock %}
{% block content %}
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header">
<div>
<h1 x-text="$t('clients.title')">CRM · Klanten</h1>
<p class="subtitle page-tip" x-text="$t('clients.subtitle')"></p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/deals">Deals →</a>
</div>
</header>
<div class="clients-kpi-row">
<div class="clients-kpi"><span>Totaal klanten</span><strong x-text="stats.total||0"></strong></div>
<div class="clients-kpi"><span>Actief</span><strong x-text="stats.active||0"></strong></div>
<div class="clients-kpi"><span>MRR schatting</span><strong x-text="fmtEur(stats.mrr_total)"></strong></div>
<div class="clients-kpi"><span>Pipeline</span><strong x-text="fmtEur(stats.pipeline_value)"></strong></div>
<div class="clients-kpi"><span>Filiaal-koppelingen</span><strong x-text="stats.store_links||0"></strong></div>
</div>
<div class="clients-tabs">
<button class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'" x-text="$t('clients.tab_kanban')">Kanban</button>
<button class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'" x-text="$t('clients.tab_list')">Lijst</button>
<button class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; searchStores()" x-text="$t('clients.tab_stores')">Supermarkten</button>
<div class="clients-tab-actions">
<button class="btn btn-primary btn-sm" @click="openClientModal()" x-text="'+ ' + $t('clients.new')">+ Nieuwe klant</button>
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
</div>
</div>
<!-- Kanban -->
<div x-show="tab==='kanban'" x-cloak>
<div class="clients-kanban">
<template x-for="st in stages" :key="st.id">
<div class="clients-kanban-col">
<h3><span x-text="st.label"></span> <span class="count" x-text="stageCount(st.id)"></span></h3>
<template x-for="c in clientsByStage(st.id)" :key="c.id">
<div class="clients-card" :class="'stage-'+c.stage" @click="openDetail(c)">
<strong x-text="c.name"></strong>
<div class="clients-card-meta">
<span x-show="c.contact" x-text="c.contact"></span>
<span x-show="c.sector"> · <span x-text="c.sector"></span></span>
</div>
<div class="clients-card-meta" x-show="c.email" x-text="c.email"></div>
<div class="clients-card-tags">
<span class="clients-tag mrr" x-show="c.mrr_estimate" x-text="'€'+Number(c.mrr_estimate).toLocaleString('nl-NL')+'/mnd'"></span>
<span class="clients-tag" x-show="c.store_count" x-text="c.store_count+' filialen'"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- List -->
<div x-show="tab==='list'" x-cloak>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek naam, contact, e-mail…" x-model="filterQ" @input="applyFilter()" />
<select x-model="filterStage" @change="applyFilter()">
<option value="">Alle stages</option>
<template x-for="st in stages" :key="'f'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
<select x-model="filterSector" @change="applyFilter()">
<option value="">Alle sectoren</option>
<template x-for="s in sectorOptions" :key="s"><option :value="s" x-text="s"></option></template>
</select>
</div>
<table class="data-table">
<thead><tr>
<th>Naam</th><th>Contact</th><th>E-mail</th><th>Sector</th><th>Stage</th><th>MRR</th><th>Filialen</th><th></th>
</tr></thead>
<tbody>
<template x-for="c in filtered" :key="'l'+c.id">
<tr class="clickable-row" @click="openDetail(c)">
<td><strong x-text="c.name"></strong></td>
<td x-text="c.contact||'—'"></td>
<td x-text="c.email||'—'"></td>
<td x-text="c.sector||'—'"></td>
<td><span class="badge" x-text="stageLabel(c.stage)"></span></td>
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
<td x-text="c.store_count||0"></td>
<td>
<button class="btn btn-sm" @click.stop="editClient(c)">Bewerk</button>
<button class="btn btn-sm btn-reject" @click.stop="removeClient(c.id)">×</button>
</td>
</tr>
</template>
</tbody>
</table>
<p class="clients-empty" x-show="!filtered.length">Geen klanten gevonden.</p>
</div>
<!-- Supermarkten -->
<div x-show="tab==='stores'" x-cloak>
<p class="page-tip" style="margin-bottom:0.75rem">Zoek een filiaal, vul ontbrekende data aan (contact, halal, manager) en koppel aan een klant.</p>
<div class="clients-toolbar">
<input type="search" placeholder="Zoek filiaal, keten, stad…" x-model="storeQ" @keyup.enter="searchStores()" />
<select x-model="storeChain" @change="searchStores()">
<option value="">Alle ketens</option>
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
</select>
<select x-model="storeProvince" @change="searchStores()">
<option value="">Alle provincies</option>
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
</select>
<button class="btn btn-sm btn-pulse" @click="searchStores()" :disabled="storeBusy">Zoeken</button>
</div>
<div class="store-search-panel">
<div>
<div class="store-list">
<template x-for="s in storeResults" :key="s.id">
<div class="store-list-item" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="(s.city||'') + (s.province ? ', '+s.province : '')"></div>
<div class="clients-card-tags">
<span class="clients-tag" x-show="s.partnership_status && s.partnership_status!=='none'" x-text="s.partnership_status"></span>
<span class="clients-tag" x-show="s.halal_certified">Halal ✓</span>
</div>
</div>
</template>
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen resultaten — pas filters aan.</p>
</div>
<p class="muted" style="font-size:0.72rem;margin-top:0.35rem" x-text="storeResults.length+' filialen · max 80'"></p>
</div>
<div class="store-edit-panel" x-show="selectedStore">
<h3 x-text="(selectedStore?.chain||'') + ' · ' + (selectedStore?.name||'')"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Adres</span>
<input class="form-input" x-model="storeForm.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="storeForm.postcode" /></label>
<label><span class="filter-label">Stad</span>
<input class="form-input" x-model="storeForm.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="storeForm.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="storeForm.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="storeForm.email" /></label>
<label><span class="filter-label">Website</span>
<input class="form-input" x-model="storeForm.website" /></label>
<label><span class="filter-label">Manager</span>
<input class="form-input" x-model="storeForm.manager_name" /></label>
<label><span class="filter-label">Partnership</span>
<select class="form-input" x-model="storeForm.partnership_status">
<option value="none">Geen</option><option value="prospect">Prospect</option>
<option value="active">Actief</option><option value="paused">Gepauzeerd</option>
</select></label>
<label><span class="filter-label">Halal certificerder</span>
<input class="form-input" x-model="storeForm.halal_certifier" placeholder="HQC, OSM…" /></label>
</div>
<div class="store-checks">
<label><input type="checkbox" x-model="storeForm.halal_certified" /> Halal gecertificeerd</label>
<label><input type="checkbox" x-model="storeForm.has_halal_section" /> Halal schap</label>
</div>
<div class="clients-form-grid" style="margin-top:0.75rem">
<label class="full"><span class="filter-label">Koppel aan klant</span>
<select class="form-input" x-model="linkForm.client_id">
<option value="">— Selecteer klant —</option>
<template x-for="c in clients" :key="'lc'+c.id">
<option :value="c.id" x-text="c.name + ' ('+stageLabel(c.stage)+')'"></option>
</template>
</select></label>
<label><span class="filter-label">Relatie</span>
<select class="form-input" x-model="linkForm.relationship_type">
<option value="prospect">Prospect</option><option value="partner">Partner</option>
<option value="supplier">Leverancier</option><option value="competitor">Concurrent</option>
</select></label>
<label><span class="filter-label">Notities koppeling</span>
<input class="form-input" x-model="linkForm.notes" placeholder="Optioneel" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary btn-sm" @click="saveStore()" :disabled="storeBusy">Opslaan filiaal</button>
<button class="btn btn-sm btn-pulse-green" @click="linkStore()" :disabled="!linkForm.client_id">Koppel klant</button>
<a class="btn btn-sm" :href="'/retail?store='+selectedStore?.id" target="_blank">Retail 360 →</a>
</div>
</div>
<div class="store-edit-panel clients-empty" x-show="!selectedStore && !storeBusy">
Selecteer een filiaal links om data aan te vullen.
</div>
</div>
</div>
<!-- Client modal -->
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
<div class="modal-card" style="max-width:560px">
<h3 x-text="clientForm.id ? 'Klant bewerken' : 'Nieuwe klant'"></h3>
<div class="clients-form-grid">
<label class="full"><span class="filter-label">Bedrijfsnaam *</span>
<input class="form-input" x-model="clientForm.name" placeholder="Chai N Masala B.V." /></label>
<label><span class="filter-label">Contactpersoon</span>
<input class="form-input" x-model="clientForm.contact" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" type="email" x-model="clientForm.email" /></label>
<label><span class="filter-label">Sector</span>
<select class="form-input" x-model="clientForm.sector">
<option value="">— Kies sector —</option>
<template x-for="s in sectorOptions" :key="'s'+s"><option :value="s" x-text="s"></option></template>
</select></label>
<label><span class="filter-label">Stage</span>
<select class="form-input" x-model="clientForm.stage">
<template x-for="st in stages" :key="'cs'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select></label>
<label><span class="filter-label">MRR schatting (€/mnd)</span>
<input class="form-input" type="number" min="0" step="100" x-model="clientForm.mrr_estimate" /></label>
<label class="full"><span class="filter-label">Notities</span>
<textarea class="form-input" rows="4" x-model="clientForm.notes" placeholder="Context, afspraken, next steps…"></textarea></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="saveClient()" :disabled="!clientForm.name.trim()">Opslaan</button>
<button class="btn btn-secondary" @click="clientModal=false">Annuleren</button>
<button class="btn btn-reject" x-show="clientForm.id" @click="removeClient(clientForm.id); clientModal=false">Verwijderen</button>
</div>
</div>
</div>
<!-- Store create modal -->
<div class="modal" :class="{ open: storeCreateModal }" @click.self="storeCreateModal=false">
<div class="modal-card" style="max-width:560px">
<h3>Nieuw filiaal toevoegen</h3>
<div class="clients-form-grid">
<label><span class="filter-label">Keten *</span>
<input class="form-input" x-model="newStore.chain" list="chain-list" placeholder="Jumbo, AH, Lidl…" /></label>
<datalist id="chain-list"><template x-for="ch in chains" :key="'dl'+ch"><option :value="ch"></option></template></datalist>
<label><span class="filter-label">Naam *</span>
<input class="form-input" x-model="newStore.name" placeholder="Jumbo Bos en Lommer" /></label>
<label class="full"><span class="filter-label">Adres *</span>
<input class="form-input" x-model="newStore.address" /></label>
<label><span class="filter-label">Postcode</span>
<input class="form-input" x-model="newStore.postcode" /></label>
<label><span class="filter-label">Stad *</span>
<input class="form-input" x-model="newStore.city" /></label>
<label><span class="filter-label">Provincie</span>
<input class="form-input" x-model="newStore.province" /></label>
<label><span class="filter-label">Telefoon</span>
<input class="form-input" x-model="newStore.phone" /></label>
<label><span class="filter-label">E-mail</span>
<input class="form-input" x-model="newStore.email" /></label>
</div>
<div class="btn-group" style="margin-top:1rem">
<button class="btn btn-primary" @click="createStore()" :disabled="!newStore.name||!newStore.chain||!newStore.city">Toevoegen</button>
<button class="btn btn-secondary" @click="storeCreateModal=false">Annuleren</button>
</div>
</div>
</div>
<!-- Detail drawer -->
<aside class="clients-drawer" :class="{ open: drawer }">
<div class="clients-drawer-head">
<div>
<h2 x-text="detail?.client?.name||''"></h2>
<p class="muted" x-text="stageLabel(detail?.client?.stage)+' · '+(detail?.client?.sector||'Geen sector')"></p>
</div>
<button class="drawer-close" @click="drawer=false">×</button>
</div>
<template x-if="detail?.client">
<div>
<div class="clients-card-meta">
<div x-show="detail.client.contact"><strong>Contact:</strong> <span x-text="detail.client.contact"></span></div>
<div x-show="detail.client.email"><strong>E-mail:</strong> <span x-text="detail.client.email"></span></div>
<div x-show="detail.client.mrr_estimate"><strong>MRR:</strong> <span x-text="fmtEur(detail.client.mrr_estimate)"></span></div>
<div x-show="detail.client.notes" style="margin-top:0.5rem;white-space:pre-wrap" x-text="detail.client.notes"></div>
</div>
<div class="btn-group" style="margin-top:0.75rem">
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
<select class="form-input" style="width:auto" x-model="detail.client.stage" @change="quickStage(detail.client)">
<template x-for="st in stages" :key="'ds'+st.id"><option :value="st.id" x-text="st.label"></option></template>
</select>
</div>
<div class="clients-drawer-section">
<h4>Deals (<span x-text="(detail.deals||[]).length"></span>)</h4>
<template x-for="d in detail.deals||[]" :key="d.id">
<div class="clients-card" style="cursor:default">
<strong x-text="d.title"></strong>
<div class="clients-card-meta" x-text="fmtEur(d.value)+' · '+d.stage"></div>
</div>
</template>
<p class="muted" x-show="!(detail.deals||[]).length">Nog geen deals — <a href="/deals">voeg toe</a></p>
</div>
<div class="clients-drawer-section">
<h4>Gekoppelde filialen (<span x-text="(detail.stores||[]).length"></span>)</h4>
<template x-for="s in detail.stores||[]" :key="s.id">
<div class="clients-card" style="cursor:default">
<strong x-text="s.chain+' · '+s.name"></strong>
<div class="clients-card-meta" x-text="s.city+(s.province?', '+s.province:'')"></div>
<span class="clients-tag" x-text="s.relationship_type||s.partnership_status"></span>
</div>
</template>
<button class="btn btn-sm" style="margin-top:0.5rem" @click="tab='stores'; drawer=false">+ Filiaal koppelen</button>
</div>
</div>
</template>
</aside>
</div>
{% endblock %}
{% block scripts %}
<script>
function clientsHub() {
const STAGES = [
{ id: 'intake', label: 'Intake' },
{ id: 'discovery', label: 'Verkenning' },
{ id: 'proposal', label: 'Voorstel' },
{ id: 'active', label: 'Actief' },
{ id: 'churned', label: 'Verloren' },
];
const SECTORS = [
'Retail', 'Supermarkt', 'Groothandel', 'Horeca', 'Food service',
'Spices & Tea', 'Kant-en-klaar', 'Import/Export', 'Private label', 'Overig',
];
return {
tab: 'kanban',
busy: false,
storeBusy: false,
clientModal: false,
storeCreateModal: false,
drawer: false,
stages: STAGES,
sectorOptions: SECTORS,
stats: {},
clients: [],
filtered: [],
filterQ: '',
filterStage: '',
filterSector: '',
detail: null,
clientForm: {},
storeResults: [],
selectedStore: null,
storeForm: {},
linkForm: { client_id: '', relationship_type: 'prospect', notes: '' },
storeQ: '',
storeChain: '',
storeProvince: '',
chains: [],
provinces: [],
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
fmtEur(v) {
if (v == null || v === '') return '—';
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
},
stageLabel(id) {
return (STAGES.find(s => s.id === id) || {}).label || id || '—';
},
stageCount(id) { return this.clients.filter(c => c.stage === id).length; },
clientsByStage(id) { return this.clients.filter(c => c.stage === id); },
async init() { await this.loadAll(); },
async loadAll() {
this.busy = true;
try {
const [statsR, clientsR, filtersR] = await Promise.all([
Cockpit.api('/clients/stats'),
Cockpit.api('/clients'),
fetch('/api/retail/filters').then(r => r.json()).catch(() => ({})),
]);
this.stats = statsR.stats || {};
this.clients = clientsR.items || [];
this.chains = (filtersR.chains || []).map(x => x.chain || x).filter(Boolean);
this.provinces = (filtersR.provinces || []).map(x => x.province || x).filter(Boolean);
this.applyFilter();
} catch (e) {
Cockpit.toast(e.message, 'error');
}
this.busy = false;
},
applyFilter() {
const q = (this.filterQ || '').toLowerCase();
this.filtered = this.clients.filter(c => {
if (this.filterStage && c.stage !== this.filterStage) return false;
if (this.filterSector && c.sector !== this.filterSector) return false;
if (!q) return true;
return [c.name, c.contact, c.email, c.sector, c.notes].some(
f => f && String(f).toLowerCase().includes(q)
);
});
},
openClientModal() {
this.clientForm = { id: null, name: '', contact: '', email: '', stage: 'intake', sector: '', mrr_estimate: '', notes: '' };
this.clientModal = true;
},
editClient(c) {
this.clientForm = {
id: c.id, name: c.name || '', contact: c.contact || '', email: c.email || '',
stage: c.stage || 'intake', sector: c.sector || '', mrr_estimate: c.mrr_estimate || '',
notes: c.notes || '',
};
this.clientModal = true;
this.drawer = false;
},
async saveClient() {
const body = {
name: this.clientForm.name.trim(),
contact: this.clientForm.contact || null,
email: this.clientForm.email || null,
stage: this.clientForm.stage,
sector: this.clientForm.sector || null,
mrr_estimate: this.clientForm.mrr_estimate ? parseFloat(this.clientForm.mrr_estimate) : null,
notes: this.clientForm.notes || null,
};
try {
if (this.clientForm.id) {
await Cockpit.api('/clients/' + this.clientForm.id, { method: 'PUT', body: JSON.stringify(body) });
} else {
await Cockpit.api('/clients', { method: 'POST', body: JSON.stringify(body) });
}
Cockpit.toast('Klant opgeslagen', 'success');
this.clientModal = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async removeClient(id) {
if (!Cockpit.confirmDelete('Klant definitief verwijderen?')) return;
try {
await Cockpit.api('/clients/' + id, { method: 'DELETE' });
Cockpit.toast('Klant verwijderd', 'info');
this.drawer = false;
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async openDetail(c) {
try {
const d = await Cockpit.api('/clients/' + c.id + '/detail');
this.detail = d;
this.drawer = true;
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async quickStage(c) {
const body = {
name: c.name, contact: c.contact, email: c.email, stage: c.stage,
sector: c.sector, mrr_estimate: c.mrr_estimate, notes: c.notes,
};
try {
await Cockpit.api('/clients/' + c.id, { method: 'PUT', body: JSON.stringify(body) });
Cockpit.toast('Stage bijgewerkt', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async searchStores() {
this.storeBusy = true;
try {
const params = new URLSearchParams({ limit: '80', sort: 'name' });
if (this.storeQ) params.set('q', this.storeQ);
if (this.storeChain) params.set('chain', this.storeChain);
if (this.storeProvince) params.set('province', this.storeProvince);
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
this.storeResults = r.items || [];
if (this.storeResults.length && !this.selectedStore) {
await this.selectStore(this.storeResults[0].id);
}
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async selectStore(id) {
try {
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
this.selectedStore = r;
this.storeForm = {
address: r.address || '', postcode: r.postcode || '', city: r.city || '',
province: r.province || '', phone: r.phone || '', email: r.email || '',
website: r.website || '', manager_name: r.manager_name || r.profile_manager || '',
partnership_status: r.partnership_status || 'none',
halal_certified: !!r.halal_certified, has_halal_section: !!r.has_halal_section,
halal_certifier: r.halal_certifier || '',
};
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
async saveStore() {
if (!this.selectedStore) return;
this.storeBusy = true;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.storeForm),
}).then(r => { if (!r.ok) throw new Error('Opslaan mislukt'); return r.json(); });
Cockpit.toast('Filiaaldata opgeslagen', 'success');
await this.selectStore(this.selectedStore.id);
await this.searchStores();
} catch (e) { Cockpit.toast(e.message, 'error'); }
this.storeBusy = false;
},
async linkStore() {
if (!this.selectedStore || !this.linkForm.client_id) return;
try {
await fetch('/api/retail/supermarkets/' + this.selectedStore.id + '/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: parseInt(this.linkForm.client_id, 10),
relationship_type: this.linkForm.relationship_type,
partnership_status: this.storeForm.partnership_status !== 'none' ? this.storeForm.partnership_status : 'prospect',
notes: this.linkForm.notes || null,
}),
}).then(r => { if (!r.ok) throw new Error('Koppelen mislukt'); return r.json(); });
Cockpit.toast('Filiaal gekoppeld aan klant', 'success');
await this.loadAll();
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
openStoreCreate() {
this.newStore = { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' };
this.storeCreateModal = true;
},
async createStore() {
try {
const r = await fetch('/api/retail/supermarkets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newStore),
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
Cockpit.toast('Filiaal toegevoegd', 'success');
this.storeCreateModal = false;
await this.searchStores();
if (r.item?.id) await this.selectStore(r.item.id);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
</script>
{% endblock %}