412 lines
24 KiB
HTML
412 lines
24 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 <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 %}
|