Fix map layout jump, CRM/edit distributeurs, progress overlay.
Stable map grid, no fitBounds on world view, entity PATCH edit form, CRM buttons on distributors, and loading animation for sync/wait states.
This commit is contained in:
@@ -86,6 +86,13 @@ async def api_entities(
|
|||||||
return await _proxy("GET", "/entities", params=params)
|
return await _proxy("GET", "/entities", params=params)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/export-intel/entities/{entity_id}")
|
||||||
|
async def api_update_entity(entity_id: int, request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
return await _proxy("PATCH", f"/entities/{entity_id}", json=body)
|
||||||
|
|
||||||
@router.get("/api/export-intel/entities/{entity_id}")
|
@router.get("/api/export-intel/entities/{entity_id}")
|
||||||
async def api_entity(entity_id: int):
|
async def api_entity(entity_id: int):
|
||||||
return await _proxy("GET", f"/entities/{entity_id}")
|
return await _proxy("GET", f"/entities/{entity_id}")
|
||||||
|
|||||||
@@ -40,6 +40,56 @@
|
|||||||
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
||||||
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
|
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
|
||||||
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
|
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
|
||||||
|
|
||||||
|
.ei-split { align-items: stretch; }
|
||||||
|
.ei-split > .ei-map-wrap { grid-column: 1; }
|
||||||
|
.ei-split > .ei-panel { grid-column: 2; }
|
||||||
|
|
||||||
|
.ei-progress-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1200;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(4, 8, 14, 0.55); backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.ei-progress-card {
|
||||||
|
min-width: 260px; max-width: 90vw; padding: 1.1rem 1.25rem;
|
||||||
|
border-radius: 14px; border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
background: rgba(10, 14, 20, 0.95); text-align: center;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.ei-progress-card p { margin: 0.65rem 0 0.85rem; color: #e2e8f0; font-size: 0.88rem; }
|
||||||
|
.ei-progress-spinner {
|
||||||
|
width: 28px; height: 28px; margin: 0 auto; border-radius: 50%;
|
||||||
|
border: 3px solid rgba(56, 189, 248, 0.2); border-top-color: #38bdf8;
|
||||||
|
animation: ei-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
.ei-progress-bar {
|
||||||
|
height: 4px; border-radius: 999px; background: rgba(148, 163, 184, 0.2); overflow: hidden;
|
||||||
|
}
|
||||||
|
.ei-progress-bar-fill {
|
||||||
|
height: 100%; width: 35%; border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #0ea5e9, #38bdf8, #0ea5e9);
|
||||||
|
background-size: 200% 100%; animation: ei-progress-slide 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ei-progress-slide {
|
||||||
|
0% { transform: translateX(-120%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ei-th-actions, .ei-td-actions { white-space: nowrap; width: 1%; }
|
||||||
|
.ei-td-actions { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
||||||
|
.ei-td-actions .btn { padding: 0.2rem 0.45rem; font-size: 0.68rem; }
|
||||||
|
|
||||||
|
.ei-drawer-edit { margin: 1rem 0; padding: 0.85rem; border-radius: 12px; border: 1px solid rgba(56,189,248,0.25); background: rgba(8,12,18,0.6); }
|
||||||
|
.ei-drawer-edit h4 { margin: 0 0 0.65rem; font-size: 0.85rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.ei-edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem; }
|
||||||
|
.ei-edit-grid label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.68rem; color: #64748b; text-transform: uppercase; }
|
||||||
|
.ei-edit-grid input, .ei-edit-grid select, .ei-edit-grid textarea {
|
||||||
|
padding: 0.45rem 0.55rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2);
|
||||||
|
background: rgba(15,23,42,0.8); color: #e2e8f0; font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.ei-edit-full { grid-column: 1 / -1; }
|
||||||
|
.ei-edit-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||||
|
|
||||||
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
|
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
|
||||||
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
|
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ window.ExportIntelMap = (function () {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFeatures(geojson) {
|
function setFeatures(geojson, opts) {
|
||||||
|
opts = opts || {};
|
||||||
if (!map || !clusterLayer) return;
|
if (!map || !clusterLayer) return;
|
||||||
clusterLayer.clearLayers();
|
clusterLayer.clearLayers();
|
||||||
markersById = {};
|
markersById = {};
|
||||||
@@ -247,10 +248,12 @@ window.ExportIntelMap = (function () {
|
|||||||
|
|
||||||
invalidateMapSize();
|
invalidateMapSize();
|
||||||
|
|
||||||
if (features.length === 1) {
|
if (opts.autoFit === false) {
|
||||||
|
map.setView([20, 10], 2);
|
||||||
|
} else if (features.length === 1) {
|
||||||
var c = features[0].geometry.coordinates;
|
var c = features[0].geometry.coordinates;
|
||||||
map.setView([c[1], c[0]], 10);
|
map.setView([c[1], c[0]], 10);
|
||||||
} else if (markers.length > 1 && clusterLayer.getBounds) {
|
} else if (opts.autoFit && markers.length > 1 && clusterLayer.getBounds) {
|
||||||
try {
|
try {
|
||||||
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
|
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
|
||||||
} catch (e) { /* empty */ }
|
} catch (e) { /* empty */ }
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function exportIntelApp() {
|
|||||||
crmModalOpen: false,
|
crmModalOpen: false,
|
||||||
crmCreateDeals: true,
|
crmCreateDeals: true,
|
||||||
crmPushBusy: false,
|
crmPushBusy: false,
|
||||||
|
editMode: false,
|
||||||
|
editForm: {},
|
||||||
resultCount: 0,
|
resultCount: 0,
|
||||||
_searchTimer: null,
|
_searchTimer: null,
|
||||||
|
|
||||||
@@ -193,7 +195,19 @@ function exportIntelApp() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showSelectionBar() {
|
showSelectionBar() {
|
||||||
return this.tab === 'map' || this.tab === 'contacts';
|
return ['map', 'contacts', 'distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
isWorking() {
|
||||||
|
return this.busy || this.crmPushBusy || this.mapLoading || this.drawerLoading;
|
||||||
|
},
|
||||||
|
|
||||||
|
progressLabel() {
|
||||||
|
if (this.busy && this.syncMsg) return this.syncMsg;
|
||||||
|
if (this.crmPushBusy) return 'CRM import bezig…';
|
||||||
|
if (this.mapLoading) return 'Kaart laden…';
|
||||||
|
if (this.drawerLoading) return 'Profiel laden…';
|
||||||
|
return 'Bezig…';
|
||||||
},
|
},
|
||||||
|
|
||||||
entityTypeOptions() {
|
entityTypeOptions() {
|
||||||
@@ -343,7 +357,8 @@ function exportIntelApp() {
|
|||||||
window.ExportIntelMap.init('ei-map');
|
window.ExportIntelMap.init('ei-map');
|
||||||
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
|
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
|
||||||
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
|
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
|
||||||
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] });
|
var autoFit = !!(this.country || this.region);
|
||||||
|
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit });
|
||||||
if (this.country) {
|
if (this.country) {
|
||||||
var t = this.territories.find((x) => x.country_iso2 === this.country);
|
var t = this.territories.find((x) => x.country_iso2 === this.country);
|
||||||
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
|
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
|
||||||
@@ -456,6 +471,59 @@ function exportIntelApp() {
|
|||||||
this.drawerOpen = false;
|
this.drawerOpen = false;
|
||||||
this.selected = null;
|
this.selected = null;
|
||||||
this.drawerLoading = false;
|
this.drawerLoading = false;
|
||||||
|
this.editMode = false;
|
||||||
|
this.editForm = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
startEdit() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
var s = this.selected;
|
||||||
|
this.editForm = {
|
||||||
|
name: s.name || '',
|
||||||
|
entity_type: s.entity_type || '',
|
||||||
|
country_iso2: s.country_iso2 || '',
|
||||||
|
city: s.city || '',
|
||||||
|
address_line: s.address_line || '',
|
||||||
|
postal_code: s.postal_code || '',
|
||||||
|
phone: s.phone || '',
|
||||||
|
email: s.email || '',
|
||||||
|
website: s.website || '',
|
||||||
|
volume_band: s.volume_band || '',
|
||||||
|
pipeline_stage: s.pipeline_stage || 'identified',
|
||||||
|
confidence: s.confidence != null ? s.confidence : 50,
|
||||||
|
halal_cert_notes: s.halal_cert_notes || '',
|
||||||
|
cold_chain: !!s.cold_chain,
|
||||||
|
};
|
||||||
|
this.editMode = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editMode = false;
|
||||||
|
this.editForm = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveEdit() {
|
||||||
|
if (!this.selected || !this.selected.id) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.syncMsg = 'Opslaan…';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/export-intel/entities/' + this.selected.id, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(this.editForm),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = await r.json().catch(function () { return {}; });
|
||||||
|
throw new Error(err.detail || err.error || 'Opslaan mislukt');
|
||||||
|
}
|
||||||
|
this.selected = await r.json();
|
||||||
|
this.editMode = false;
|
||||||
|
this.syncMsg = 'Opgeslagen';
|
||||||
|
await this.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
this.syncMsg = 'Opslaan mislukt: ' + e.message;
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
async syncKind(kind) {
|
async syncKind(kind) {
|
||||||
|
|||||||
@@ -4,14 +4,21 @@
|
|||||||
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v=1" />
|
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v=1" />
|
||||||
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.css?v=1" />
|
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.css?v=1" />
|
||||||
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.Default.css?v=1" />
|
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.Default.css?v=1" />
|
||||||
<link rel="stylesheet" href="/static/css/export-intel.css?v=14" />
|
<link rel="stylesheet" href="/static/css/export-intel.css?v=15" />
|
||||||
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
|
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
|
||||||
<script src="/static/vendor/leaflet/leaflet.js?v=1"></script>
|
<script src="/static/vendor/leaflet/leaflet.js?v=1"></script>
|
||||||
<script src="/static/vendor/leaflet.markercluster/leaflet.markercluster.js?v=1"></script>
|
<script src="/static/vendor/leaflet.markercluster/leaflet.markercluster.js?v=1"></script>
|
||||||
<script src="/static/js/export-intel-map.js?v=10"></script>
|
<script src="/static/js/export-intel-map.js?v=11"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="ei-page" x-data="exportIntelApp()" x-init="init()">
|
<div class="ei-page" x-data="exportIntelApp()" x-init="init()">
|
||||||
|
<div class="ei-progress-overlay" x-show="isWorking()" x-cloak>
|
||||||
|
<div class="ei-progress-card">
|
||||||
|
<div class="ei-progress-spinner"></div>
|
||||||
|
<p x-text="progressLabel()"></p>
|
||||||
|
<div class="ei-progress-bar"><div class="ei-progress-bar-fill"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<header class="ei-header page-header">
|
<header class="ei-header page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Wereldexport <span class="live-badge">Live</span></h1>
|
<h1>Wereldexport <span class="live-badge">Live</span></h1>
|
||||||
@@ -235,9 +242,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ei-map-error" x-show="mapError" x-cloak x-text="mapError"></div>
|
||||||
|
<p class="ei-map-truncated" x-show="mapTruncated && !mapLoading" x-cloak>Wereldoverzicht toont de belangrijkste ~2.500 locaties. Filter op regio of land voor meer detail.</p>
|
||||||
<div class="ei-split">
|
<div class="ei-split">
|
||||||
<div class="ei-map-error" x-show="mapError" x-cloak x-text="mapError"></div>
|
|
||||||
<p class="ei-map-truncated" x-show="mapTruncated && !mapLoading" x-cloak>Wereldoverzicht toont de belangrijkste ~2.500 locaties. Filter op regio of land voor meer detail.</p>
|
|
||||||
<div class="ei-map-wrap"><div class="ei-map-loading" x-show="mapLoading" x-cloak><span class="ei-map-spinner"></span> Kaart laden…</div><div id="ei-map"></div></div>
|
<div class="ei-map-wrap"><div class="ei-map-loading" x-show="mapLoading" x-cloak><span class="ei-map-spinner"></span> Kaart laden…</div><div id="ei-map"></div></div>
|
||||||
<div class="ei-panel">
|
<div class="ei-panel">
|
||||||
<h3 style="margin:0 0 0.5rem;font-size:0.9rem" x-text="mapHalalMode ? 'Top halal-kansen' : 'Entities op kaart'"></h3>
|
<h3 style="margin:0 0 0.5rem;font-size:0.9rem" x-text="mapHalalMode ? 'Top halal-kansen' : 'Entities op kaart'"></h3>
|
||||||
@@ -292,6 +299,8 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="ei-list-focus-actions">
|
<div class="ei-list-focus-actions">
|
||||||
<button type="button" class="btn btn-sm btn-pulse" @click="openEntity(listFocusDetail.id)">Volledig profiel →</button>
|
<button type="button" class="btn btn-sm btn-pulse" @click="openEntity(listFocusDetail.id)">Volledig profiel →</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!listFocusDetail.client_id" @click="pushEntityToCrm(listFocusDetail.id)" :disabled="crmPushBusy">➕ CRM</button>
|
||||||
|
<a class="btn btn-sm btn-pulse" x-show="listFocusDetail.client_id" href="/clients">Open CRM</a>
|
||||||
<a class="btn btn-sm" x-show="listFocusDetail.maps_url" :href="listFocusDetail.maps_url" target="_blank" rel="noopener">Google Maps</a>
|
<a class="btn btn-sm" x-show="listFocusDetail.maps_url" :href="listFocusDetail.maps_url" target="_blank" rel="noopener">Google Maps</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -321,6 +330,7 @@
|
|||||||
<th>Contact</th>
|
<th>Contact</th>
|
||||||
<th x-show="mapHalalMode">Score</th>
|
<th x-show="mapHalalMode">Score</th>
|
||||||
<th>Land</th>
|
<th>Land</th>
|
||||||
|
<th class="ei-th-actions">Acties</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -345,6 +355,11 @@
|
|||||||
<td class="ei-contact-cell" x-text="contactPreview(e)"></td>
|
<td class="ei-contact-cell" x-text="contactPreview(e)"></td>
|
||||||
<td x-show="mapHalalMode"><span class="ei-halal-pill" :class="'ei-halal-'+(e.halal_score>=75?'hot':e.halal_score>=55?'warm':'mild')" x-text="Math.round(e.halal_score||0)"></span></td>
|
<td x-show="mapHalalMode"><span class="ei-halal-pill" :class="'ei-halal-'+(e.halal_score>=75?'hot':e.halal_score>=55?'warm':'mild')" x-text="Math.round(e.halal_score||0)"></span></td>
|
||||||
<td x-text="e.country_iso2"></td>
|
<td x-text="e.country_iso2"></td>
|
||||||
|
<td class="ei-td-actions" @click.stop>
|
||||||
|
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!e.client_id" @click="pushEntityToCrm(e.id)" :disabled="crmPushBusy">CRM</button>
|
||||||
|
<a class="btn btn-sm" x-show="e.client_id" href="/clients">CRM ✓</a>
|
||||||
|
<button type="button" class="btn btn-sm" @click="openEntity(e.id)">Edit</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -372,16 +387,30 @@
|
|||||||
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
<div class="ei-panel" style="border:none;background:transparent;padding:0">
|
||||||
<div class="ei-table-wrap">
|
<div class="ei-table-wrap">
|
||||||
<table class="ei-table">
|
<table class="ei-table">
|
||||||
<thead><tr><th>Naam</th><th>Land</th><th>Stad</th><th>E-mail</th><th>Telefoon</th><th>Confidence</th></tr></thead>
|
<thead><tr>
|
||||||
|
<th class="ei-th-check"><input type="checkbox" @change="toggleSelectAllVisible()" title="Alles" /></th>
|
||||||
|
<th>Naam</th><th>Land</th><th>Stad</th><th>E-mail</th><th>Telefoon</th><th>Confidence</th><th class="ei-th-actions">Acties</th>
|
||||||
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template x-for="e in entities" :key="e.id">
|
<template x-for="e in entities" :key="e.id">
|
||||||
<tr @click="openEntity(e.id)" style="cursor:pointer">
|
<tr @click="openEntity(e.id)" :class="{ 'ei-row-selected': isSelected(e.id), 'ei-row-crm': e.client_id }" style="cursor:pointer">
|
||||||
<td x-text="e.name"></td>
|
<td class="ei-td-check" @click.stop>
|
||||||
|
<input type="checkbox" :checked="isSelected(e.id)" @change="toggleSelect(e.id, $event)" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span x-text="e.name"></span>
|
||||||
|
<span class="ei-crm-badge" x-show="e.client_id">CRM</span>
|
||||||
|
</td>
|
||||||
<td x-text="e.country_iso2"></td>
|
<td x-text="e.country_iso2"></td>
|
||||||
<td x-text="e.city||'—'"></td>
|
<td x-text="e.city||'—'"></td>
|
||||||
<td x-text="e.primary_email||e.email||'—'"></td>
|
<td x-text="e.primary_email||e.email||'—'"></td>
|
||||||
<td x-text="e.primary_phone||e.phone||'—'"></td>
|
<td x-text="e.primary_phone||e.phone||'—'"></td>
|
||||||
<td class="ei-confidence" x-text="e.confidence+'%'"></td>
|
<td class="ei-confidence" x-text="e.confidence+'%'"></td>
|
||||||
|
<td class="ei-td-actions" @click.stop>
|
||||||
|
<button type="button" class="btn btn-sm btn-pulse-green" x-show="!e.client_id" @click="pushEntityToCrm(e.id)" :disabled="crmPushBusy">CRM</button>
|
||||||
|
<a class="btn btn-sm" x-show="e.client_id" href="/clients">CRM ✓</a>
|
||||||
|
<button type="button" class="btn btn-sm" @click="openEntity(e.id)">Edit</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -671,6 +700,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template x-if="selected && !drawerLoading">
|
<template x-if="selected && !drawerLoading">
|
||||||
<div class="ei-drawer-body">
|
<div class="ei-drawer-body">
|
||||||
|
<div x-show="!editMode">
|
||||||
<div class="ei-drawer-hero">
|
<div class="ei-drawer-hero">
|
||||||
<span class="ei-drawer-badge" :class="'ei-type-'+selected.entity_type" x-text="typeLabel(selected.entity_type)"></span>
|
<span class="ei-drawer-badge" :class="'ei-type-'+selected.entity_type" x-text="typeLabel(selected.entity_type)"></span>
|
||||||
<span class="ei-drawer-flag" x-text="selected.country_iso2"></span>
|
<span class="ei-drawer-flag" x-text="selected.country_iso2"></span>
|
||||||
@@ -760,7 +790,54 @@
|
|||||||
<p x-show="!(selected.contacts||[]).length" class="ei-drawer-empty">Geen contactpersonen in registry.</p>
|
<p x-show="!(selected.contacts||[]).length" class="ei-drawer-empty">Geen contactpersonen in registry.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="ei-drawer-edit" x-show="editMode" x-cloak>
|
||||||
|
<h4>Bewerken</h4>
|
||||||
|
<div class="ei-edit-grid">
|
||||||
|
<label>Naam<input type="text" x-model="editForm.name" /></label>
|
||||||
|
<label>Type
|
||||||
|
<select x-model="editForm.entity_type">
|
||||||
|
<option value="distributor">Distributeur</option>
|
||||||
|
<option value="wholesaler">Groothandel</option>
|
||||||
|
<option value="importer">Importeur</option>
|
||||||
|
<option value="logistics">Logistiek</option>
|
||||||
|
<option value="contract_caterer">Cateraar</option>
|
||||||
|
<option value="restaurant">Restaurant</option>
|
||||||
|
<option value="doner_shoarma">Döner/shoarma</option>
|
||||||
|
<option value="butcher">Slager</option>
|
||||||
|
<option value="foodservice">Foodservice</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Land (ISO2)<input type="text" maxlength="2" x-model="editForm.country_iso2" /></label>
|
||||||
|
<label>Stad<input type="text" x-model="editForm.city" /></label>
|
||||||
|
<label>Adres<input type="text" x-model="editForm.address_line" /></label>
|
||||||
|
<label>E-mail<input type="email" x-model="editForm.email" /></label>
|
||||||
|
<label>Telefoon<input type="text" x-model="editForm.phone" /></label>
|
||||||
|
<label>Website<input type="url" x-model="editForm.website" /></label>
|
||||||
|
<label>Pipeline
|
||||||
|
<select x-model="editForm.pipeline_stage">
|
||||||
|
<option value="identified">Geïdentificeerd</option>
|
||||||
|
<option value="qualified">Gekwalificeerd</option>
|
||||||
|
<option value="contacted">Gecontacteerd</option>
|
||||||
|
<option value="proposal">Voorstel</option>
|
||||||
|
<option value="won">Gewonnen</option>
|
||||||
|
<option value="lost">Verloren</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Confidence %<input type="number" min="0" max="100" x-model.number="editForm.confidence" /></label>
|
||||||
|
<label>Volume<input type="text" x-model="editForm.volume_band" placeholder="pallet / container" /></label>
|
||||||
|
<label class="ei-edit-full">Halal notities<textarea rows="2" x-model="editForm.halal_cert_notes"></textarea></label>
|
||||||
|
<label class="ei-filter-check ei-edit-full"><input type="checkbox" x-model="editForm.cold_chain" /> Cold chain</label>
|
||||||
|
</div>
|
||||||
|
<div class="ei-edit-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-pulse-green" @click="saveEdit()" :disabled="busy">Opslaan</button>
|
||||||
|
<button type="button" class="btn btn-sm" @click="cancelEdit()">Annuleren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ei-drawer-actions">
|
<div class="ei-drawer-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-pulse" x-show="!editMode" @click="startEdit()">✎ Bewerken</button>
|
||||||
<button type="button" class="btn btn-sm" :class="selected.is_favorite ? 'btn-pulse' : ''" @click="toggleFavorite(selected.id, selected.is_favorite)">
|
<button type="button" class="btn btn-sm" :class="selected.is_favorite ? 'btn-pulse' : ''" @click="toggleFavorite(selected.id, selected.is_favorite)">
|
||||||
<span x-text="selected.is_favorite ? '★ Favoriet' : '☆ Favoriet maken'"></span>
|
<span x-text="selected.is_favorite ? '★ Favoriet' : '☆ Favoriet maken'"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -798,5 +875,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/export-intel.js?v=13"></script>
|
<script src="/static/js/export-intel.js?v=14"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.db import execute, execute_returning, fetch_all, fetch_one
|
from app.db import execute, execute_returning, fetch_all, fetch_one
|
||||||
from app.export_halal_scoring import COUNTRY_HALAL_MARKET, halal_pin_tier, score_entity
|
from app.export_halal_scoring import COUNTRY_HALAL_MARKET, halal_pin_tier, score_entity
|
||||||
@@ -139,6 +139,24 @@ class EntityIdsIn(BaseModel):
|
|||||||
entity_ids: list[int]
|
entity_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class EntityUpdateIn(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
entity_type: Optional[str] = None
|
||||||
|
country_iso2: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
address_line: Optional[str] = None
|
||||||
|
postal_code: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
volume_band: Optional[str] = None
|
||||||
|
pipeline_stage: Optional[str] = None
|
||||||
|
confidence: Optional[int] = Field(None, ge=0, le=100)
|
||||||
|
halal_cert_notes: Optional[str] = None
|
||||||
|
cold_chain: Optional[bool] = None
|
||||||
|
|
||||||
class FavoritesIn(BaseModel):
|
class FavoritesIn(BaseModel):
|
||||||
entity_ids: list[int]
|
entity_ids: list[int]
|
||||||
favorite: bool = True
|
favorite: bool = True
|
||||||
@@ -428,6 +446,30 @@ def export_contacts_csv(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/entities/{entity_id}")
|
||||||
|
def update_entity(entity_id: int, payload: EntityUpdateIn) -> dict[str, Any]:
|
||||||
|
row = fetch_one("SELECT id FROM export_market_entities WHERE id = %s", (entity_id,))
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="Geen velden om bij te werken")
|
||||||
|
if "country_iso2" in data and data["country_iso2"]:
|
||||||
|
data["country_iso2"] = data["country_iso2"].upper()[:2]
|
||||||
|
|
||||||
|
sets = [f"{k} = %s" for k in data]
|
||||||
|
sets.append("updated_at = NOW()")
|
||||||
|
params = list(data.values()) + [entity_id]
|
||||||
|
execute(
|
||||||
|
f"UPDATE export_market_entities SET {', '.join(sets)} WHERE id = %s",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
_MAP_CACHE.clear()
|
||||||
|
return get_entity(entity_id)
|
||||||
|
|
||||||
@router.post("/entities/{entity_id}/contacts")
|
@router.post("/entities/{entity_id}/contacts")
|
||||||
def add_contact(entity_id: int, payload: ContactCreateIn) -> dict[str, Any]:
|
def add_contact(entity_id: int, payload: ContactCreateIn) -> dict[str, Any]:
|
||||||
ent = fetch_one("SELECT id FROM export_market_entities WHERE id = %s", (entity_id,))
|
ent = fetch_one("SELECT id FROM export_market_entities WHERE id = %s", (entity_id,))
|
||||||
@@ -691,6 +733,8 @@ def map_bundle(
|
|||||||
"email": row.get("email") or row.get("contact_email"),
|
"email": row.get("email") or row.get("contact_email"),
|
||||||
"phone": row.get("phone") or row.get("contact_phone"),
|
"phone": row.get("phone") or row.get("contact_phone"),
|
||||||
"halal_score": h_score,
|
"halal_score": h_score,
|
||||||
|
"client_id": row.get("client_id"),
|
||||||
|
"is_favorite": bool(row.get("is_favorite")),
|
||||||
"has_contact": bool(
|
"has_contact": bool(
|
||||||
row.get("email") or row.get("contact_email")
|
row.get("email") or row.get("contact_email")
|
||||||
or row.get("phone") or row.get("contact_phone")
|
or row.get("phone") or row.get("contact_phone")
|
||||||
|
|||||||
Reference in New Issue
Block a user