Files

962 lines
44 KiB
HTML
Raw Permalink 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=5" />
{% endblock %}
{% block content %}
<div class="clients-shell" x-data="clientsHub()" x-init="init()">
<header class="page-header">
<div>
<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>
<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>
<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()">+ Nieuwe klant</button>
<button class="btn btn-sm" @click="openStoreCreate()" x-show="tab==='stores'">+ Nieuw filiaal</button>
</div>
</nav>
<!-- 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>
<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>
</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 @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>
<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">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>
</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>
<!-- 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 &amp; 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 &amp; 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">
<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>
<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>
</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: '',
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 });
},
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() {
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;
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.storePartnerships = filtersR.partnerships || [];
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'); }
},
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((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) {
if (prev) c.stage = prev;
Cockpit.toast(e.message, 'error');
}
},
stageSelectFocus(c) {
c._prevStage = c.stage;
},
async searchStores(resetPage) {
if (resetPage) this.storePage = 1;
this.storeBusy = true;
try {
const params = this.buildStoreParams();
const r = await fetch('/api/retail/list?' + params).then(x => x.json());
this.storeResults = r.items || [];
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());
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(false);
} 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(false);
if (r.item?.id) await this.selectStore(r.item.id);
} catch (e) { Cockpit.toast(e.message, 'error'); }
},
};
}
</script>
{% endblock %}