Files
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

248 lines
11 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" />
<div x-data="retailIntel()" x-init="init()">
<header class="page-header">
<div>
<h1>Retail Intelligence</h1>
<p class="subtitle">Supermarkten · CBS demografie · PDOK · partnerships</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm" @click="applyFilters()" :disabled="loading">Vernieuwen</button>
<button class="btn btn-sm btn-primary" @click="runEnrich()" :disabled="enriching">
<span x-text="enriching ? 'CBS laden…' : 'CBS/PDOK verrijken'"></span>
</button>
</div>
</header>
<div class="kpi-row">
<div class="kpi-card"><span>Totaal</span><strong x-text="stats.total || 0"></strong></div>
<div class="kpi-card kpi-green"><span>Actief</span><strong x-text="stats.active_partnerships || 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>Gem. inkomen</span><strong x-text="formatEuro(stats.avg_area_income)"></strong></div>
<div class="kpi-card"><span>Gem. halal-markt*</span><strong x-text="formatPct(stats.avg_muslim_proxy_pct)"></strong></div>
<div class="kpi-card"><span>Resultaat</span><strong x-text="mapCount"></strong></div>
</div>
<div class="retail-layout">
<aside class="retail-filters panel">
<h3>Filters</h3>
<label>Keten
<select x-model="f.chain" @change="applyFilters()">
<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>Provincie
<select x-model="f.province" @change="applyFilters()">
<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>Partnership
<select x-model="f.partnership" @change="applyFilters()">
<option value="">Alle</option>
<option value="active">Actief</option>
<option value="proposal">Voorstel</option>
<option value="none">Geen</option>
</select>
</label>
<label>Postcode prefix<input type="text" x-model="f.postcode_prefix" maxlength="4" placeholder="10" @change="applyFilters()"></label>
<label>Zoeken<input type="text" x-model="f.q" placeholder="Naam, adres, stad" @keyup.enter="applyFilters()"></label>
<label class="checkbox"><input type="checkbox" x-model="f.halal_certified" @change="applyFilters()"> Halal gecertificeerd</label>
<label class="checkbox"><input type="checkbox" x-model="f.has_area_data" @change="applyFilters()"> Alleen met CBS data</label>
<h4>CBS demografie (gemeente-niveau)</h4>
<label>Min. bevolking<input type="number" x-model.number="f.min_population" min="0" step="1000" @change="applyFilters()"></label>
<label>Min. gem. inkomen (€)<input type="number" x-model.number="f.min_avg_income" min="0" step="1000" @change="applyFilters()"></label>
<label>Min. halal-markt proxy (%)*<input type="number" x-model.number="f.min_muslim_pct" min="0" max="100" step="0.5" @change="applyFilters()"></label>
<p class="hint">*Indicatief: Marokko+Turkije / bevolking (CBS)</p>
<button class="btn btn-sm" @click="resetFilters()">Reset filters</button>
<div class="enrich-status" x-show="enrichment">
<h4>Databronnen</h4>
<p><span x-text="enrichment.enriched_postcodes || 0"></span> / <span x-text="enrichment.unique_postcodes || 0"></span> postcodes verrijkt</p>
<ul class="provider-list">
<template x-for="p in (enrichment.data_providers || [])" :key="p.name">
<li><span x-text="p.name"></span> <small x-text="p.last_status || '—'"></small></li>
</template>
</ul>
</div>
</aside>
<div class="retail-map-wrap">
<div id="map" class="retail-map"></div>
<div class="map-loading" x-show="loading">Kaart laden…</div>
</div>
<aside class="retail-panel panel">
<template x-if="selected">
<div class="store-detail">
<h3 x-text="selected.name"></h3>
<p class="muted"><span x-text="selected.chain"></span> · <span x-text="selected.city"></span> · <span x-text="selected.postcode"></span></p>
<p x-text="selected.address"></p>
<div class="detail-section">
<h4>Partnership</h4>
<p>Status: <strong x-text="selected.partnership_status"></strong></p>
<p x-show="selected.halal_certified">✅ Halal gecertificeerd</p>
</div>
<template x-if="detail.area_analysis">
<div class="detail-section">
<h4>CBS Demografie</h4>
<p class="hint" x-text="detail.area_analysis.data_source"></p>
<dl class="detail-dl">
<dt>Bevolking</dt><dd x-text="formatNum(detail.area_analysis.population)"></dd>
<dt>Huishoudens</dt><dd x-text="formatNum(detail.area_analysis.households)"></dd>
<dt>Gem. inkomen</dt><dd x-text="formatEuro(detail.area_analysis.avg_income)"></dd>
<dt>Halal-markt proxy</dt><dd x-text="formatPct(detail.area_analysis.religious_composition?.muslim_proxy_pct)"></dd>
<dt>Niet-Westers %</dt><dd x-text="formatPct(detail.area_analysis.ethnic_composition?.niet_westers_pct)"></dd>
</dl>
</div>
</template>
<template x-if="!detail.area_analysis">
<p class="hint">Geen CBS data — klik 'CBS/PDOK verrijken'</p>
</template>
<template x-if="detail.nearby_stores && detail.nearby_stores.length">
<div class="detail-section">
<h4>Concurrentie (&lt;3 km)</h4>
<ul class="nearby-list">
<template x-for="n in detail.nearby_stores" :key="n.id">
<li @click="selectStore(n.id)" class="clickable-row">
<span x-text="n.chain"></span><span x-text="n.name"></span>
<small x-text="`${n.distance_km?.toFixed?.(1) || n.distance_km} km`"></small>
</li>
</template>
</ul>
</div>
</template>
<template x-if="detail.recommendations && detail.recommendations.length">
<div class="detail-section">
<h4>Herman</h4>
<template x-for="r in detail.recommendations" :key="r.id">
<p><strong x-text="r.title"></strong><br><small x-text="r.description"></small></p>
</template>
</div>
</template>
</div>
</template>
<template x-if="!selected">
<p class="muted">Klik een marker voor CBS-data, concurrentie en Herman-tips.</p>
<h4>Herman Top picks</h4>
<ul class="reco-list">
{% for r in recommendations %}
<li><strong>{{ r.title }}</strong><br><small>{{ r.priority }} · {{ r.recommendation_type }}</small></li>
{% endfor %}
</ul>
</template>
</aside>
</div>
</div>
<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 {
map: null, cluster: null, markers: [],
stats: {{ stats | tojson }},
meta: {{ filters | tojson }},
enrichment: {{ enrichment | tojson }},
f: { chain:'', province:'', partnership:'', postcode_prefix:'', q:'',
halal_certified:false, has_area_data:false,
min_population:null, min_avg_income:null, min_muslim_pct:null },
selected: null, detail: {}, loading: false, enriching: false, mapCount: 0,
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.applyFilters();
},
params(){
const p = new URLSearchParams();
Object.entries(this.f).forEach(([k,v]) => {
if (v === null || v === '' || v === false) return;
p.set(k, v);
});
return p.toString();
},
async applyFilters(){
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.mapCount = mapData.count || 0;
this.renderMarkers(mapData.items || []);
} finally { this.loading = false; }
},
renderMarkers(stores){
this.cluster.clearLayers();
this.markers = [];
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 color = active ? '#22c55e' : (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.storeId = s.id;
m.on('click', () => this.selectStore(s.id));
m.bindTooltip(`${s.chain}: ${s.name}`, { direction: 'top' });
this.cluster.addLayer(m);
this.markers.push(m);
});
},
async selectStore(id){
const d = await fetch(`/api/retail/supermarkets/${id}`).then(r=>r.json());
this.selected = d;
this.detail = d;
if (d.latitude && d.longitude) this.map.setView([+d.latitude, +d.longitude], 14);
},
resetFilters(){
this.f = { chain:'', province:'', partnership:'', postcode_prefix:'', q:'',
halal_certified:false, has_area_data:false,
min_population:null, min_avg_income:null, min_muslim_pct:null };
this.selected = null; this.detail = {};
this.applyFilters();
},
async runEnrich(){
this.enriching = true;
try {
await fetch('/api/retail/enrich?limit=100', { method:'POST' });
this.enrichment = await fetch('/api/retail/filters').then(r=>r.json()).then(d=>d.enrichment);
await this.applyFilters();
} finally { this.enriching = 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 !== undefined) ? v + '%' : '—'; },
};
}
</script>
{% endblock %}