5d60d33db1
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
751 lines
43 KiB
HTML
751 lines
43 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
|
|
<link rel="stylesheet" href="/static/retail.css?v=2" />
|
|
|
|
<div x-data="retailIntel()" x-init="init()">
|
|
<header class="page-header">
|
|
<div>
|
|
<h1>Retail Intelligence <span class="live-badge">Live</span></h1>
|
|
<p class="subtitle">360° filiaal view · CBS · halal · CRM · groothandels · trends</p>
|
|
</div>
|
|
<div class="retail-actions">
|
|
<button class="btn btn-sm btn-pulse" @click="refreshRss()" :disabled="busy">↻ RSS feeds</button>
|
|
<button class="btn btn-sm btn-pulse-green" @click="importWholesale()" :disabled="busy">Groothandels import</button>
|
|
<button class="btn btn-sm btn-pulse-purple" @click="syncCities()" :disabled="busy">Stad demografie</button>
|
|
<a class="btn btn-sm" :href="'/api/retail/export?' + params()" target="_blank">Export CSV</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="live-feed-bar" x-show="rssItems.length">
|
|
<div class="ticker-track">
|
|
<template x-for="r in rssItems.slice(0,10)" :key="'r'+r.id">
|
|
<a class="ticker-link" :href="r.link" target="_blank" rel="noopener" x-text="'📰 '+r.feed_name+': '+r.title"></a>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi-row">
|
|
<div class="kpi-card kpi-pulse"><span>Totaal filialen</span><strong x-text="stats.total||0"></strong></div>
|
|
<div class="kpi-card kpi-green"><span>CRM actief</span><strong x-text="stats.active_partnerships||0"></strong></div>
|
|
<div class="kpi-card"><span>Halal cert.</span><strong x-text="stats.halal_certified_count||0"></strong></div>
|
|
<div class="kpi-card"><span>CBS data</span><strong x-text="stats.with_area_data||0"></strong></div>
|
|
<div class="kpi-card"><span>Groothandels</span><strong x-text="wholesaleCount"></strong></div>
|
|
<div class="kpi-card"><span>Resultaat</span><strong x-text="resultCount"></strong></div>
|
|
</div>
|
|
|
|
<div class="retail-workspace" :class="['stores','wholesale','opportunities','cities'].includes(mode) ? 'with-filters' : ''">
|
|
<aside class="retail-sidebar panel">
|
|
<nav class="side-nav">
|
|
<button class="side-nav-btn" :class="mode==='stores'?'active':''" @click="mode='stores'; apply()">🏪 Supermarkten</button>
|
|
<button class="side-nav-btn" :class="mode==='wholesale'?'active':''" @click="mode='wholesale'; loadWholesale()">📦 Groothandels</button>
|
|
<button class="side-nav-btn" :class="mode==='opportunities'?'active':''" @click="mode='opportunities'; loadOpportunities()">🎯 Kansen</button>
|
|
<button class="side-nav-btn" :class="mode==='cities'?'active':''" @click="mode='cities'; loadCities()">🏙️ Steden · CBS</button>
|
|
<a class="side-nav-btn" href="/marketing?tab=regelgeving">⚖️ Regelgeving</a>
|
|
<a class="side-nav-btn" href="/clients">👥 CRM Clients</a>
|
|
<a class="side-nav-btn" href="/deals">💼 CRM Deals</a>
|
|
<a class="side-nav-btn" href="/marketing">📣 Marketing Hub</a>
|
|
</nav>
|
|
</aside>
|
|
|
|
<aside class="retail-filters panel" x-show="mode==='wholesale'" x-cloak>
|
|
<div class="filters-head"><h3>Groothandel filters</h3></div>
|
|
<details class="filter-group" open>
|
|
<summary>🔎 Zoeken</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field"><span class="filter-label">Zoek</span>
|
|
<input type="text" x-model="wf.q" @keyup.enter="loadWholesale()" placeholder="Naam, adres, e-mail">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Provincie</span>
|
|
<select x-model="wf.province" @change="loadWholesale()"><option value="">Alle</option>
|
|
<template x-for="p in wholesaleMeta.provinces||[]" :key="p.province">
|
|
<option :value="p.province" x-text="p.province+' ('+p.n+')'"></option>
|
|
</template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Stad</span>
|
|
<input type="text" x-model="wf.city" @change="loadWholesale()" placeholder="Amsterdam">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Sorteer</span>
|
|
<select x-model="wf.sort" @change="loadWholesale()">
|
|
<option value="name">Naam A-Z</option><option value="city">Stad</option><option value="province">Provincie</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
<details class="filter-group">
|
|
<summary>📞 Contact</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.has_phone" @change="loadWholesale()"><span>Heeft telefoon</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.has_email" @change="loadWholesale()"><span>Heeft e-mail</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="wf.halal_certified" @change="loadWholesale()"><span>Halal gecertificeerd</span></label>
|
|
</div>
|
|
</details>
|
|
<div class="filter-actions">
|
|
<button class="btn btn-sm btn-block btn-pulse" @click="loadWholesale()" :disabled="loading">Toepassen</button>
|
|
<button class="btn btn-sm btn-block" @click="resetWholesaleFilters()">Reset</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<aside class="retail-filters panel" x-show="mode==='opportunities'" x-cloak>
|
|
<div class="filters-head"><h3>Kansen filters</h3></div>
|
|
<label class="filter-field"><span class="filter-label">Keten</span>
|
|
<select x-model="of.chain" @change="loadOpportunities()"><option value="">Alle</option>
|
|
<template x-for="c in meta.chains" :key="c.chain"><option :value="c.chain" x-text="c.chain"></option></template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Provincie</span>
|
|
<select x-model="of.province" @change="loadOpportunities()"><option value="">Alle</option>
|
|
<template x-for="p in meta.provinces" :key="p.province"><option :value="p.province" x-text="p.province"></option></template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Min. score</span>
|
|
<input type="number" min="0" max="100" x-model.number="of.min_score" @change="loadOpportunities()">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Max. resultaten</span>
|
|
<input type="number" min="10" max="500" x-model.number="of.limit" @change="loadOpportunities()">
|
|
</label>
|
|
<button class="btn btn-sm btn-block btn-pulse" @click="loadOpportunities()">Toepassen</button>
|
|
</aside>
|
|
|
|
<aside class="retail-filters panel" x-show="mode==='cities'" x-cloak>
|
|
<div class="filters-head"><h3>Steden filters</h3></div>
|
|
<label class="filter-field"><span class="filter-label">Zoek stad</span>
|
|
<input type="text" x-model="cf.q" @keyup.enter="loadCities()" placeholder="Amsterdam">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Min. inwoners</span>
|
|
<input type="number" x-model.number="cf.min_population" @change="loadCities()">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Min. halal-markt %</span>
|
|
<input type="number" step="0.1" x-model.number="cf.min_muslim_pct" @change="loadCities()">
|
|
</label>
|
|
<label class="filter-field"><span class="filter-label">Sorteer</span>
|
|
<select x-model="cf.sort" @change="loadCities()">
|
|
<option value="population">Inwoners ↓</option>
|
|
<option value="muslim">Halal-markt ↓</option>
|
|
<option value="stores">Filialen ↓</option>
|
|
<option value="city">Naam A-Z</option>
|
|
</select>
|
|
</label>
|
|
<button class="btn btn-sm btn-block btn-pulse" @click="loadCities()">Toepassen</button>
|
|
</aside>
|
|
|
|
<aside class="retail-filters panel" x-show="mode==='stores'" x-cloak>
|
|
<div class="filters-head">
|
|
<h3>Filters</h3>
|
|
<span class="filter-count" x-show="activeFilterCount" x-text="activeFilterCount"></span>
|
|
</div>
|
|
|
|
<details class="filter-group" open>
|
|
<summary>📍 Locatie</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field">
|
|
<span class="filter-label">Keten</span>
|
|
<select x-model="f.chain" @change="apply()"><option value="">Alle ketens</option>
|
|
<template x-for="c in meta.chains" :key="c.chain"><option :value="c.chain" x-text="c.chain+' ('+c.n+')'"></option></template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Provincie</span>
|
|
<select x-model="f.province" @change="apply()"><option value="">Alle provincies</option>
|
|
<template x-for="p in meta.provinces" :key="p.province"><option :value="p.province" x-text="p.province+' ('+p.n+')'"></option></template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Stad</span>
|
|
<input type="text" x-model="f.city" @change="apply()" placeholder="Amsterdam">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Postcode</span>
|
|
<input type="text" x-model="f.postcode_prefix" @change="apply()" placeholder="1012">
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="filter-group" open>
|
|
<summary>🔎 Zoeken</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field">
|
|
<span class="filter-label">Zoekveld</span>
|
|
<input type="text" x-model="f.q" @keyup.enter="apply()" placeholder="Naam, adres, manager">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Sorteer</span>
|
|
<select x-model="f.sort" @change="apply()">
|
|
<option value="name">Naam A-Z</option>
|
|
<option value="halal_opportunity">Halal kans ↓</option>
|
|
<option value="population">Bevolking ↓</option>
|
|
<option value="chain">Keten</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="filter-group">
|
|
<summary>🤝 CRM & partnership</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field">
|
|
<span class="filter-label">Partnership status</span>
|
|
<select x-model="f.partnership" @change="apply()"><option value="">Alle statussen</option>
|
|
<template x-for="p in (meta.partnerships||[])" :key="p.partnership_status">
|
|
<option :value="p.partnership_status" x-text="(p.partnership_status||'none')+' ('+p.n+')'"></option>
|
|
</template>
|
|
</select>
|
|
</label>
|
|
<label class="filter-field filter-check">
|
|
<input type="checkbox" x-model="f.linked_to_crm" @change="apply()">
|
|
<span>Gekoppeld aan CRM</span>
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="filter-group">
|
|
<summary>☪️ Halal</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.halal_gap_only" @change="apply()"><span>Halal-gap kansen</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.halal_certified" @change="apply()"><span>Halal gecertificeerd</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_halal_section" @change="apply()"><span>Halal schap</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_halal_cert_registry" @change="apply()"><span>In halal registry</span></label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Kans score min</span>
|
|
<input type="number" min="0" max="100" x-model.number="f.min_halal_opportunity" @change="apply()" placeholder="35">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Kans score max</span>
|
|
<input type="number" min="0" max="100" x-model.number="f.max_halal_opportunity" @change="apply()" placeholder="100">
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="filter-group">
|
|
<summary>📊 CBS demografie</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_area_data" @change="apply()"><span>Met CBS data</span></label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Min. inwoners</span>
|
|
<input type="number" x-model.number="f.min_population" @change="apply()" placeholder="5000">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Max. inwoners</span>
|
|
<input type="number" x-model.number="f.max_population" @change="apply()" placeholder="500000">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Min. inkomen (€)</span>
|
|
<input type="number" x-model.number="f.min_avg_income" @change="apply()" :placeholder="meta.ranges?.min_income||''">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Max. inkomen (€)</span>
|
|
<input type="number" x-model.number="f.max_avg_income" @change="apply()" :placeholder="meta.ranges?.max_income||''">
|
|
</label>
|
|
<label class="filter-field">
|
|
<span class="filter-label">Min. halal-markt %</span>
|
|
<input type="number" step="0.1" x-model.number="f.min_muslim_pct" @change="apply()" placeholder="5">
|
|
</label>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="filter-group">
|
|
<summary>📞 Contact</summary>
|
|
<div class="filter-group-body">
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_phone" @change="apply()"><span>Heeft telefoon</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_email" @change="apply()"><span>Heeft e-mail</span></label>
|
|
<label class="filter-field filter-check"><input type="checkbox" x-model="f.has_manager" @change="apply()"><span>Heeft manager</span></label>
|
|
</div>
|
|
</details>
|
|
|
|
<div class="filter-actions">
|
|
<button class="btn btn-sm btn-block btn-pulse" @click="apply()" :disabled="loading">Toepassen</button>
|
|
<button class="btn btn-sm btn-block" @click="resetFilters()">Reset filters</button>
|
|
</div>
|
|
|
|
<div class="filter-sync">
|
|
<h4>Data sync</h4>
|
|
<button class="btn btn-sm btn-block btn-pulse" @click="sync('contacts')" :disabled="busy">OSM contacten</button>
|
|
<button class="btn btn-sm btn-block" @click="sync('halal')" :disabled="busy">Halal registry</button>
|
|
<button class="btn btn-sm btn-block" @click="sync('opportunities')" :disabled="busy">Kans-scores</button>
|
|
<button class="btn btn-sm btn-block btn-primary" @click="runEnrich()" :disabled="busy">CBS/PDOK batch</button>
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="retail-main">
|
|
<div x-show="mode==='stores'" class="retail-map-wrap">
|
|
<div id="map" class="retail-map"></div>
|
|
<div class="map-loading" x-show="loading">Laden…</div>
|
|
</div>
|
|
<div x-show="mode==='wholesale'" class="panel wholesale-list">
|
|
<h3>Groothandels <span class="live-badge">OSM + contact</span> <small x-text="'('+wholesaleTotal+' totaal)'"></small></h3>
|
|
<template x-for="w in wholesaleRows" :key="w.id">
|
|
<div class="wholesale-item" :class="selectedWholesale?.id===w.id?'active':''" @click="selectWholesale(w)">
|
|
<strong x-text="w.name"></strong>
|
|
<span class="ticker-item" x-show="w.halal_certified" style="margin-left:0.35rem">Halal</span><br>
|
|
<small x-text="(w.address||'')+', '+(w.city||'')"></small><br>
|
|
<small x-show="w.phone" x-text="'📞 '+w.phone"></small>
|
|
<small x-show="w.email" x-text="' · ✉ '+w.email"></small>
|
|
</div>
|
|
</template>
|
|
<p x-show="!wholesaleRows.length" class="muted">Geen groothandels — pas filters aan of klik Groothandels import.</p>
|
|
</div>
|
|
<div x-show="mode==='opportunities'" class="panel retail-table-wrap">
|
|
<h3>Top halal-markt kansen <small x-text="'('+oppRows.length+' resultaten)'"></small></h3>
|
|
<div class="retail-viz-row" style="display:grid;grid-template-columns:1fr 280px;gap:1rem;margin-bottom:1rem">
|
|
<canvas id="opp-chart" height="180"></canvas>
|
|
<div id="opp-stats" class="kpi-card"></div>
|
|
</div>
|
|
<table class="retail-table">
|
|
<thead><tr><th>Score</th><th>Filiaal</th><th>Stad</th><th>Halal%</th><th>CRM</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="o in oppRows" :key="o.id">
|
|
<tr @click="selectStore(o.id); mode='stores'" class="clickable-row">
|
|
<td><strong x-text="Math.round(o.halal_opportunity_score||0)"></strong></td>
|
|
<td x-text="o.chain+' · '+o.name"></td>
|
|
<td x-text="o.city"></td>
|
|
<td x-text="formatPct(o.muslim_proxy_pct)"></td>
|
|
<td x-text="o.partnership_status||'—'"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div x-show="mode==='cities'" class="panel retail-table-wrap">
|
|
<h3>Stad demografie <span class="live-badge">CBS</span> <small x-text="'('+cityRows.length+' steden)'"></small></h3>
|
|
<div class="retail-viz-row" style="display:grid;grid-template-columns:1fr 280px;gap:1rem;margin-bottom:1rem">
|
|
<canvas id="city-chart" height="180"></canvas>
|
|
<div id="city-stats" class="kpi-card"></div>
|
|
</div>
|
|
<table class="retail-table">
|
|
<thead><tr><th>Stad</th><th>Inwoners</th><th>Huishoudens</th><th>Halal-markt%</th><th>Filialen</th></tr></thead>
|
|
<tbody>
|
|
<template x-for="c in cityRows" :key="c.id">
|
|
<tr><td x-text="c.city"></td><td x-text="formatNum(c.population)"></td>
|
|
<td x-text="formatNum(c.households)"></td><td x-text="formatPct(c.muslim_proxy_pct)"></td>
|
|
<td x-text="c.store_count"></td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="retail-panel panel">
|
|
<template x-if="selected && mode==='stores'">
|
|
<div class="store-detail">
|
|
<h3 x-text="view360.store?.name||detail.name"></h3>
|
|
<p class="muted"><span x-text="view360.store?.chain||detail.chain"></span> · <span x-text="view360.store?.city||detail.city"></span></p>
|
|
<p><strong x-text="view360.store?.address||detail.address"></strong></p>
|
|
<p class="muted" x-text="(view360.store?.postcode||'')+' '+(view360.store?.province||'')"></p>
|
|
|
|
<div class="section-tabs">
|
|
<button class="section-tab" :class="section==='overview'?'active':''" @click="section='overview'">Overzicht</button>
|
|
<button class="section-tab" :class="section==='crm'?'active':''" @click="section='crm'">CRM</button>
|
|
<button class="section-tab" :class="section==='notes'?'active':''" @click="section='notes'">Notes</button>
|
|
<button class="section-tab" :class="section==='media'?'active':''" @click="section='media'">Media</button>
|
|
<button class="section-tab" :class="section==='milestones'?'active':''" @click="section='milestones'">Sales</button>
|
|
<button class="section-tab" :class="section==='ownership'?'active':''" @click="section='ownership'">Overnames</button>
|
|
<button class="section-tab" :class="section==='weather'?'active':''" @click="section='weather'">Weer</button>
|
|
<button class="section-tab" :class="section==='agenda'?'active':''" @click="section='agenda'">Agenda</button>
|
|
</div>
|
|
|
|
<div x-show="section==='overview'">
|
|
<dl class="detail-dl">
|
|
<dt>Telefoon</dt><dd x-text="detail.phone||view360.store?.phone||'—'"></dd>
|
|
<dt>E-mail</dt><dd x-text="detail.email||view360.store?.email||'—'"></dd>
|
|
<dt>Leidinggevende</dt><dd x-text="detail.manager_name||'—'"></dd>
|
|
<dt>LinkedIn</dt><dd><a x-show="detail.linkedin_url" :href="detail.linkedin_url" target="_blank" x-text="detail.linkedin_url"></a><span x-show="!detail.linkedin_url">—</span></dd>
|
|
<dt>Halal kans</dt><dd x-text="Math.round(detail.opportunity?.halal_opportunity_score||0)+'/100'"></dd>
|
|
<dt>Stad inwoners</dt><dd x-text="formatNum(view360.catchment?.city_population)"></dd>
|
|
<dt>Filialen in stad</dt><dd x-text="view360.catchment?.stores_in_city||0"></dd>
|
|
<dt>Postcode bevolking</dt><dd x-text="formatNum(view360.catchment?.postcode_population_proxy)"></dd>
|
|
</dl>
|
|
<div class="detail-section" x-show="view360.area_analysis">
|
|
<h4>CBS Demografie</h4>
|
|
<dl class="detail-dl">
|
|
<dt>Bevolking</dt><dd x-text="formatNum(view360.area_analysis.population)"></dd>
|
|
<dt>Gem. inkomen</dt><dd x-text="formatEuro(view360.area_analysis.avg_income)"></dd>
|
|
<dt>Halal-markt proxy</dt><dd x-text="formatPct(view360.area_analysis.religious_composition?.muslim_proxy_pct)"></dd>
|
|
</dl>
|
|
</div>
|
|
<div class="detail-section" x-show="(detail.crm?.contacts||[]).length">
|
|
<h4>Contactpersonen</h4>
|
|
<template x-for="c in (detail.crm?.contacts||[])" :key="c.id">
|
|
<div class="note-bubble">
|
|
<strong x-text="c.full_name||c.role"></strong> · <small x-text="c.role"></small>
|
|
<p x-show="c.phone" x-text="'📞 '+c.phone"></p>
|
|
<p x-show="c.email" x-text="'✉ '+c.email"></p>
|
|
<a x-show="c.linkedin_url" :href="c.linkedin_url" target="_blank">LinkedIn →</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="section==='crm'">
|
|
<template x-for="link in (detail.crm?.links||[])" :key="link.id">
|
|
<p><strong x-text="link.client_name"></strong> · <span x-text="link.relationship_type"></span></p>
|
|
</template>
|
|
<label>Klant<select x-model.number="linkForm.client_id">
|
|
<option value="">— kies klant —</option>
|
|
<template x-for="c in crmOptions.clients" :key="c.id">
|
|
<option :value="c.id" x-text="c.name"></option>
|
|
</template>
|
|
</select></label>
|
|
<label>Deal<select x-model.number="linkForm.deal_id">
|
|
<option value="">— optioneel —</option>
|
|
<template x-for="d in crmOptions.deals" :key="d.id">
|
|
<option :value="d.id" x-text="d.title"></option>
|
|
</template>
|
|
</select></label>
|
|
<button class="btn btn-sm btn-primary btn-block btn-pulse" @click="linkCrm()" :disabled="!linkForm.client_id">Koppel aan CRM</button>
|
|
<a class="btn btn-sm btn-block" href="/clients">Open CRM →</a>
|
|
</div>
|
|
|
|
<div x-show="section==='notes'">
|
|
<template x-for="n in (view360.notes||[])" :key="n.id">
|
|
<div class="note-bubble">
|
|
<strong x-text="n.title||'Note'"></strong>
|
|
<p x-text="n.body"></p>
|
|
<small x-text="n.created_at"></small>
|
|
</div>
|
|
</template>
|
|
<textarea x-model="noteForm.body" class="form-input" rows="3" placeholder="Nieuwe note…"></textarea>
|
|
<input x-model="noteForm.title" class="form-input" placeholder="Titel (optioneel)">
|
|
<button class="btn btn-sm btn-pulse btn-block" @click="addNote()" :disabled="!noteForm.body">Note opslaan</button>
|
|
</div>
|
|
|
|
<div x-show="section==='media'">
|
|
<div class="media-grid">
|
|
<template x-for="m in (view360.media||[])" :key="m.id">
|
|
<img class="media-thumb" :src="m.storage_path" :alt="m.caption||m.filename">
|
|
</template>
|
|
</div>
|
|
<input x-model="mediaForm.url" class="form-input" placeholder="Foto URL">
|
|
<input x-model="mediaForm.caption" class="form-input" placeholder="Bijschrift">
|
|
<button class="btn btn-sm btn-pulse-purple btn-block" @click="addMedia()" :disabled="!mediaForm.url">Foto toevoegen</button>
|
|
</div>
|
|
|
|
<div x-show="section==='milestones'">
|
|
<template x-for="m in (view360.milestones||[])" :key="m.id">
|
|
<div class="milestone-item">
|
|
<span class="milestone-dot" :class="m.status==='completed'?'done':''"></span>
|
|
<div><strong x-text="m.title"></strong><br>
|
|
<small x-text="m.milestone_type+' · '+m.status+(m.value_eur?' · €'+m.value_eur:'')"></small></div>
|
|
</div>
|
|
</template>
|
|
<input x-model="milestoneForm.title" class="form-input" placeholder="Milestone titel">
|
|
<select x-model="milestoneForm.milestone_type" class="form-input">
|
|
<option value="first_order">Eerste order</option>
|
|
<option value="listing">Listing</option>
|
|
<option value="promo">Promotie</option>
|
|
<option value="renewal">Verlenging</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
<input type="number" x-model.number="milestoneForm.value_eur" class="form-input" placeholder="Waarde €">
|
|
<button class="btn btn-sm btn-pulse-green btn-block" @click="addMilestone()" :disabled="!milestoneForm.title">Milestone toevoegen</button>
|
|
</div>
|
|
|
|
<div x-show="section==='ownership'">
|
|
<template x-for="o in (view360.ownership_changes||[])" :key="o.id">
|
|
<div class="note-bubble">
|
|
<strong x-text="o.new_owner"></strong>
|
|
<p x-text="'Was: '+(o.previous_owner||'?')+' · '+o.change_type"></p>
|
|
<small x-text="o.effective_date||o.created_at"></small>
|
|
</div>
|
|
</template>
|
|
<input x-model="ownershipForm.new_owner" class="form-input" placeholder="Nieuwe eigenaar">
|
|
<input x-model="ownershipForm.previous_owner" class="form-input" placeholder="Vorige eigenaar">
|
|
<select x-model="ownershipForm.change_type" class="form-input">
|
|
<option value="acquisition">Overname</option>
|
|
<option value="merger">Fusie</option>
|
|
<option value="rebrand">Rebrand</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-block" @click="addOwnership()" :disabled="!ownershipForm.new_owner">Overname registreren</button>
|
|
</div>
|
|
|
|
<div x-show="section==='weather'">
|
|
<p class="live-badge">7-daagse voorspelling · Open-Meteo</p>
|
|
<div class="weather-row">
|
|
<template x-for="d in (view360.weather_forecast||[])" :key="d.date">
|
|
<div class="weather-day">
|
|
<span x-text="d.date"></span>
|
|
<strong x-text="Math.round(d.temperature_c||0)+'°'"></strong>
|
|
<span x-text="(d.precipitation_mm||0)+'mm'"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="section==='agenda'">
|
|
<template x-for="ev in (view360.calendar||[])" :key="ev.id">
|
|
<div class="note-bubble">
|
|
<strong x-text="ev.title"></strong>
|
|
<p x-text="ev.starts_at"></p>
|
|
</div>
|
|
</template>
|
|
<input x-model="calendarForm.title" class="form-input" placeholder="Afspraak titel">
|
|
<input type="datetime-local" x-model="calendarForm.starts_at" class="form-input">
|
|
<button class="btn btn-sm btn-pulse btn-block" @click="addCalendar()" :disabled="!calendarForm.title">In agenda zetten</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template x-if="selectedWholesale && mode==='wholesale'">
|
|
<div>
|
|
<h3 x-text="selectedWholesale.name"></h3>
|
|
<dl class="detail-dl">
|
|
<dt>Adres</dt><dd x-text="selectedWholesale.address+', '+selectedWholesale.postcode+' '+selectedWholesale.city"></dd>
|
|
<dt>Provincie</dt><dd x-text="selectedWholesale.province||'—'"></dd>
|
|
<dt>Telefoon</dt><dd x-text="selectedWholesale.phone||'—'"></dd>
|
|
<dt>E-mail</dt><dd x-text="selectedWholesale.email||'—'"></dd>
|
|
<dt>Website</dt><dd><a x-show="selectedWholesale.website" :href="selectedWholesale.website" target="_blank" x-text="selectedWholesale.website"></a><span x-show="!selectedWholesale.website">—</span></dd>
|
|
<dt>LinkedIn</dt><dd><a x-show="selectedWholesale.linkedin_url" :href="selectedWholesale.linkedin_url" target="_blank">Profiel →</a><span x-show="!selectedWholesale.linkedin_url">—</span></dd>
|
|
</dl>
|
|
<div class="detail-section" x-show="wholesaleContacts.length">
|
|
<h4>Contactpersonen</h4>
|
|
<template x-for="c in wholesaleContacts" :key="c.id">
|
|
<div class="note-bubble">
|
|
<strong x-text="c.full_name"></strong> · <small x-text="c.role"></small>
|
|
<p x-show="c.phone" x-text="'📞 '+c.phone"></p>
|
|
<p x-show="c.email" x-text="'✉ '+c.email"></p>
|
|
<a x-show="c.linkedin_url" :href="c.linkedin_url" target="_blank">LinkedIn →</a>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<input x-model="whContactForm.full_name" class="form-input" placeholder="Naam contactpersoon">
|
|
<input x-model="whContactForm.phone" class="form-input" placeholder="Telefoon">
|
|
<input x-model="whContactForm.email" class="form-input" placeholder="E-mail">
|
|
<input x-model="whContactForm.linkedin_url" class="form-input" placeholder="LinkedIn URL">
|
|
<button class="btn btn-sm btn-pulse btn-block" @click="addWhContact()" :disabled="!whContactForm.full_name">Contact toevoegen</button>
|
|
</div>
|
|
</template>
|
|
<template x-if="!selected && mode==='stores'">
|
|
<p class="muted">Selecteer een filiaal op de kaart voor de volledige 360° view.</p>
|
|
</template>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
|
<script>
|
|
function retailIntel(){
|
|
return {
|
|
mode:'stores', section:'overview', map:null, cluster:null, loading:false, busy:false,
|
|
stats: {{ stats | tojson }}, meta: {{ filters | tojson }}, trends: {{ trends | tojson }},
|
|
crmOptions: {{ crm_options | tojson }},
|
|
f:{
|
|
chain:'',province:'',city:'',postcode_prefix:'',q:'',sort:'name',partnership:'',
|
|
halal_gap_only:false,halal_certified:false,has_halal_section:false,has_halal_cert_registry:false,
|
|
linked_to_crm:false,has_area_data:false,has_phone:false,has_email:false,has_manager:false,
|
|
min_population:null,max_population:null,min_avg_income:null,max_avg_income:null,
|
|
min_muslim_pct:null,min_halal_opportunity:null,max_halal_opportunity:null
|
|
},
|
|
selected:null, detail:{}, view360:{}, tableRows:[], oppRows:[], cityRows:[],
|
|
wholesaleRows:[], wholesaleCount:0, wholesaleTotal:0, wholesaleMeta:{}, wholesaleContacts:[], selectedWholesale:null,
|
|
wf:{ q:'', province:'', city:'', sort:'name', has_phone:false, has_email:false, halal_certified:false },
|
|
of:{ chain:'', province:'', min_score:35, limit:100 },
|
|
cf:{ q:'', min_population:null, min_muslim_pct:null, sort:'population', limit:200 },
|
|
whContactForm:{ full_name:'', phone:'', email:'', linkedin_url:'' },
|
|
oppChart:null, cityChart:null,
|
|
rssItems:[], resultCount:0, _pollStop:null,
|
|
linkForm:{ client_id:'', deal_id:'', partnership_status:'proposal', relationship_type:'prospect' },
|
|
noteForm:{ title:'', body:'' }, mediaForm:{ url:'', caption:'' },
|
|
milestoneForm:{ title:'', milestone_type:'custom', value_eur:null },
|
|
ownershipForm:{ new_owner:'', previous_owner:'', change_type:'acquisition' },
|
|
calendarForm:{ title:'', starts_at:'' },
|
|
|
|
async init(){
|
|
this.map = L.map('map').setView([52.15,5.3],8);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:18}).addTo(this.map);
|
|
this.cluster = L.markerClusterGroup(); this.map.addLayer(this.cluster);
|
|
this.loadWholesaleMeta(); this.loadRss(); this.loadWholesale(); await this.apply();
|
|
this._pollStop = CockpitLive.startPolling(() => { this.loadRss(); if(this.mode==='wholesale') this.loadWholesale(); }, 45000);
|
|
},
|
|
get activeFilterCount(){
|
|
let n=0;
|
|
Object.entries(this.f).forEach(([k,v])=>{
|
|
if(v===null||v===''||v===false) return;
|
|
if(k==='sort'&&v==='name') return;
|
|
n++;
|
|
});
|
|
return n?n+' actief':'';
|
|
},
|
|
resetFilters(){
|
|
this.f={ chain:'',province:'',city:'',postcode:'',q:'',sort:'name',partnership:'',
|
|
halal_gap_only:false,halal_certified:false,has_halal_section:false,has_halal_cert_registry:false,
|
|
linked_to_crm:false,has_area_data:false,has_phone:false,has_email:false,has_manager:false,
|
|
min_population:null,max_population:null,min_avg_income:null,max_avg_income:null,
|
|
min_muslim_pct:null,min_halal_opportunity:null,max_halal_opportunity:null };
|
|
this.apply();
|
|
},
|
|
params(){
|
|
const p = new URLSearchParams();
|
|
Object.entries(this.f).forEach(([k,v])=>{
|
|
if(v===null||v===''||v===false) return;
|
|
if(typeof v==='boolean') p.set(k, v?'true':'false'); else p.set(k,v);
|
|
});
|
|
return p.toString();
|
|
},
|
|
async apply(){
|
|
this.loading=true;
|
|
try{
|
|
const [stats,mapData]=await Promise.all([
|
|
fetch('/api/retail/stats?'+this.params()).then(r=>r.json()),
|
|
fetch('/api/retail/map?'+this.params()).then(r=>r.json()),
|
|
]);
|
|
this.stats=stats; this.resultCount=mapData.count||0;
|
|
this.renderMarkers(mapData.items||[]);
|
|
}finally{ this.loading=false; }
|
|
},
|
|
renderMarkers(stores){
|
|
this.cluster.clearLayers();
|
|
const colors={'Albert Heijn':'#0066cc','Jumbo':'#ffcc00','Plus':'#008040','Lidl':'#0050aa','ALDI':'#0066b3','Dirk':'#e30613'};
|
|
stores.forEach(s=>{
|
|
if(!s.latitude||!s.longitude) return;
|
|
const active=s.partnership_status==='active';
|
|
const score=s.halal_opportunity_score||0;
|
|
const color=active?'#22c55e':(s.halal_certified?'#a855f7':(colors[s.chain]||'#64748b'));
|
|
const m=L.circleMarker([+s.latitude,+s.longitude],{radius:active?10:7,color,weight:2,fillColor:color,fillOpacity:0.85});
|
|
m.on('click',()=>this.selectStore(s.id));
|
|
m.bindTooltip(`${s.chain}: ${s.name}${score?' · kans '+Math.round(score):''}`,{direction:'top'});
|
|
this.cluster.addLayer(m);
|
|
});
|
|
},
|
|
async selectStore(id){
|
|
const [detail, v360] = await Promise.all([
|
|
fetch('/api/retail/supermarkets/'+id).then(r=>r.json()),
|
|
fetch('/api/retail/360/'+id).then(r=>r.json()),
|
|
]);
|
|
this.detail=detail; this.view360=v360; this.selected=id; this.section='overview';
|
|
const lat=v360.store?.latitude||detail.latitude;
|
|
const lon=v360.store?.longitude||detail.longitude;
|
|
if(lat) this.map.setView([+lat,+lon],14);
|
|
},
|
|
async loadOpportunities(){
|
|
const p=new URLSearchParams({limit:String(this.of.limit||100),min_score:String(this.of.min_score||35)});
|
|
if(this.of.chain) p.set('chain',this.of.chain);
|
|
if(this.of.province) p.set('province',this.of.province);
|
|
const d=await fetch('/api/retail/opportunities?'+p).then(r=>r.json());
|
|
this.oppRows=d.items||[];
|
|
this.renderOppChart();
|
|
},
|
|
renderOppChart(){
|
|
if(typeof Chart==='undefined') return;
|
|
const el=document.getElementById('opp-chart'); if(!el) return;
|
|
const top=this.oppRows.slice(0,12);
|
|
if(this.oppChart) this.oppChart.destroy();
|
|
this.oppChart=new Chart(el,{type:'bar',data:{
|
|
labels:top.map(o=>(o.chain||'')+' '+(o.city||'')),
|
|
datasets:[{label:'Halal score',data:top.map(o=>Math.round(o.halal_opportunity_score||0)),backgroundColor:'rgba(168,85,247,0.75)',borderRadius:6}]
|
|
},options:{indexAxis:'y',responsive:true,plugins:{legend:{display:false}}}});
|
|
const st=document.getElementById('opp-stats');
|
|
if(st){ const avg=top.length?Math.round(top.reduce((a,o)=>a+Number(o.halal_opportunity_score||0),0)/top.length):0;
|
|
st.innerHTML='<span>Gem. score</span><strong>'+avg+'/100</strong><br><small>'+this.oppRows.length+' filialen</small>'; }
|
|
},
|
|
async loadCities(){
|
|
const p=new URLSearchParams({limit:String(this.cf.limit||200),sort:this.cf.sort||'population'});
|
|
if(this.cf.q) p.set('q',this.cf.q);
|
|
if(this.cf.min_population) p.set('min_population',this.cf.min_population);
|
|
if(this.cf.min_muslim_pct) p.set('min_muslim_pct',this.cf.min_muslim_pct);
|
|
const d=await fetch('/api/retail/cities?'+p).then(r=>r.json());
|
|
this.cityRows=d.items||[];
|
|
this.renderCityChart();
|
|
},
|
|
renderCityChart(){
|
|
if(typeof Chart==='undefined') return;
|
|
const el=document.getElementById('city-chart'); if(!el) return;
|
|
const top=this.cityRows.slice(0,15);
|
|
if(this.cityChart) this.cityChart.destroy();
|
|
this.cityChart=new Chart(el,{type:'bar',data:{
|
|
labels:top.map(c=>c.city),
|
|
datasets:[{label:'Inwoners',data:top.map(c=>Number(c.population||0)),backgroundColor:'rgba(56,189,248,0.75)',borderRadius:6}]
|
|
},options:{responsive:true,plugins:{legend:{display:false}}}});
|
|
const st=document.getElementById('city-stats');
|
|
if(st){ st.innerHTML='<span>Steden</span><strong>'+this.cityRows.length+'</strong><br><small>top: '+(top[0]?.city||'—')+'</small>'; }
|
|
},
|
|
async loadWholesale(){
|
|
const p = new URLSearchParams({ limit: '500', sort: this.wf.sort });
|
|
if(this.wf.q) p.set('q', this.wf.q);
|
|
if(this.wf.province) p.set('province', this.wf.province);
|
|
if(this.wf.city) p.set('city', this.wf.city);
|
|
if(this.wf.has_phone) p.set('has_phone', 'true');
|
|
if(this.wf.has_email) p.set('has_email', 'true');
|
|
if(this.wf.halal_certified) p.set('halal_certified', 'true');
|
|
const d=await fetch('/api/retail/wholesalers?'+p).then(r=>r.json());
|
|
this.wholesaleRows=d.items||[]; this.wholesaleCount=d.count||0; this.wholesaleTotal=d.total||d.count||0;
|
|
},
|
|
async loadWholesaleMeta(){
|
|
try{ this.wholesaleMeta=await fetch('/api/retail/wholesalers/meta').then(r=>r.json()); }catch(e){}
|
|
},
|
|
resetWholesaleFilters(){
|
|
this.wf={ q:'', province:'', city:'', sort:'name', has_phone:false, has_email:false, halal_certified:false };
|
|
this.loadWholesale();
|
|
},
|
|
async selectWholesale(w){
|
|
this.selectedWholesale=w;
|
|
this.wholesaleContacts=[];
|
|
if(w?.id){
|
|
try{
|
|
const d=await fetch('/api/retail/wholesalers/'+w.id+'/contacts').then(r=>r.json());
|
|
this.wholesaleContacts=d.contacts||[];
|
|
}catch(e){}
|
|
}
|
|
if(w.latitude) this.map.setView([+w.latitude,+w.longitude],13);
|
|
},
|
|
async addWhContact(){
|
|
if(!this.selectedWholesale?.id||!this.whContactForm.full_name) return;
|
|
await fetch('/api/retail/wholesalers/'+this.selectedWholesale.id+'/contacts',{
|
|
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(this.whContactForm)
|
|
});
|
|
this.whContactForm={ full_name:'', phone:'', email:'', linkedin_url:'' };
|
|
await this.selectWholesale(this.selectedWholesale);
|
|
},
|
|
async loadRss(){
|
|
try{ const d=await fetch('/api/retail/rss/live?limit=20&category=kant-en-klaar').then(r=>r.json());
|
|
if(!(d.items||[]).length) {
|
|
const d2=await fetch('/api/retail/rss/live?limit=20').then(r=>r.json());
|
|
this.rssItems=d2.items||[];
|
|
} else { this.rssItems=d.items||[]; }
|
|
}catch(e){}
|
|
},
|
|
async refreshRss(){ this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadRss(); }finally{ this.busy=false; } },
|
|
async importWholesale(){ this.busy=true; try{ await fetch('/api/retail/wholesalers/import',{method:'POST'}); await this.loadWholesale(); }finally{ this.busy=false; } },
|
|
async syncCities(){ this.busy=true; try{ await fetch('/api/retail/cities/sync?limit=50',{method:'POST'}); await this.loadCities(); }finally{ this.busy=false; } },
|
|
async linkCrm(){
|
|
if(!this.selected||!this.linkForm.client_id) return;
|
|
await fetch('/api/retail/supermarkets/'+this.selected+'/link',{
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({...this.linkForm, deal_id:this.linkForm.deal_id||null})
|
|
});
|
|
await this.selectStore(this.selected); await this.apply();
|
|
},
|
|
async addNote(){
|
|
await fetch('/api/retail/360/'+this.selected+'/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.noteForm)});
|
|
this.noteForm={title:'',body:''}; await this.selectStore(this.selected);
|
|
},
|
|
async addMedia(){
|
|
const fn=this.mediaForm.url.split('/').pop()||'photo.jpg';
|
|
await fetch('/api/retail/360/'+this.selected+'/media',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({filename:fn,storage_path:this.mediaForm.url,caption:this.mediaForm.caption})});
|
|
this.mediaForm={url:'',caption:''}; await this.selectStore(this.selected);
|
|
},
|
|
async addMilestone(){
|
|
await fetch('/api/retail/360/'+this.selected+'/milestones',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.milestoneForm)});
|
|
this.milestoneForm={title:'',milestone_type:'custom',value_eur:null}; await this.selectStore(this.selected);
|
|
},
|
|
async addOwnership(){
|
|
await fetch('/api/retail/360/'+this.selected+'/ownership',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.ownershipForm)});
|
|
this.ownershipForm={new_owner:'',previous_owner:'',change_type:'acquisition'}; await this.selectStore(this.selected);
|
|
},
|
|
async addCalendar(){
|
|
await fetch('/api/retail/360/'+this.selected+'/calendar',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(this.calendarForm)});
|
|
this.calendarForm={title:'',starts_at:''}; await this.selectStore(this.selected);
|
|
},
|
|
async sync(action){ this.busy=true; try{ await fetch('/api/retail/sync/'+action,{method:'POST'}); await this.apply(); }finally{ this.busy=false; } },
|
|
async runEnrich(){ this.busy=true; try{ await fetch('/api/retail/enrich?limit=100',{method:'POST'}); await this.apply(); }finally{ this.busy=false; } },
|
|
formatEuro(v){ return v?'€ '+Math.round(v).toLocaleString('nl-NL'):'—'; },
|
|
formatNum(v){ return v?Number(v).toLocaleString('nl-NL'):'—'; },
|
|
formatPct(v){ return (v!=null&&v!=='')?Number(v).toFixed(1)+'%':'—'; },
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|