2026-06-09 00:41:27 +00:00
{% extends "base.html" %}
2026-06-09 10:41:13 +00:00
{% block extra_head %}
2026-06-23 10:04:23 +00:00
< link rel = "stylesheet" href = "/static/css/clients-crm.css?v=5" />
2026-06-09 10:41:13 +00:00
{% endblock %}
2026-06-09 00:41:27 +00:00
{% block content %}
2026-06-09 10:41:13 +00:00
< div class = "clients-shell" x-data = "clientsHub()" x-init = "init()" >
< header class = "page-header" >
< div >
2026-06-23 10:04:23 +00:00
< h1 > CRM · Klanten</ h1 >
< p class = "subtitle page-tip" > Klanten beheren · pipeline · supermarkten · 360° intelligence</ p >
2026-06-09 10:41:13 +00:00
</ 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 >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-09 10:41:13 +00:00
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
< div class = "clients-tab-actions" >
2026-06-23 10:04:23 +00:00
< button class = "btn btn-primary btn-sm" @ click = "openClientModal()" > + Nieuwe klant</ button >
2026-06-09 10:41:13 +00:00
< button class = "btn btn-sm" @ click = "openStoreCreate()" x-show = "tab==='stores'" > + Nieuw filiaal</ button >
</ div >
2026-06-23 10:04:23 +00:00
</ nav >
2026-06-09 10:41:13 +00:00
<!-- 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 >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
</ 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 >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
< 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 >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
</ div >
2026-06-23 10:04:23 +00:00
2026-06-09 10:41:13 +00:00
< div class = "store-search-panel" >
2026-06-23 10:04:23 +00:00
< 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 >
2026-06-09 10:41:13 +00:00
</ 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 >
2026-06-23 10:04:23 +00:00
<!-- 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 >
2026-06-09 10:41:13 +00:00
<!-- 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 >
2026-06-23 10:04:23 +00:00
< button class = "btn btn-sm btn-pulse-green" @ click = "openClient360(detail.client)" > 360° view →</ button >
2026-06-09 10:41:13 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
</ div >
{% endblock %}
{% block scripts %}
< script >
2026-06-09 10:41:13 +00:00
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' ,
];
2026-06-09 00:41:27 +00:00
return {
2026-06-09 10:41:13 +00:00
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 : '' ,
2026-06-23 10:04:23 +00:00
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 ,
2026-06-09 10:41:13 +00:00
chains : [],
provinces : [],
2026-06-23 10:04:23 +00:00
storePartnerships : [],
2026-06-09 10:41:13 +00:00
newStore : { chain : '' , name : '' , address : '' , postcode : '0000AA' , city : '' , province : '' , phone : '' , email : '' },
2026-06-23 10:04:23 +00:00
view360ClientId : '' ,
linkProjectId : '' ,
linkPathInput : '' ,
linkProjects : [],
client360 : { stats : {}, links : [], sentiment_breakdown : [], monthly_trends : [], top_words : [], stores : [], deals : [] },
autoSyncBusy : false ,
autoSyncLabel : 'Second brain: —' ,
2026-06-09 10:41:13 +00:00
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 ); },
2026-06-23 10:04:23 +00:00
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 ();
}
},
2026-06-09 10:41:13 +00:00
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 );
2026-06-23 10:04:23 +00:00
this . storePartnerships = filtersR . partnerships || [];
2026-06-09 10:41:13 +00:00
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' ); }
},
2026-06-23 10:04:23 +00:00
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 ) {}
},
2026-06-09 10:41:13 +00:00
async quickStage ( c ) {
2026-06-23 10:04:23 +00:00
if ( ! c || ! c . id ) return ;
const prev = c . _prevStage ;
2026-06-09 10:41:13 +00:00
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 ) });
2026-06-23 10:04:23 +00:00
Cockpit . toast (( c . name || 'Klant' ) + ' → ' + this . stageLabel ( c . stage ), 'success' );
if ( this . detail ? . client ? . id === c . id ) this . detail . client . stage = c . stage ;
2026-06-09 10:41:13 +00:00
await this . loadAll ();
2026-06-23 10:04:23 +00:00
} catch ( e ) {
if ( prev ) c . stage = prev ;
Cockpit . toast ( e . message , 'error' );
}
},
stageSelectFocus ( c ) {
c . _prevStage = c . stage ;
2026-06-09 10:41:13 +00:00
},
2026-06-23 10:04:23 +00:00
async searchStores ( resetPage ) {
if ( resetPage ) this . storePage = 1 ;
2026-06-09 10:41:13 +00:00
this . storeBusy = true ;
try {
2026-06-23 10:04:23 +00:00
const params = this . buildStoreParams ();
2026-06-09 10:41:13 +00:00
const r = await fetch ( '/api/retail/list?' + params ). then ( x => x . json ());
this . storeResults = r . items || [];
2026-06-23 10:04:23 +00:00
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 ;
2026-06-09 10:41:13 +00:00
}
} catch ( e ) { Cockpit . toast ( e . message , 'error' ); }
this . storeBusy = false ;
},
2026-06-23 10:04:23 +00:00
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 );
},
2026-06-09 10:41:13 +00:00
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 );
2026-06-23 10:04:23 +00:00
await this . searchStores ( false );
2026-06-09 10:41:13 +00:00
} 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 ;
2026-06-23 10:04:23 +00:00
await this . searchStores ( false );
2026-06-09 10:41:13 +00:00
if ( r . item ? . id ) await this . selectStore ( r . item . id );
} catch ( e ) { Cockpit . toast ( e . message , 'error' ); }
},
2026-06-09 00:41:27 +00:00
};
}
</ script >
{% endblock %}