SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
+471
-50
@@ -1,13 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/clients-crm.css?v=5" />
|
||||
{% 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>
|
||||
<h1>CRM · Klanten</h1>
|
||||
<p class="subtitle page-tip">Klanten beheren · pipeline · supermarkten · 360° intelligence</p>
|
||||
</div>
|
||||
<div class="retail-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="loadAll()" :disabled="busy">↻ Verversen</button>
|
||||
@@ -24,15 +24,26 @@
|
||||
<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>
|
||||
<nav class="clients-tabs" aria-label="Klanten weergaven">
|
||||
<div class="clients-tab-bar">
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='kanban'}" @click="tab='kanban'">
|
||||
<span class="clients-tab-icon" aria-hidden="true">▦</span> Kanban
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='list'}" @click="tab='list'">
|
||||
<span class="clients-tab-icon" aria-hidden="true">☰</span> Lijst
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='stores'}" @click="tab='stores'; if(!storeResults.length) searchStores(true)">
|
||||
<span class="clients-tab-icon" aria-hidden="true">🛒</span> Supermarkten
|
||||
</button>
|
||||
<button type="button" class="clients-tab" :class="{active: tab==='360'}" @click="open360Tab()">
|
||||
<span class="clients-tab-icon" aria-hidden="true">◎</span> 360° Klant
|
||||
</button>
|
||||
</div>
|
||||
<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-primary btn-sm" @click="openClientModal()">+ Nieuwe klant</button>
|
||||
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Kanban -->
|
||||
<div x-show="tab==='kanban'" x-cloak>
|
||||
@@ -52,6 +63,11 @@
|
||||
<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>
|
||||
<select class="clients-card-stage" x-model="c.stage" @click.stop @focus="stageSelectFocus(c)" @change.stop="quickStage(c)" :title="'Status wijzigen voor '+c.name">
|
||||
<template x-for="st in stages" :key="'kb'+c.id+st.id">
|
||||
<option :value="st.id" x-text="st.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -83,7 +99,13 @@
|
||||
<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 @click.stop>
|
||||
<select class="clients-inline-stage" x-model="c.stage" @focus="stageSelectFocus(c)" @change="quickStage(c)">
|
||||
<template x-for="st in stages" :key="'ls'+c.id+st.id">
|
||||
<option :value="st.id" x-text="st.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</td>
|
||||
<td x-text="c.mrr_estimate ? fmtEur(c.mrr_estimate) : '—'"></td>
|
||||
<td x-text="c.store_count||0"></td>
|
||||
<td>
|
||||
@@ -99,35 +121,120 @@
|
||||
|
||||
<!-- 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>
|
||||
<p class="page-tip" style="margin-bottom:0.75rem">Filter filialen, selecteer een rij en vul data aan of koppel aan een klant.</p>
|
||||
|
||||
<div class="store-filters-card">
|
||||
<div class="store-filters-grid">
|
||||
<label class="store-filter-field store-filter-search">
|
||||
<span class="filter-label">Zoeken</span>
|
||||
<input type="search" class="form-input" placeholder="Naam, adres, stad…" x-model="storeQ" @keydown.enter="searchStores(true)" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Keten</span>
|
||||
<select class="form-input" x-model="storeChain" @change="searchStores(true)">
|
||||
<option value="">Alle ketens</option>
|
||||
<template x-for="ch in chains" :key="ch"><option :value="ch" x-text="ch"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Provincie</span>
|
||||
<select class="form-input" x-model="storeProvince" @change="searchStores(true)">
|
||||
<option value="">Alle provincies</option>
|
||||
<template x-for="p in provinces" :key="p"><option :value="p" x-text="p"></option></template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Stad</span>
|
||||
<input type="text" class="form-input" placeholder="bv. Amsterdam" x-model="storeCity" @keydown.enter="searchStores(true)" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Partnership</span>
|
||||
<select class="form-input" x-model="storePartnership" @change="searchStores(true)">
|
||||
<option value="">Alle statussen</option>
|
||||
<template x-for="p in storePartnerships" :key="p.partnership_status">
|
||||
<option :value="p.partnership_status" x-text="(p.partnership_status||'none')+' ('+p.n+')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Sorteren</span>
|
||||
<select class="form-input" x-model="storeSort" @change="searchStores(true)">
|
||||
<option value="name">Naam A-Z</option>
|
||||
<option value="chain">Keten + naam</option>
|
||||
<option value="halal_opportunity">Halal kans ↓</option>
|
||||
<option value="population">Bevolking ↓</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<details class="store-filters-extra">
|
||||
<summary>Extra filters</summary>
|
||||
<div class="store-filter-checks">
|
||||
<label><input type="checkbox" x-model="storeHalalGap" @change="searchStores(true)" /> Halal-gap (geen cert + geen schap)</label>
|
||||
<label><input type="checkbox" x-model="storeHalalCert" @change="searchStores(true)" /> Halal gecertificeerd</label>
|
||||
<label><input type="checkbox" x-model="storeHalalSection" @change="searchStores(true)" /> Halal schap</label>
|
||||
<label><input type="checkbox" x-model="storeLinkedCrm" @change="searchStores(true)" /> Gekoppeld aan CRM</label>
|
||||
<label><input type="checkbox" x-model="storeHasPhone" @change="searchStores(true)" /> Heeft telefoon</label>
|
||||
<label><input type="checkbox" x-model="storeHasEmail" @change="searchStores(true)" /> Heeft e-mail</label>
|
||||
<label><input type="checkbox" x-model="storeHasManager" @change="searchStores(true)" /> Heeft manager</label>
|
||||
</div>
|
||||
<div class="store-filters-grid" style="margin-top:0.65rem">
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Min. halal-kans (0-100)</span>
|
||||
<input type="number" min="0" max="100" class="form-input" x-model.number="storeMinHalalOpp" @change="searchStores(true)" placeholder="bv. 35" />
|
||||
</label>
|
||||
<label class="store-filter-field">
|
||||
<span class="filter-label">Postcode (prefix)</span>
|
||||
<input type="text" class="form-input" x-model="storePostcode" @keydown.enter="searchStores(true)" placeholder="bv. 1012" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="store-filters-actions">
|
||||
<button class="btn btn-sm btn-pulse" @click="searchStores(true)" :disabled="storeBusy">Zoeken</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="resetStoreFilters()" :disabled="storeBusy">Filters wissen</button>
|
||||
<span class="store-results-meta" x-show="storeTotal > 0" x-text="storeResultLabel()"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="store-search-panel">
|
||||
<div class="store-results-col">
|
||||
<div class="store-table-wrap">
|
||||
<table class="data-table store-results-table" x-show="storeResults.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Keten</th>
|
||||
<th>Filiaal</th>
|
||||
<th>Stad</th>
|
||||
<th>Status</th>
|
||||
<th>Halal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="s in storeResults" :key="s.id">
|
||||
<tr class="store-table-row" :class="{selected: selectedStore?.id===s.id}" @click="selectStore(s.id)">
|
||||
<td x-text="s.chain||'—'"></td>
|
||||
<td><strong x-text="s.name||'—'"></strong></td>
|
||||
<td x-text="(s.city||'—') + (s.province ? ', '+s.province : '')"></td>
|
||||
<td><span class="clients-tag" x-text="s.partnership_status||'none'"></span></td>
|
||||
<td>
|
||||
<span class="clients-tag" x-show="s.halal_certified">Cert</span>
|
||||
<span class="clients-tag" x-show="s.has_halal_section && !s.halal_certified">Schap</span>
|
||||
<span class="muted" x-show="!s.halal_certified && !s.has_halal_section">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="clients-empty" x-show="!storeResults.length && !storeBusy">Geen filialen — pas filters aan en klik Zoeken.</p>
|
||||
<p class="clients-empty" x-show="storeBusy">Zoeken…</p>
|
||||
</div>
|
||||
|
||||
<div class="store-pagination" x-show="storeTotal > storePageSize">
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="storePrevPage()" :disabled="storePage <= 1 || storeBusy">← Vorige</button>
|
||||
<span class="muted" x-text="'Pagina '+storePage+' / '+storePageCount()"></span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="storeNextPage()" :disabled="storePage >= storePageCount() || storeBusy">Volgende →</button>
|
||||
</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>
|
||||
@@ -188,6 +295,99 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 360° Klant Intelligence -->
|
||||
<div x-show="tab==='360'" x-cloak class="crm-360-shell">
|
||||
<div class="crm-360-head">
|
||||
<div>
|
||||
<h2>360° Klant Intelligence</h2>
|
||||
<p class="page-tip">NAS-documenten · sentiment · retail · deals · second brain · <span x-text="autoSyncLabel"></span></p>
|
||||
</div>
|
||||
<div class="crm-360-actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="runAutoSync(true)" :disabled="autoSyncBusy">⟳ Sync alles</button>
|
||||
<button class="btn btn-primary btn-sm" @click="linkNasFile()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel pad</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="linkNasFolder()" :disabled="!view360ClientId || !linkPathInput.trim()">+ Koppel map</button>
|
||||
<a class="btn btn-sm" href="/documents" target="_blank">📂 Documenten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-toolbar">
|
||||
<select class="form-input" x-model.number="view360ClientId" @change="loadClient360()">
|
||||
<option value="">— Klant —</option>
|
||||
<template x-for="c in clients" :key="'v360c'+c.id">
|
||||
<option :value="c.id" x-text="c.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<select class="form-input" x-model.number="linkProjectId">
|
||||
<option value="">— Project (optioneel) —</option>
|
||||
<template x-for="p in linkProjectsForClient()" :key="'v360p'+p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input class="form-input" placeholder="NAS pad (bv. Telegram/rapport.pdf of CUCINA/…)" x-model="linkPathInput" />
|
||||
</div>
|
||||
|
||||
<div class="a360-grid" x-show="view360ClientId">
|
||||
<div class="a360-kpis">
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('documents')"></div><div class="l">Documenten</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('linked_paths')"></div><div class="l">Koppelingen</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('projects')"></div><div class="l">Projecten</div></div>
|
||||
<div class="crm-360-stat"><div class="n" x-text="stat360('stores')"></div><div class="l">Winkels</div></div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Gekoppelde paden</p>
|
||||
<div class="a360-links">
|
||||
<template x-for="l in client360.links || []" :key="'lnk'+l.id">
|
||||
<div class="a360-link-row">
|
||||
<span x-text="(l.is_folder ? '📁 ' : '📄 ') + l.storage_path"></span>
|
||||
<button class="btn btn-sm btn-secondary" @click="unlinkDocument(l.id)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="clients-empty" x-show="!(client360.links||[]).length">Nog geen koppelingen — voeg een NAS-pad toe of open Documenten Workbench</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Sentiment & trends</p>
|
||||
<div class="a360-sentiment">
|
||||
<template x-for="s in client360.sentiment_breakdown || []" :key="s.sentiment_label">
|
||||
<span class="a360-pill" x-text="s.sentiment_label + ': ' + s.n"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="a360-trends">
|
||||
<template x-for="t in client360.monthly_trends || []" :key="t.month">
|
||||
<div class="a360-trend-row">
|
||||
<span x-text="(t.month||'').slice(0,7)"></span>
|
||||
<span x-text="t.docs + ' docs'"></span>
|
||||
<span x-text="'sentiment ' + (t.avg_sentiment||0).toFixed(2)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Top woorden (klant corpus)</p>
|
||||
<div class="a360-words">
|
||||
<template x-for="w in client360.top_words || []" :key="w.lemma">
|
||||
<span class="a360-word" x-text="w.lemma + ' (' + w.total + ')'"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="a360-panel">
|
||||
<p class="crm-360-section">Retail & deals</p>
|
||||
<template x-for="s in client360.stores || []" :key="'st'+s.id">
|
||||
<p class="page-tip" x-text="s.chain + ' · ' + s.name + ' · ' + (s.city||'')"></p>
|
||||
</template>
|
||||
<template x-for="d in client360.deals || []" :key="'dl'+d.id">
|
||||
<p class="page-tip" x-text="d.title + ' · €' + d.value + ' · ' + d.stage"></p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="clients-empty" x-show="!view360ClientId" style="padding:2rem">Selecteer een klant om de 360° view te laden</p>
|
||||
</div>
|
||||
|
||||
<!-- Client modal -->
|
||||
<div class="modal" :class="{ open: clientModal }" @click.self="clientModal=false">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
@@ -270,6 +470,7 @@
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:0.75rem">
|
||||
<button class="btn btn-sm" @click="editClient(detail.client)">Bewerken</button>
|
||||
<button class="btn btn-sm btn-pulse-green" @click="openClient360(detail.client)">360° view →</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>
|
||||
@@ -338,10 +539,34 @@ function clientsHub() {
|
||||
storeQ: '',
|
||||
storeChain: '',
|
||||
storeProvince: '',
|
||||
storeCity: '',
|
||||
storePartnership: '',
|
||||
storeSort: 'name',
|
||||
storePostcode: '',
|
||||
storeMinHalalOpp: null,
|
||||
storeHalalGap: false,
|
||||
storeHalalCert: false,
|
||||
storeHalalSection: false,
|
||||
storeLinkedCrm: false,
|
||||
storeHasPhone: false,
|
||||
storeHasEmail: false,
|
||||
storeHasManager: false,
|
||||
storePage: 1,
|
||||
storePageSize: 25,
|
||||
storeTotal: 0,
|
||||
chains: [],
|
||||
provinces: [],
|
||||
storePartnerships: [],
|
||||
newStore: { chain: '', name: '', address: '', postcode: '0000AA', city: '', province: '', phone: '', email: '' },
|
||||
|
||||
view360ClientId: '',
|
||||
linkProjectId: '',
|
||||
linkPathInput: '',
|
||||
linkProjects: [],
|
||||
client360: { stats: {}, links: [], sentiment_breakdown: [], monthly_trends: [], top_words: [], stores: [], deals: [] },
|
||||
autoSyncBusy: false,
|
||||
autoSyncLabel: 'Second brain: —',
|
||||
|
||||
fmtEur(v) {
|
||||
if (v == null || v === '') return '—';
|
||||
return '€' + Number(v).toLocaleString('nl-NL', { maximumFractionDigits: 0 });
|
||||
@@ -352,7 +577,17 @@ function clientsHub() {
|
||||
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 init() {
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const tab = qs.get('tab');
|
||||
const clientId = qs.get('client');
|
||||
await this.loadAll();
|
||||
if (tab === '360' || tab === 'view360') {
|
||||
this.tab = '360';
|
||||
if (clientId) this.view360ClientId = parseInt(clientId, 10) || '';
|
||||
await this.on360Tab();
|
||||
}
|
||||
},
|
||||
|
||||
async loadAll() {
|
||||
this.busy = true;
|
||||
@@ -366,6 +601,7 @@ function clientsHub() {
|
||||
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.storePartnerships = filtersR.partnerships || [];
|
||||
this.applyFilter();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
@@ -439,34 +675,219 @@ function clientsHub() {
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
openClient360(c) {
|
||||
if (!c || !c.id) return;
|
||||
this.view360ClientId = c.id;
|
||||
this.tab = '360';
|
||||
this.drawer = false;
|
||||
this.on360Tab();
|
||||
},
|
||||
|
||||
async open360Tab() {
|
||||
this.tab = '360';
|
||||
await this.on360Tab();
|
||||
},
|
||||
|
||||
async on360Tab() {
|
||||
if (this.view360ClientId) await this.loadClient360();
|
||||
await this.refreshAutoSyncStatus();
|
||||
},
|
||||
|
||||
stat360(key) {
|
||||
const stats = this.client360 && this.client360.stats;
|
||||
if (!stats || stats[key] == null) return 0;
|
||||
return stats[key];
|
||||
},
|
||||
|
||||
linkProjectsForClient() {
|
||||
if (!this.view360ClientId) return [];
|
||||
return (this.client360.projects || []).length
|
||||
? this.client360.projects
|
||||
: this.linkProjects.filter((p) => p.client_id === Number(this.view360ClientId));
|
||||
},
|
||||
|
||||
async loadClient360() {
|
||||
if (!this.view360ClientId) return;
|
||||
try {
|
||||
const data = await Cockpit.api('/clients/' + this.view360ClientId + '/360');
|
||||
if (!data.ok) throw new Error(data.error || '360 laden mislukt');
|
||||
this.client360 = data;
|
||||
this.linkProjects = data.projects || [];
|
||||
} catch (e) {
|
||||
Cockpit.toast('360: ' + (e.message || e), 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async linkNasFile() {
|
||||
const path = (this.linkPathInput || '').trim();
|
||||
if (!path || !this.view360ClientId) return;
|
||||
try {
|
||||
await Cockpit.api('/documents/links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storage_path: path,
|
||||
client_id: Number(this.view360ClientId),
|
||||
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
|
||||
is_folder: false,
|
||||
}),
|
||||
});
|
||||
Cockpit.toast('Bestand gekoppeld', 'success');
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async linkNasFolder() {
|
||||
let path = (this.linkPathInput || '').trim();
|
||||
if (!path || !this.view360ClientId) return;
|
||||
if (!path.endsWith('/')) path = path.replace(/\/[^/]+$/, '') || path;
|
||||
try {
|
||||
await Cockpit.api('/documents/links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
storage_path: path,
|
||||
client_id: Number(this.view360ClientId),
|
||||
project_id: this.linkProjectId ? Number(this.linkProjectId) : null,
|
||||
is_folder: true,
|
||||
}),
|
||||
});
|
||||
Cockpit.toast('Map gekoppeld: ' + path, 'success');
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async unlinkDocument(linkId) {
|
||||
try {
|
||||
await Cockpit.api('/documents/links/' + linkId, { method: 'DELETE' });
|
||||
await this.loadClient360();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
async runAutoSync(force) {
|
||||
this.autoSyncBusy = true;
|
||||
try {
|
||||
const data = await Cockpit.api('/brain/auto-sync' + (force ? '?force=true' : ''), { method: 'POST' });
|
||||
const brain = (data.steps || {}).brain_sync || {};
|
||||
Cockpit.toast('Sync: ' + (brain.synced || 0) + ' docs → second brain', 'success');
|
||||
await this.refreshAutoSyncStatus();
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
} finally {
|
||||
this.autoSyncBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshAutoSyncStatus() {
|
||||
try {
|
||||
const data = await Cockpit.api('/brain/auto-sync/status');
|
||||
const last = data.last;
|
||||
if (last && last.created_at) {
|
||||
this.autoSyncLabel = 'Laatste sync: ' + last.created_at.slice(0, 16) + ' (' + last.status + ')';
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async quickStage(c) {
|
||||
if (!c || !c.id) return;
|
||||
const prev = c._prevStage;
|
||||
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');
|
||||
Cockpit.toast((c.name || 'Klant') + ' → ' + this.stageLabel(c.stage), 'success');
|
||||
if (this.detail?.client?.id === c.id) this.detail.client.stage = c.stage;
|
||||
await this.loadAll();
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
} catch (e) {
|
||||
if (prev) c.stage = prev;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async searchStores() {
|
||||
stageSelectFocus(c) {
|
||||
c._prevStage = c.stage;
|
||||
},
|
||||
|
||||
async searchStores(resetPage) {
|
||||
if (resetPage) this.storePage = 1;
|
||||
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 params = this.buildStoreParams();
|
||||
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);
|
||||
this.storeTotal = r.total != null ? r.total : (r.count || this.storeResults.length);
|
||||
if (this.selectedStore && !this.storeResults.find(s => s.id === this.selectedStore.id)) {
|
||||
this.selectedStore = null;
|
||||
}
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
|
||||
buildStoreParams() {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(this.storePageSize),
|
||||
offset: String((this.storePage - 1) * this.storePageSize),
|
||||
sort: this.storeSort || '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);
|
||||
if (this.storeCity) params.set('city', this.storeCity);
|
||||
if (this.storePartnership) params.set('partnership', this.storePartnership);
|
||||
if (this.storePostcode) params.set('postcode_prefix', this.storePostcode);
|
||||
if (this.storeMinHalalOpp != null && this.storeMinHalalOpp !== '') params.set('min_halal_opportunity', String(this.storeMinHalalOpp));
|
||||
if (this.storeHalalGap) params.set('halal_gap_only', 'true');
|
||||
if (this.storeHalalCert) params.set('halal_certified', 'true');
|
||||
if (this.storeHalalSection) params.set('has_halal_section', 'true');
|
||||
if (this.storeLinkedCrm) params.set('linked_to_crm', 'true');
|
||||
if (this.storeHasPhone) params.set('has_phone', 'true');
|
||||
if (this.storeHasEmail) params.set('has_email', 'true');
|
||||
if (this.storeHasManager) params.set('has_manager', 'true');
|
||||
return params;
|
||||
},
|
||||
|
||||
resetStoreFilters() {
|
||||
this.storeQ = '';
|
||||
this.storeChain = '';
|
||||
this.storeProvince = '';
|
||||
this.storeCity = '';
|
||||
this.storePartnership = '';
|
||||
this.storeSort = 'name';
|
||||
this.storePostcode = '';
|
||||
this.storeMinHalalOpp = null;
|
||||
this.storeHalalGap = false;
|
||||
this.storeHalalCert = false;
|
||||
this.storeHalalSection = false;
|
||||
this.storeLinkedCrm = false;
|
||||
this.storeHasPhone = false;
|
||||
this.storeHasEmail = false;
|
||||
this.storeHasManager = false;
|
||||
this.storePage = 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
storePageCount() {
|
||||
return Math.max(1, Math.ceil((this.storeTotal || 0) / this.storePageSize));
|
||||
},
|
||||
|
||||
storeResultLabel() {
|
||||
const from = this.storeTotal ? (this.storePage - 1) * this.storePageSize + 1 : 0;
|
||||
const to = Math.min(this.storePage * this.storePageSize, this.storeTotal);
|
||||
return from + '–' + to + ' van ' + this.storeTotal + ' filialen';
|
||||
},
|
||||
|
||||
storePrevPage() {
|
||||
if (this.storePage <= 1) return;
|
||||
this.storePage -= 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
storeNextPage() {
|
||||
if (this.storePage >= this.storePageCount()) return;
|
||||
this.storePage += 1;
|
||||
this.searchStores(false);
|
||||
},
|
||||
|
||||
async selectStore(id) {
|
||||
try {
|
||||
const r = await fetch('/api/retail/supermarkets/' + id).then(x => x.json());
|
||||
@@ -493,7 +914,7 @@ function clientsHub() {
|
||||
}).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();
|
||||
await this.searchStores(false);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.storeBusy = false;
|
||||
},
|
||||
@@ -530,7 +951,7 @@ function clientsHub() {
|
||||
}).then(x => { if (!x.ok) throw new Error('Toevoegen mislukt'); return x.json(); });
|
||||
Cockpit.toast('Filiaal toegevoegd', 'success');
|
||||
this.storeCreateModal = false;
|
||||
await this.searchStores();
|
||||
await this.searchStores(false);
|
||||
if (r.item?.id) await this.selectStore(r.item.id);
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user