Files

412 lines
24 KiB
HTML
Raw Permalink Normal View History

{% 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 <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">
<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</button>
<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==='stores'">
<h3>Filters</h3>
<label>Keten<select x-model="f.chain" @change="apply()"><option value="">Alle</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="apply()"><option value="">Alle</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>Stad<input x-model="f.city" @change="apply()" placeholder="Amsterdam"></label>
<label>Zoek<input x-model="f.q" @keyup.enter="apply()" placeholder="Naam, adres"></label>
<label class="checkbox"><input type="checkbox" x-model="f.halal_gap_only" @change="apply()"> Halal-gap kansen</label>
<label class="checkbox"><input type="checkbox" x-model="f.linked_to_crm" @change="apply()"> Gekoppeld CRM</label>
<label class="checkbox"><input type="checkbox" x-model="f.has_area_data" @change="apply()"> Met CBS data</label>
<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>
</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</span></h3>
<input class="form-input" x-model="wholesaleQ" @keyup.enter="loadWholesale()" placeholder="Zoek groothandel…" style="margin-bottom:0.5rem">
<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><br>
<small x-text="w.address+', '+w.city"></small>
</div>
</template>
<p x-show="!wholesaleRows.length" class="muted">Geen groothandels — klik Groothandels import.</p>
</div>
<div x-show="mode==='opportunities'" class="panel retail-table-wrap">
<h3>Top halal-markt kansen</h3>
<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></h3>
<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||'—'"></dd>
<dt>Leidinggevende</dt><dd x-text="detail.manager_name||'—'"></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>
<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>
<p x-text="selectedWholesale.address"></p>
<p x-text="selectedWholesale.city+' · '+selectedWholesale.phone"></p>
<a class="btn btn-sm" :href="selectedWholesale.website" target="_blank" x-show="selectedWholesale.website">Website →</a>
</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://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:'',q:'',halal_gap_only:false,linked_to_crm:false,has_area_data:false },
selected:null, detail:{}, view360:{}, tableRows:[], oppRows:[], cityRows:[],
wholesaleRows:[], wholesaleCount:0, wholesaleQ:'', selectedWholesale:null,
rssItems:[], resultCount:0,
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.loadRss(); this.loadWholesale(); await 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 d=await fetch('/api/retail/opportunities?min_score=35&limit=100').then(r=>r.json());
this.oppRows=d.items||[];
},
async loadCities(){
const d=await fetch('/api/retail/cities?limit=200').then(r=>r.json());
this.cityRows=d.items||[];
},
async loadWholesale(){
const q=this.wholesaleQ?'&q='+encodeURIComponent(this.wholesaleQ):'';
const d=await fetch('/api/retail/wholesalers?limit=500'+q).then(r=>r.json());
this.wholesaleRows=d.items||[]; this.wholesaleCount=d.count||0;
},
selectWholesale(w){ this.selectedWholesale=w; if(w.latitude) this.map.setView([+w.latitude,+w.longitude],13); },
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 %}