SysOps: manual — 2026-07-30 09:33 UTC

This commit is contained in:
sysops
2026-07-30 09:33:03 +00:00
parent f21d58483b
commit 5dd69a1138
25 changed files with 4148 additions and 734 deletions
+10
View File
@@ -21,3 +21,13 @@ PROXMOX_HOST=10.4.7.14
PROXMOX_USER=root@pam PROXMOX_USER=root@pam
PROXMOX_TOKEN_NAME= PROXMOX_TOKEN_NAME=
PROXMOX_TOKEN_VALUE= PROXMOX_TOKEN_VALUE=
# OpenRouter (optioneel — ook via Instellingen → AI / LLM in cockpit)
OPENROUTER_API_KEY=
OPENROUTER_MODEL=deepseek/deepseek-v4-flash
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# orchestrator | router | auto (auto = orchestrator met fallback naar standaard LLM)
HERMAN_LLM_BACKEND=auto
# Google Places (Export Intel — telefoon/website verrijking)
GOOGLE_MAPS_API_KEY=
+6
View File
@@ -10,6 +10,12 @@ class Settings:
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b") OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b")
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700") TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090") HERMAN_ORCHESTRATOR_URL: str = os.getenv("HERMAN_ORCHESTRATOR_URL", "http://10.4.7.19:8090")
# OpenRouter (optioneel — key ook via Instellingen → AI / LLM)
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL: str = os.getenv("OPENROUTER_MODEL", "deepseek/deepseek-v4-flash")
OPENROUTER_BASE_URL: str = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
# orchestrator | router | auto (auto = orchestrator, fallback naar standaard LLM provider)
HERMAN_LLM_BACKEND: str = os.getenv("HERMAN_LLM_BACKEND", "auto")
WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions") WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions")
WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base")
WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl")
+34 -1
View File
@@ -276,6 +276,39 @@ async def api_tenders(country: Optional[str] = None, status: Optional[str] = "op
return await _proxy("GET", "/tenders", params=params) return await _proxy("GET", "/tenders", params=params)
@router.get("/api/export-intel/settings")
async def api_export_intel_settings_get():
return await _proxy("GET", "/settings")
@router.put("/api/export-intel/settings")
async def api_export_intel_settings_put(request: Request):
body = await request.json()
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.put(f"{TOOLS}/export-intel/settings", json=body)
return r.json()
@router.post("/api/export-intel/sync/places")
async def api_sync_places(request: Request):
body = {}
if request.headers.get("content-type", "").startswith("application/json"):
try:
body = await request.json()
except Exception:
body = {}
async with httpx.AsyncClient(timeout=7200.0) as client:
r = await client.post(f"{TOOLS}/export-intel/sync/places", json=body)
return r.json()
@router.get("/api/export-intel/sync/status")
async def api_sync_status():
return await _proxy("GET", "/sync/status")
@router.post("/api/export-intel/sync/{kind}") @router.post("/api/export-intel/sync/{kind}")
async def api_sync(kind: str, request: Request): async def api_sync(kind: str, request: Request):
body = {} body = {}
@@ -284,7 +317,7 @@ async def api_sync(kind: str, request: Request):
body = await request.json() body = await request.json()
except Exception: except Exception:
body = {} body = {}
if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region"): if kind not in ("contacts", "caterers", "distributors", "customers", "tenders", "all", "world", "region", "missing", "places"):
return {"error": "unknown sync kind"} return {"error": "unknown sync kind"}
timeout = 7200.0 if kind in ("world", "region", "all") else 600.0 timeout = 7200.0 if kind in ("world", "region", "all") else 600.0
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
+2 -1
View File
@@ -406,6 +406,7 @@ def llm_presets() -> dict[str, Any]:
@settings_router.get("/llm") @settings_router.get("/llm")
def llm_list() -> dict[str, Any]: def llm_list() -> dict[str, Any]:
llm_router.ensure_openrouter_placeholder()
items = llm_router.list_providers() items = llm_router.list_providers()
default = next((i for i in items if i.get("is_default")), items[0] if items else None) default = next((i for i in items if i.get("is_default")), items[0] if items else None)
return {"providers": items, "default": default, "presets": llm_router.list_presets()} return {"providers": items, "default": default, "presets": llm_router.list_presets()}
@@ -415,7 +416,7 @@ def llm_list() -> dict[str, Any]:
def llm_create(body: LlmProviderBody) -> dict[str, Any]: def llm_create(body: LlmProviderBody) -> dict[str, Any]:
preset = llm_router.LLM_PRESETS.get(body.provider_type, {}) preset = llm_router.LLM_PRESETS.get(body.provider_type, {})
base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None base_url = (body.api_base_url or preset.get("api_base_url") or "").strip() or None
model = body.model or (preset.get("models") or ["deepseek-chat"])[0] model = body.model or preset.get("default_model") or (preset.get("models") or ["deepseek-chat"])[0]
if body.is_default: if body.is_default:
execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()") execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()")
row = fetch_one( row = fetch_one(
+58
View File
@@ -119,6 +119,40 @@ def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dic
return steps return steps
async def _chat_via_llm_router(message: str, channel: str) -> dict[str, Any]:
from app.services import llm_router
system = (
"Je bent Herman, de AI-assistent van Foodlinkk B2B (halal vlees, export, CRM). "
"Antwoord in het Nederlands, bondig, actiegericht. Geef concrete vervolgstappen waar nuttig."
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": message},
]
reply, meta = await llm_router.chat_messages(messages, timeout=120.0)
provider = meta.get("provider_label") or "LLM"
model = meta.get("model") or ""
await _log_event(
"herman",
"llm_chat",
f"Herman via {provider}",
message[:2000],
{"provider": meta, "channel": channel},
channel=channel,
)
return {
"agent": "herman",
"agent_label": f"Herman · {provider}",
"reply": reply,
"delegated_agents": ["herman"],
"routing_reason": f"Cloud LLM · {provider}" + (f" · {model}" if model else ""),
"agent_steps": _agent_steps([], f"Via {provider}"),
"llm_provider": meta,
}
async def chat( async def chat(
message: str, message: str,
channel: str = "cockpit", channel: str = "cockpit",
@@ -344,6 +378,17 @@ async def chat(
"reply": f"Kon geen afbeelding genereren: {exc}", "reply": f"Kon geen afbeelding genereren: {exc}",
} }
backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower()
if backend in ("router", "openrouter", "llm"):
try:
return await _chat_via_llm_router(message, channel)
except Exception as exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": f"LLM provider niet beschikbaar: {exc}. Vul OpenRouter key in via Instellingen → AI / LLM.",
}
try: try:
async with httpx.AsyncClient(timeout=620.0) as client: async with httpx.AsyncClient(timeout=620.0) as client:
r = await client.post( r = await client.post(
@@ -373,6 +418,19 @@ async def chat(
"crm_loaded": data.get("crm_loaded", False), "crm_loaded": data.get("crm_loaded", False),
} }
except Exception as exc: except Exception as exc:
if backend == "auto":
try:
return await _chat_via_llm_router(message, channel)
except Exception as llm_exc:
return {
"agent": "herman",
"agent_label": "Herman",
"reply": (
f"Orchestrator offline ({exc}). "
f"Cloud LLM ook niet beschikbaar ({llm_exc}). "
"Voeg OpenRouter API key toe: Instellingen → AI / LLM."
),
}
return { return {
"agent": "herman", "agent": "herman",
"agent_label": "Herman", "agent_label": "Herman",
+69 -3
View File
@@ -48,13 +48,17 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
"label": "OpenRouter", "label": "OpenRouter",
"api_base_url": "https://openrouter.ai/api/v1", "api_base_url": "https://openrouter.ai/api/v1",
"models": [ "models": [
"deepseek/deepseek-v4-flash",
"deepseek/deepseek-v4-flash:free",
"deepseek/deepseek-chat",
"deepseek/deepseek-r1",
"google/gemini-2.0-flash-exp:free", "google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-r1:free",
"meta-llama/llama-3.3-70b-instruct:free", "meta-llama/llama-3.3-70b-instruct:free",
], ],
"default_model": "deepseek/deepseek-v4-flash",
"needs_key": True, "needs_key": True,
"signup_url": "https://openrouter.ai/", "signup_url": "https://openrouter.ai/keys",
"hint": "Veel gratis modellen via één API.", "hint": "Eén API key · DeepSeek V4 Flash via openrouter.ai. Vul key in Instellingen → AI.",
}, },
"mistral": { "mistral": {
"label": "Mistral AI", "label": "Mistral AI",
@@ -74,6 +78,52 @@ LLM_PRESETS: dict[str, dict[str, Any]] = {
} }
OPENROUTER_PLACEHOLDER_LABEL = "OpenRouter · DeepSeek V4 Flash"
def ensure_openrouter_placeholder() -> None:
"""Seed inactive OpenRouter row so user can fill API key later in Settings."""
row = fetch_one(
"SELECT id FROM llm_providers WHERE provider_type = 'openrouter' LIMIT 1"
)
if row:
return
execute(
"""INSERT INTO llm_providers (
label, provider_type, api_base_url, api_key, model,
is_active, is_default, extra_config, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW())""",
(
OPENROUTER_PLACEHOLDER_LABEL,
"openrouter",
"https://openrouter.ai/api/v1",
"",
"deepseek/deepseek-v4-flash",
False,
False,
json.dumps({"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"}),
),
)
def _env_openrouter_provider() -> dict[str, Any] | None:
from app.config import settings as app_settings
key = (app_settings.OPENROUTER_API_KEY or "").strip()
if not key:
return None
return {
"id": -1,
"label": "OpenRouter (environment)",
"provider_type": "openrouter",
"api_base_url": (app_settings.OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/"),
"api_key": key,
"model": app_settings.OPENROUTER_MODEL or "deepseek/deepseek-v4-flash",
"extra_config": {"referer": "https://foodlinkk.local", "title": "Foodlinkk Command Center"},
}
def list_presets() -> list[dict[str, Any]]: def list_presets() -> list[dict[str, Any]]:
out = [] out = []
for key, meta in LLM_PRESETS.items(): for key, meta in LLM_PRESETS.items():
@@ -127,6 +177,9 @@ def get_provider(provider_id: int | None = None) -> dict[str, Any] | None:
def resolve_provider(provider_id: int | None = None) -> dict[str, Any]: def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
row = get_provider(provider_id) row = get_provider(provider_id)
if not row: if not row:
env_or = _env_openrouter_provider()
if env_or:
return env_or
return { return {
"id": 0, "id": 0,
"label": "Ollama lokaal", "label": "Ollama lokaal",
@@ -136,6 +189,19 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]:
"model": settings.OLLAMA_MODEL, "model": settings.OLLAMA_MODEL,
"extra_config": {}, "extra_config": {},
} }
env_or = _env_openrouter_provider()
ptype = (row.get("provider_type") or "").lower()
if env_or and ptype == "openrouter" and not (row.get("api_key") or "").strip():
merged = dict(row)
merged["api_key"] = env_or["api_key"]
if not (merged.get("model") or "").strip():
merged["model"] = env_or["model"]
return merged
if env_or and ptype == "ollama" and row.get("is_default") and not (row.get("api_key") or "").strip():
# Optional: prefer env OpenRouter over default Ollama when key is set
from app.config import settings as app_settings
if (app_settings.HERMAN_LLM_BACKEND or "").lower() in ("router", "openrouter"):
return env_or
return row return row
+150 -38
View File
@@ -671,57 +671,134 @@ def build_live_digest(data: dict[str, Any]) -> dict[str, Any]:
} }
def build_template_report(data: dict[str, Any]) -> str: def build_template_report(data: dict[str, Any]) -> str:
lines = [ lines = [
f"# Foodlinkk Dagrapport — {data['date']}", f"# Foodlinkk Dagrapport — {data['date']}",
"", "",
f"*Gegenereerd: {data['generated_at'][:19]} UTC*", f"*Gegenereerd: {data['generated_at'][:19]} UTC · Model: {settings.OLLAMA_MODEL}*",
"", "",
"------------------------------", "## KPI's",
"KWALITEITSBEGELEIDING", f"- **Klanten:** {data['clients']} · **Deals:** {data['deals']} · **Pipeline:** €{data['pipeline_eur']:,.0f}",
"------------------------------", f"- **Supermarkten in DB:** {data.get('supermarkets', 0)} · **CRM partnerships:** {data.get('crm_partnerships', 0)}",
f"- **Klanten:** {data['clients']} | **Deals:** {data['deals']} | **Pipeline:** €{data['pipeline_eur']:,.0f}", f"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}",
f"- **Supermarkten:** {data.get('supermarkets', 0)} | **Partnerships:** {data.get('crm_partnerships', 0)}",
f"- **Groothandels:** {data.get('wholesalers', 0)}",
"", "",
"------------------------------",
"PIPELINE & KLANTEN",
"------------------------------",
] ]
for row in data.get("deals_by_stage") or []:
lines.append(f"- {row.get('stage')}: {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
if data.get("recent_clients"):
lines.extend(["", "Recente klanten:"])
for c in data["recent_clients"]:
lines.append(f" - {c.get('name')} -- {c.get('stage')}")
lines.extend([
"",
"------------------------------",
"RETAIL & MARKT",
"------------------------------",
])
if data.get("top_opportunities"): if data.get("top_opportunities"):
lines.extend(["Top halal kansen:"]) lines.extend(["## Top halal-markt kansen (Retail 360)"])
for row in data["top_opportunities"][:3]: for row in data["top_opportunities"]:
lines.append(f" - {row.get('chain')} {row.get('name')} ({row.get('city')})") score = round(float(row.get("halal_opportunity_score") or 0))
lines.append(f"- **{row.get('chain')} · {row.get('name')}** ({row.get('city')}) — score {score}/100")
lines.append("")
if data.get("milestones_pending"):
lines.extend(["## Sales milestones — open"])
for row in data["milestones_pending"]:
td = row.get("target_date")
td_s = td.isoformat()[:10] if hasattr(td, "isoformat") else str(td or "—")[:10]
lines.append(f"- [{td_s}] **{row.get('title')}** · {row.get('chain') or ''} {row.get('store_name') or ''} · €{row.get('value_eur') or '—'}")
lines.append("")
if data.get("rss_highlights"):
lines.extend(["## Kant-en-klaar & supermarkt nieuws"])
for row in data["rss_highlights"]:
lines.append(f"- [{row.get('feed_name')}] {row.get('title')}")
lines.append("")
if data.get("market_trends"): if data.get("market_trends"):
lines.extend(["", "Markt trends:"]) lines.extend(["## Markt trends"])
for row in data["market_trends"]: for row in data["market_trends"]:
pct = round(float(row.get("opportunity_score") or 0) * 100) pct = round(float(row.get("opportunity_score") or 0) * 100)
lines.append(f" - {row.get('trend_name')} ({pct}%)") lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}")
lines.extend([ lines.append("")
"",
"------------------------------", lines.extend(["## Pipeline per stage"])
"ACTIVITEITEN", for row in data.get("deals_by_stage") or []:
"------------------------------", lines.append(f"- **{row.get('stage')}:** {row.get('cnt')} deals · €{float(row.get('total') or 0):,.0f}")
]) if not data.get("deals_by_stage"):
lines.append("- Geen deals in database.")
if data.get("calendar_events"):
lines.extend(["", "## Agenda (7 dagen)"])
for row in data["calendar_events"]:
ts = row.get("starts_at")
ts_s = ts.isoformat()[:16] if hasattr(ts, "isoformat") else str(ts)[:16]
lines.append(f"- [{ts_s}] {row.get('title')} ({row.get('client_name') or '-'})")
if data.get("sysops_activity_24h"):
lines.extend(["", "## SysOps IT — laatste 24 uur"])
for row in data["sysops_activity_24h"][:12]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
cref = f" · commit `{row.get('commit_ref')}`" if row.get("commit_ref") else ""
lines.append(f"- [{ts_s}] **{row.get('title')}**{cref}")
if row.get("body"):
lines.append(f" {str(row.get('body'))[:200]}")
if data.get("pending_approval_requests"):
lines.extend(["", "## ⏳ Wacht op jouw goedkeuring (agents)"])
for row in data["pending_approval_requests"]:
action = row.get("action_type") or ""
label = "Update-scan VM106" if action == "maintenance_scan" else action
lines.append(f"- **{row.get('agent_key')}** · {label}: {row.get('title')}")
if data.get("activity_log"):
lines.extend(["", "## Herman activiteitenlog (24u)"])
for entry in data["activity_log"][:20]:
lines.append(f"- {entry}")
if data.get("recent_handoffs"): if data.get("recent_handoffs"):
for row in data["recent_handoffs"][:4]: lines.extend(["", "## Agent samenwerking (handoffs 24u)"])
lines.append(f" - {row.get('from_agent')} {row.get('to_agent')} ({row.get('handoff_type')})") for row in data["recent_handoffs"][:15]:
ts = row.get("created_at")
ts_s = ts.isoformat()[11:16] if hasattr(ts, "isoformat") else ""
lines.append(
f"- {row.get('from_agent')} → {row.get('to_agent')} ({row.get('handoff_type')}) {ts_s}"
)
if data.get("pending_items"):
lines.extend(["", "## Legacy goedkeuringen"])
for row in data["pending_items"]:
lines.append(f"- {row.get('agent_name')}: {row.get('title')}")
return "\n".join(lines) return "\n".join(lines)
async def _ai_executive_summary(data: dict[str, Any]) -> str:
opp_lines = ""
for row in data.get("top_opportunities") or []:
opp_lines += f"- {row.get('chain')} {row.get('name')} ({row.get('city')}): score {round(float(row.get('halal_opportunity_score') or 0))}\n"
ms_lines = ""
for row in data.get("milestones_pending") or []:
ms_lines += f"- {row.get('title')} ({row.get('chain') or 'CRM'}) deadline {row.get('target_date') or '?'}\n"
activity = "\n".join((data.get("activity_log") or [])[:15]) or "- Geen recente agent-acties"
prompt = (
"Schrijf in het Nederlands (markdown) voor CEO Aïssa van Foodlinkk (halal kant-en-klaar maaltijden):\n\n"
"## Samenvatting\n(5-7 zinnen: pipeline, retail, IT ops, agent activiteit vandaag)\n\n"
"## Actiepunten vandaag — korte termijn\n(minimaal 5 bullets — incl. open goedkeuringen SysOps scan/backup)\n\n"
"## Lange termijn focus\n(3-5 bullets)\n\n"
"## Herman documentatie — wat er gebeurde\n(korte chronologische samenvatting van agent-acties, project assets, backups)\n\n"
f"Data vandaag ({data['date']}):\n"
f"- Pipeline €{data['pipeline_eur']:,.0f}, {data['clients']} klanten, {data['deals']} deals\n"
f"- {data.get('supermarkets',0)} supermarkten, {data.get('crm_partnerships',0)} actieve CRM partnerships\n"
f"- {data['pending_approvals']} goedkeuringen open in approval queue\n"
f"Activiteitenlog:\n{activity}\n"
f"Top kansen:\n{opp_lines or '- geen data'}\n"
f"Milestones open:\n{ms_lines or '- geen milestones'}\n"
)
system = (
"Je bent Herman, AI co-CEO van Foodlinkk. Documenteer en vat samen wat agents en IT hebben gedaan. "
"Noem expliciet openstaande SysOps scan/backup verzoeken als die in de log staan. "
"Schrijf warm, professioneel, actionable."
)
try:
return await llm_router.generate(prompt, system=system, timeout=120.0)
except Exception:
return ""
def _fallback_summary(data: dict[str, Any]) -> str: def _fallback_summary(data: dict[str, Any]) -> str:
digest = build_live_digest(data) digest = build_live_digest(data)
@@ -757,4 +834,39 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None:
( (
f"Briefing {data['date']}", f"Briefing {data['date']}",
content[:8000], content[:8000],
json.dumps({"dats json.dumps({"date": data["date"], "activity_count": len(data.get("activity_log") or [])}),
),
)
except Exception:
pass
try:
execute(
"""INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)""",
(
"herman", "herman_delegate", "briefing",
f"CEO dagrapport {data['date']}", content[:2000],
"completed", "dashboard", json.dumps({"stats": safe, "activity_log": data.get("activity_log", [])}),
),
)
except Exception:
pass
async def generate_daily_briefing() -> tuple[str, dict[str, Any]]:
data = collect_briefing_data()
template = build_template_report(data)
try:
ai_part = await asyncio.wait_for(_ai_executive_summary(data), timeout=25.0)
except (asyncio.TimeoutError, Exception):
ai_part = ""
if ai_part and len(ai_part.strip()) > 80:
content = ai_part.strip() + "\n\n---\n\n" + template
else:
content = _fallback_summary(data) + "\n\n---\n\n" + template
_save_briefing(content, data)
stats = serialize_stats(data)
stats["live_digest"] = build_live_digest(data)
return content, stats
+31 -10
View File
@@ -1,24 +1,36 @@
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */ /* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
.app-shell { .app-shell {
display: grid; display: block;
grid-template-columns: 220px 1fr;
min-height: 100vh; min-height: 100vh;
} }
.app-sidebar { .app-sidebar {
position: sticky; position: fixed;
top: 0; top: 0;
align-self: start; left: 0;
height: auto; bottom: 0;
max-height: none; width: 220px;
overflow: visible; z-index: 200;
overflow-x: hidden; display: flex;
padding: 0.75rem 0.6rem; flex-direction: column;
overflow: hidden;
padding: 0.75rem 0.6rem 0;
background: #080c12; background: #080c12;
border-right: 1px solid rgba(148, 163, 184, 0.1); border-right: 1px solid rgba(148, 163, 184, 0.1);
} }
.app-main {
margin-left: 220px;
min-width: 0;
min-height: 100vh;
}
.app-sidebar-brand,
.persona-switch.brand-switch {
flex-shrink: 0;
}
.app-sidebar-brand { .app-sidebar-brand {
padding: 0.25rem 0.5rem 0.75rem; padding: 0.25rem 0.5rem 0.75rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@@ -59,7 +71,16 @@
.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); } .brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); } .brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
.persona-nav { display: contents; } .persona-nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.75rem;
scrollbar-gutter: stable;
}
.app-sidebar-group { .app-sidebar-group {
margin-bottom: 0.65rem; margin-bottom: 0.65rem;
+135 -8
View File
@@ -1,19 +1,146 @@
/* Wereldexport — KPIs + layout lock */ /* Wereldexport — full-width map workspace */
.wereldexport-workspace.vtabs-layout { .wereldexport-workspace.vtabs-layout {
display: flex !important; display: flex !important;
flex-direction: row !important; flex-direction: column !important;
align-items: flex-start !important; align-items: stretch !important;
gap: 1rem !important; gap: 0.65rem !important;
width: 100%;
min-height: calc(100dvh - 220px);
} }
.wereldexport-workspace > .vtabs-nav { .wereldexport-workspace > .vtabs-nav {
flex: 0 0 220px !important; flex: 0 0 auto !important;
width: 220px !important; width: 100% !important;
max-width: 220px !important; max-width: none !important;
position: sticky;
top: 0;
z-index: 60;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 0.35rem;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0.65rem;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
} }
.wereldexport-workspace > .vtabs-nav::before { display: none; }
.wereldexport-workspace .vtabs-nav-header { display: none; }
.wereldexport-workspace > .vtabs-content { .wereldexport-workspace > .vtabs-content {
flex: 1 1 0 !important; flex: 1 1 auto !important;
min-width: 0 !important; min-width: 0 !important;
min-height: 0 !important;
display: flex;
flex-direction: column;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
flex: 0 0 auto;
min-width: 128px;
max-width: 168px;
grid-template-columns: auto 1fr;
padding: 0.45rem 0.55rem;
}
.wereldexport-workspace .ei-vtab-meta,
.wereldexport-workspace .ei-vtab-key,
.wereldexport-workspace .ei-vtab-sub { display: none; }
.wereldexport-workspace .vtab-btn.active {
box-shadow: inset 0 -2px 0 var(--tab-accent, #0ea5e9);
}
.ei-tab-panel:has(.ei-workspace-body) {
background: rgba(8, 11, 16, 0.95);
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 280px);
border-radius: 12px;
overflow: hidden;
}
.ei-tab-panel:has(.ei-workspace-body-map) .ei-tab-panel-head {
display: none;
}
.ei-workspace-body,
.ei-workspace-body-map {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0 !important;
}
.ei-workspace-body-map .ei-map-legend {
flex-shrink: 0;
margin: 0;
padding: 0.45rem 0.75rem;
font-size: 0.68rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.ei-workspace-body-map .ei-map-empty {
flex-shrink: 0;
margin: 0;
padding: 0 0.75rem 0.45rem;
}
.ei-workspace-body-map .ei-map-empty-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
padding: 0.55rem 0.75rem;
}
.ei-workspace-body-map .ei-map-empty-inner strong {
display: inline;
margin: 0;
font-size: 0.78rem;
}
.ei-workspace-body-map .ei-map-empty-inner p {
flex: 1 1 200px;
margin: 0;
font-size: 0.72rem;
}
.ei-workspace-body-map .ei-map-empty-actions {
margin: 0;
flex-shrink: 0;
}
.ei-workspace-body-map .ei-map-truncated,
.ei-workspace-body-map .ei-map-error {
flex-shrink: 0;
margin: 0 0.75rem 0.35rem;
}
.ei-workspace-body-map > .ei-split.ei-split-side {
flex: 1 1 auto;
min-height: 420px;
}
@media (min-width: 900px) {
.ei-workspace-body-map > .ei-split.ei-split-side {
min-height: calc(100dvh - 320px);
}
}
@media (max-width: 899px) {
.wereldexport-workspace > .vtabs-nav {
flex-wrap: nowrap;
}
.wereldexport-workspace .vtab-btn.vtab-btn-rich {
min-width: 110px;
}
} }
.ei-neo-kpi-row { .ei-neo-kpi-row {
+676 -32
View File
@@ -13,33 +13,102 @@
.ei-filter-bar { .ei-filter-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
align-items: flex-end; gap: 0.65rem;
gap: 0.65rem 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95)); background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
border: 1px solid rgba(148,163,184,0.15); border: 1px solid rgba(148,163,184,0.15);
border-radius: 12px; border-radius: 12px;
} }
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; } .ei-filter-grid {
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; } display: grid;
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; } grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 0.55rem 0.65rem;
align-items: end;
}
.ei-filter-cell { min-width: 0; }
.ei-filter-cell-search { grid-column: span 3; }
.ei-filter-cell-check {
grid-column: span 2;
align-self: end;
padding-bottom: 0.45rem;
}
.ei-filter-cell-meta {
grid-column: span 3;
justify-self: end;
}
.ei-filter-label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #94a3b8;
font-weight: 600;
}
.ei-filter-label select, .ei-filter-label select,
.ei-filter-label input { .ei-filter-label input[type="search"],
.ei-filter-label input[type="text"] {
width: 100%;
box-sizing: border-box;
font-size: 0.82rem; font-size: 0.82rem;
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(148,163,184,0.22); border: 1px solid rgba(148,163,184,0.22);
background: #0a0e12; background: #0a0e12;
color: #e2e8f0; color: #e2e8f0;
min-width: 8rem;
} }
.ei-filter-search-wrap input { width: 100%; min-width: 0; } .ei-filter-meta {
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } display: flex;
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; } align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
padding-bottom: 0.35rem;
}
.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-filter-map-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem 0.85rem;
padding-top: 0.15rem;
border-top: 1px solid rgba(148,163,184,0.1);
}
.ei-filter-map-row-halal { border-top: none; padding-top: 0; }
.ei-filter-map-label { margin: 0; white-space: nowrap; }
@media (min-width: 1100px) {
.ei-filter-grid {
grid-template-areas: "regio land zoek type contact conf stad crm fav meta meta meta";
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.ei-fa-regio { grid-area: regio; }
.ei-fa-land { grid-area: land; }
.ei-fa-zoek { grid-area: zoek; }
.ei-fa-type { grid-area: type; }
.ei-fa-contact { grid-area: contact; }
.ei-fa-conf { grid-area: conf; }
.ei-fa-stad { grid-area: stad; }
.ei-fa-extra { grid-area: stad; }
.ei-fa-crm { grid-area: crm; }
.ei-fa-fav { grid-area: fav; }
.ei-fa-meta { grid-area: meta; }
.ei-filter-cell-search,
.ei-filter-cell-check,
.ei-filter-cell-meta { grid-column: auto; }
}
@media (max-width: 1099px) {
.ei-filter-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ei-filter-cell-search { grid-column: span 2; }
.ei-filter-cell-meta { grid-column: 1 / -1; justify-self: end; }
}
@media (max-width: 640px) {
.ei-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ei-filter-cell-search { grid-column: 1 / -1; }
}
.ei-split { align-items: stretch; } .ei-split { align-items: stretch; }
.ei-split > .ei-map-wrap { width: 100%; } .ei-split > .ei-map-wrap { width: 100%; }
@@ -93,21 +162,106 @@
.ei-split { .ei-split {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0;
min-height: 0; min-height: 0;
} }
@media (min-width: 1200px) { @media (min-width: 900px) {
.ei-split.ei-split-side { .ei-split.ei-split-side {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr); grid-template-columns: minmax(0, 1.2fr) minmax(340px, 400px);
grid-template-rows: minmax(480px, 62vh); grid-template-rows: minmax(0, 1fr);
align-items: stretch; align-items: stretch;
height: 100%;
min-height: 420px;
}
.ei-split.ei-split-side > .ei-map-wrap {
grid-column: 1;
grid-row: 1;
height: 100%;
min-height: 0;
}
.ei-split.ei-split-side > .ei-side-panel {
grid-column: 2;
grid-row: 1;
height: 100%;
min-height: 0;
} }
.ei-split.ei-split-side > .ei-map-wrap { grid-column: 1; grid-row: 1; height: 100%; min-height: 480px; }
.ei-split.ei-split-side > .ei-panel { grid-column: 2; grid-row: 1; max-height: 62vh; overflow: auto; }
} }
.ei-map-wrap { position: relative; } .ei-map-wrap { position: relative; }
.ei-workspace-body {
padding: 0 !important;
}
.ei-workspace-body-map {
padding: 0 1rem 1rem !important;
}
.ei-workspace-body-map .ei-map-legend,
.ei-workspace-body-map .ei-map-empty,
.ei-workspace-body-map .ei-map-error,
.ei-workspace-body-map .ei-map-truncated {
margin-left: 0;
margin-right: 0;
}
.ei-workspace-body > .ei-split {
border-radius: 0 0 12px 12px;
overflow: hidden;
}
.ei-side-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: linear-gradient(180deg, rgba(12, 16, 22, 0.98) 0%, rgba(8, 11, 16, 0.98) 100%);
border-left: 1px solid rgba(148, 163, 184, 0.1);
}
.ei-side-panel-head {
flex-shrink: 0;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(15, 23, 42, 0.35);
}
.ei-side-panel-head h3 {
margin: 0;
font-size: 0.92rem;
font-weight: 600;
color: #f1f5f9;
}
.ei-side-panel-head p {
margin: 0.25rem 0 0;
font-size: 0.78rem;
color: #64748b;
line-height: 1.35;
}
.ei-side-panel-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0;
}
.ei-side-section {
padding: 0.65rem 0.75rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
}
.ei-side-section h4 {
margin: 0 0 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.ei-side-hint {
margin: 0;
padding: 0.65rem 0.85rem;
font-size: 0.78rem;
color: #64748b;
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(14, 165, 233, 0.04);
}
.ei-action-tile[disabled], .ei-action-tile.ei-action-busy { opacity: 0.55; pointer-events: none; }
.ei-map-loading { .ei-map-loading {
position: absolute; inset: 0; z-index: 500; position: absolute; inset: 0; z-index: 500;
display: flex; align-items: center; justify-content: center; gap: 0.65rem; display: flex; align-items: center; justify-content: center; gap: 0.65rem;
@@ -125,17 +279,37 @@
.ei-map-error { margin: 0 0 0.65rem; padding: 0.65rem 0.85rem; border-radius: 10px; border: 1px solid rgba(251,113,133,0.35); background: rgba(127,29,29,0.25); color: #fecdd3; font-size: 0.82rem; } .ei-map-error { margin: 0 0 0.65rem; padding: 0.65rem 0.85rem; border-radius: 10px; border: 1px solid rgba(251,113,133,0.35); background: rgba(127,29,29,0.25); color: #fecdd3; font-size: 0.82rem; }
.ei-map-wrap { .ei-map-wrap {
border-radius: 14px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(14,165,233,0.2); background: #070b10;
}
.ei-workspace-body .ei-map-wrap {
border: none;
border-radius: 0;
}
.ei-workspace-body-map .ei-split-side > .ei-map-wrap {
border: 1px solid rgba(14, 165, 233, 0.18);
border-radius: 12px;
}
.ei-split:not(.ei-split-side) > .ei-map-wrap {
height: min(62vh, 720px); height: min(62vh, 720px);
min-height: 480px; min-height: 480px;
background: #0a0e14; border-radius: 12px;
border: 1px solid rgba(14, 165, 233, 0.18);
} }
.ei-map-wrap #ei-map { .ei-map-wrap #ei-map,
.ei-map-wrap [id^="ei-map-"] {
height: 100%; height: 100%;
min-height: 480px;
width: 100%; width: 100%;
min-height: 280px;
}
@media (max-width: 899px) {
.ei-split > .ei-map-wrap {
height: min(48vh, 440px);
min-height: 280px;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
.ei-side-panel { border-left: none; border-top: 1px solid rgba(148, 163, 184, 0.1); }
.ei-side-panel-body { max-height: 50vh; }
} }
/* Leaflet popups — rich card */ /* Leaflet popups — rich card */
@@ -204,7 +378,6 @@
box-shadow: 0 0 0 1px rgba(14,165,233,0.2); box-shadow: 0 0 0 1px rgba(14,165,233,0.2);
} }
.ei-filter-group-maptypes { flex: 1 1 100%; max-width: 100%; }
.ei-type-chips { .ei-type-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -228,12 +401,6 @@
background: rgba(34,197,94,0.12); background: rgba(34,197,94,0.12);
} }
.ei-filter-group-halal {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.ei-filter-check { .ei-filter-check {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -354,10 +521,10 @@
.ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; } .ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; }
.ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; } .ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; }
.ei-modal-root { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; padding: 1rem; } .ei-modal-root { position: fixed; inset: 0; z-index: 10050; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.ei-modal-card { .ei-modal-card {
position: relative; position: relative;
z-index: 1302; z-index: 10051;
width: min(420px, 96vw); width: min(420px, 96vw);
padding: 1.25rem; padding: 1.25rem;
border-radius: 14px; border-radius: 14px;
@@ -439,7 +606,7 @@
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; } .ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; }
.ei-drawer-root { position: relative; z-index: 1201; } .ei-drawer-root { position: relative; z-index: 1201; }
.ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); } .ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); }
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; } .ei-drawer-rich { width: min(640px, 94vw); padding: 0; display: flex; flex-direction: column; }
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); } .ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; } .ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; } .ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
@@ -464,3 +631,480 @@
.ei-link-btn:hover { text-decoration: underline; } .ei-link-btn:hover { text-decoration: underline; }
.ei-td-actions { white-space: nowrap; } .ei-td-actions { white-space: nowrap; }
.ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; } .ei-load-more { display: flex; justify-content: center; padding: 1rem 0 0.5rem; }
/* Entity rows — click to open full profile */
.ei-th-open, .ei-td-open { width: 88px; text-align: right; }
.ei-open-chevron {
color: #38bdf8;
font-size: 0.8125rem;
font-weight: 600;
opacity: 0.75;
transition: opacity 0.15s, transform 0.15s;
}
.ei-table tbody tr:hover .ei-open-chevron { opacity: 1; transform: translateX(2px); }
.ei-table tbody tr { transition: background 0.12s; }
.ei-table tbody tr:hover { background: rgba(14, 165, 233, 0.06) !important; }
.ei-row-active { box-shadow: inset 3px 0 0 #0ea5e9; background: rgba(14, 165, 233, 0.1) !important; }
/* Full profile drawer */
.ei-drawer-rich {
width: min(640px, 94vw) !important;
display: flex;
flex-direction: column;
padding: 0;
}
.ei-drawer-head-rich {
padding: 1.1rem 1.25rem 0.85rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: linear-gradient(180deg, rgba(14, 165, 233, 0.08) 0%, transparent 100%);
flex-shrink: 0;
}
.ei-drawer-head-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.ei-drawer-close {
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.8);
color: #94a3b8;
border-radius: 8px;
width: 36px;
height: 36px;
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
}
.ei-drawer-close:hover { color: #fff; border-color: rgba(56, 189, 248, 0.4); }
.ei-drawer-loading {
display: flex; align-items: center; justify-content: center; gap: 0.65rem;
padding: 3rem 1rem; color: #94a3b8;
}
.ei-drawer-toolbar {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.55rem;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
background: rgba(8, 12, 18, 0.5);
flex-shrink: 0;
}
.ei-action-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
padding: 0.65rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.7);
color: #e2e8f0;
text-decoration: none;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.12s;
min-height: 72px;
}
.ei-action-tile:hover { border-color: rgba(56, 189, 248, 0.35); background: rgba(14, 165, 233, 0.08); transform: translateY(-1px); }
.ei-action-tile:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.ei-action-icon { font-size: 1.1rem; line-height: 1; }
.ei-action-label { font-size: 0.875rem; font-weight: 600; color: #f1f5f9; }
.ei-action-sub { font-size: 0.75rem; color: #64748b; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; max-width: 100%; white-space: nowrap; }
.ei-action-crm { border-color: rgba(74, 222, 128, 0.35); background: rgba(74, 222, 128, 0.08); }
.ei-action-crm:hover { border-color: rgba(74, 222, 128, 0.55); background: rgba(74, 222, 128, 0.12); }
.ei-action-crm-done { border-color: rgba(74, 222, 128, 0.4); background: rgba(74, 222, 128, 0.1); }
.ei-action-edit { border-color: rgba(56, 189, 248, 0.3); }
.ei-action-fav-on { border-color: rgba(251, 191, 36, 0.4); background: rgba(251, 191, 36, 0.08); }
.ei-drawer-body-rich { padding: 1rem 1.25rem 1.5rem; }
.ei-drawer-section {
background: rgba(10, 14, 20, 0.6);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 0.85rem 1rem;
margin-bottom: 0.75rem;
}
.ei-drawer-section h4 { font-size: 0.8125rem !important; color: #94a3b8 !important; margin-bottom: 0.65rem !important; }
.ei-drawer-edit {
margin: 0 1.25rem 1rem;
border-radius: 12px;
}
.ei-drawer-title { font-size: 1.35rem !important; margin-top: 0.5rem; }
.ei-drawer-meta { font-size: 0.875rem !important; margin-bottom: 0 !important; }
.ei-drawer-kv { font-size: 0.9375rem !important; }
/* Profile modal — visibility via .is-open (not Alpine x-show) */
#ei-profile-modal { display: none !important; }
#ei-profile-modal.is-open {
display: flex !important;
z-index: 99999 !important;
}
/* Profiel popup modal */
body.ei-profile-open { overflow: hidden; }
.ei-profile-modal-root {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.ei-profile-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.68);
backdrop-filter: blur(4px);
animation: ei-fade-in 0.18s ease-out;
}
.ei-profile-panel {
position: relative;
z-index: 1;
width: min(720px, 96vw);
max-height: min(90vh, 920px);
display: flex;
flex-direction: column;
border-radius: 16px;
border: 1px solid rgba(14, 165, 233, 0.28);
background: linear-gradient(180deg, #121820 0%, #0c1016 100%);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(56, 189, 248, 0.08);
overflow: hidden;
animation: ei-modal-pop 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ei-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes ei-modal-pop {
from { opacity: 0; transform: translateY(12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.ei-profile-inner { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow: hidden; }
.ei-drawer-head-rich { flex-shrink: 0; }
.ei-drawer-toolbar { flex-shrink: 0; max-height: 38vh; overflow-y: auto; }
.ei-drawer-body-rich { flex: 1; overflow-y: auto; }
@media (max-width: 640px) {
.ei-profile-panel { width: 100%; max-height: 96vh; border-radius: 14px 14px 0 0; }
.ei-profile-modal-root { padding: 0; align-items: flex-end; }
}
.ei-row-open-btn {
background: none; border: none; padding: 0; margin: 0;
color: #f1f5f9; font: inherit; font-weight: 600; text-align: left;
cursor: pointer; max-width: 100%;
}
.ei-row-open-btn:hover { color: #38bdf8; text-decoration: underline; }
#ei-profile-mount { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow: hidden; max-height: min(90vh, 920px); }
#ei-profile-mount .ei-drawer-body-rich { flex: 1; overflow-y: auto; }
.ei-filter-bar {
position: sticky;
top: 0;
z-index: 200;
backdrop-filter: blur(8px);
background: rgba(8, 12, 18, 0.92);
}
.ei-tab-filters {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(10, 14, 20, 0.6);
}
.ei-tab-filters-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 0.85rem;
align-items: flex-end;
}
.ei-tab-filter-search { flex: 1 1 180px; min-width: 160px; }
.ei-tab-filter-search input { width: 100%; }
.ei-tab-filter-count {
margin-left: auto;
font-size: 0.8125rem;
color: #64748b;
white-space: nowrap;
padding-bottom: 0.35rem;
}
@media (max-width: 768px) {
.ei-tab-filters-row { flex-direction: column; align-items: stretch; }
.ei-tab-filter-count { margin-left: 0; }
}
/* ── Side panel entity list — readable, scrolls inside panel ── */
.ei-side-panel-body {
display: flex;
flex-direction: column;
overflow: hidden;
}
.ei-side-panel .ei-table-wrap {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ei-table-compact {
table-layout: fixed;
width: 100%;
min-width: 0;
border-collapse: collapse;
}
.ei-table-compact th,
.ei-table-compact td {
vertical-align: middle;
padding: 0.38rem 0.4rem;
line-height: 1.35;
}
.ei-table-compact thead th {
position: sticky;
top: 0;
z-index: 3;
background: #0f1419;
font-size: 0.65rem;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.12);
color: #64748b;
}
.ei-table-compact tbody tr {
height: auto;
min-height: 2.35rem;
}
.ei-table-compact .ei-th-check,
.ei-table-compact .ei-td-check {
width: 28px;
padding: 0.2rem 0.15rem;
text-align: center;
}
.ei-table-compact .ei-th-fav,
.ei-table-compact .ei-td-fav {
width: 30px;
padding: 0.2rem 0.15rem;
text-align: center;
}
.ei-table-compact .ei-td-check input[type="checkbox"] { margin: 0; }
.ei-table-compact .ei-th-name,
.ei-table-compact .ei-td-name {
width: 46%;
min-width: 0;
overflow: hidden;
}
.ei-name-cell {
display: flex;
align-items: flex-start;
gap: 0.35rem;
min-width: 0;
}
.ei-table-compact .ei-th-type,
.ei-table-compact .ei-td-type {
width: 22%;
min-width: 72px;
text-align: left;
}
.ei-table-compact .ei-th-city,
.ei-table-compact .ei-td-city {
width: 32%;
min-width: 88px;
font-size: 0.72rem;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide extra columns in side panel — details in profiel popup */
.ei-table-compact .ei-td-contact,
.ei-table-compact .ei-th-contact,
.ei-table-compact .ei-td-country,
.ei-table-compact .ei-th-country,
.ei-table-compact .ei-td-conf,
.ei-table-compact .ei-th-conf,
.ei-table-compact .ei-td-open,
.ei-table-compact .ei-th-open,
.ei-table-compact .ei-col-hide-side {
display: none !important;
}
.ei-table-compact .ei-td-score,
.ei-table-compact .ei-th-score {
width: 44px;
text-align: center;
font-size: 0.72rem;
}
.ei-table-compact .ei-type-pill,
.ei-table-compact .ei-type-pill-sm {
display: inline-block;
white-space: nowrap;
font-size: 0.62rem;
padding: 0.12rem 0.38rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.ei-table-compact .ei-row-open-btn {
flex: 1 1 auto;
min-width: 0;
text-align: left;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
line-height: 1.25;
font-size: 0.78rem;
font-weight: 600;
color: #f1f5f9;
}
.ei-table-compact .ei-crm-badge {
flex-shrink: 0;
margin: 0.1rem 0 0;
font-size: 0.55rem;
}
.ei-table-compact .ei-fav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
font-size: 0.9rem;
}
.ei-table-compact tbody tr { cursor: pointer; }
.ei-table-compact tbody tr:hover .ei-row-open-btn { color: #38bdf8; }
.ei-table-compact .ei-td-pipe-meta,
.ei-table-compact .ei-th-pipe-meta { display: none; }
.ei-table-compact:not(.ei-table-contacts) .ei-td-actions,
.ei-table-compact:not(.ei-table-contacts) th:empty:last-child { display: none; }
.ei-table-compact .ei-td-actions {
width: 68px;
padding: 0.2rem;
vertical-align: middle;
}
.ei-table-compact .ei-td-actions .btn { font-size: 0.6rem; padding: 0.18rem 0.32rem; }
.ei-table-brand .ei-td-name { width: 42%; }
.ei-table-brand .ei-td-country { width: 48px; }
@media (min-width: 1400px) {
.ei-split.ei-split-side {
grid-template-columns: minmax(0, 1.15fr) minmax(440px, 560px);
}
.ei-table-compact.ei-table-pipeline .ei-td-pipe-meta,
.ei-table-compact.ei-table-pipeline .ei-th-pipe-meta {
display: table-cell;
font-size: 0.68rem;
}
}
@media (max-width: 899px) {
.ei-split.ei-split-side {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
}
.ei-side-panel-body { max-height: 50vh; }
}
.ei-popup-web-list { display: flex; flex-direction: column; gap: 0.25rem; align-items: flex-start; }
.ei-popup-web {
display: inline-block;
max-width: 100%;
padding: 0.12rem 0.4rem;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 600;
color: #7dd3fc !important;
background: rgba(14, 165, 233, 0.1);
border: 1px solid rgba(56, 189, 248, 0.22);
text-decoration: none !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ei-popup-web:hover { background: rgba(14, 165, 233, 0.18); color: #bae6fd !important; }
.ei-popup-websites .ei-popup-v { max-width: 200px; }
.ei-popup-card { max-height: 420px; overflow-y: auto; }
.ei-popup-meta { font-size: 0.72rem; color: #64748b; margin: 0.15rem 0 0.45rem; }
.ei-popup-reach { display: flex; flex-direction: column; gap: 0.45rem; margin: 0.35rem 0 0.5rem; }
.ei-popup-section { border-top: 1px solid rgba(148, 163, 184, 0.1); padding-top: 0.35rem; }
.ei-popup-section-title { font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; margin-bottom: 0.25rem; font-weight: 700; }
.ei-popup-channel-list, .ei-popup-web-list { display: flex; flex-direction: column; gap: 0.2rem; }
.ei-popup-link {
display: flex; flex-direction: column; gap: 0.05rem;
padding: 0.22rem 0.35rem; border-radius: 6px;
background: rgba(15, 23, 42, 0.55); border: 1px solid rgba(148, 163, 184, 0.12);
text-decoration: none !important; color: #e2e8f0 !important;
}
.ei-popup-link:hover { border-color: rgba(56, 189, 248, 0.35); background: rgba(14, 165, 233, 0.08); }
.ei-popup-link-label { font-size: 0.58rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.ei-popup-link-val { font-size: 0.72rem; color: #7dd3fc; word-break: break-all; line-height: 1.25; }
.ei-popup-plain { padding: 0.15rem 0.35rem; font-size: 0.72rem; color: #cbd5e1; }
.ei-popup-person { padding: 0.35rem 0.4rem; margin-top: 0.2rem; border-radius: 8px; background: rgba(8, 12, 18, 0.55); border: 1px solid rgba(148, 163, 184, 0.1); }
.ei-popup-person-head { font-size: 0.75rem; font-weight: 600; color: #f1f5f9; }
.ei-popup-person-sub { font-size: 0.65rem; color: #64748b; margin: 0.1rem 0 0.25rem; }
.ei-popup-person-links { display: flex; flex-direction: column; gap: 0.15rem; }
.ei-popup-primary { font-size: 0.55rem; color: #4ade80; border: 1px solid rgba(74, 222, 128, 0.35); padding: 0 0.25rem; border-radius: 4px; margin-left: 0.2rem; }
.ei-popup-maps { display: inline-block; font-size: 0.72rem; color: #7dd3fc !important; text-decoration: none !important; }
.ei-popup-empty-contact { font-size: 0.72rem; color: #64748b; padding: 0.35rem 0; font-style: italic; }
.ei-reach-block h5 { margin: 0.5rem 0 0.25rem; font-size: 0.68rem; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; }
.leaflet-popup-content { margin: 10px 12px !important; }
.leaflet-popup-content-wrapper { border-radius: 12px !important; }
.ei-chip-count { font-size: 0.62rem; opacity: 0.75; margin-left: 0.15rem; }
.ei-type-chip.active .ei-chip-count { opacity: 1; }
.ei-map-filter-hint { color: #fbbf24 !important; }
.ei-sync-monitor {
margin: 0.65rem 0 0.35rem;
border: 1px solid rgba(56, 189, 248, 0.25);
border-radius: 10px;
background: rgba(15, 23, 42, 0.65);
}
.ei-sync-monitor-active { border-color: rgba(34, 197, 94, 0.45); box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.15); }
.ei-sync-monitor-toggle {
width: 100%; display: flex; align-items: center; gap: 0.6rem;
padding: 0.55rem 0.75rem; background: transparent; border: 0; color: #e2e8f0;
cursor: pointer; font-size: 0.82rem; text-align: left;
}
.ei-sync-monitor-badge {
margin-left: auto; font-size: 0.72rem; color: #7dd3fc;
background: rgba(14, 165, 233, 0.15); padding: 0.15rem 0.45rem; border-radius: 999px;
}
.ei-sync-monitor-panel { padding: 0 0.75rem 0.75rem; }
.ei-sync-monitor-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
.ei-sync-live { color: #4ade80; font-size: 0.72rem; animation: ei-pulse 1.5s ease-in-out infinite; }
@keyframes ei-pulse { 0%,100%{opacity:1} 50%{opacity:.45} }
.ei-sync-job { margin-bottom: 0.65rem; padding: 0.55rem 0.65rem; border-radius: 8px; background: rgba(2, 6, 23, 0.55); }
.ei-sync-job-title { font-size: 0.78rem; font-weight: 600; color: #f8fafc; }
.ei-sync-job-msg { font-size: 0.74rem; color: #94a3b8; margin-top: 0.2rem; }
.ei-sync-job-bar { height: 6px; border-radius: 999px; background: rgba(51, 65, 85, 0.8); margin-top: 0.45rem; overflow: hidden; }
.ei-sync-job-bar-fill { height: 100%; background: linear-gradient(90deg, #0ea5e9, #22c55e); transition: width 0.4s ease; }
.ei-sync-job-meta { font-size: 0.7rem; color: #64748b; margin-top: 0.25rem; }
.ei-sync-coverage { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.45rem; margin: 0.5rem 0; }
.ei-sync-coverage div { background: rgba(2, 6, 23, 0.45); border-radius: 8px; padding: 0.4rem 0.5rem; display: flex; flex-direction: column; gap: 0.15rem; }
.ei-sync-coverage span { font-size: 0.68rem; color: #64748b; }
.ei-sync-coverage strong { font-size: 0.85rem; color: #e2e8f0; }
.ei-sync-events { max-height: 180px; overflow: auto; border-top: 1px solid rgba(51, 65, 85, 0.6); padding-top: 0.45rem; }
.ei-sync-events-title { font-size: 0.72rem; color: #64748b; margin-bottom: 0.35rem; }
.ei-sync-event { display: flex; gap: 0.45rem; font-size: 0.72rem; padding: 0.2rem 0; color: #cbd5e1; }
.ei-sync-event-time { color: #64748b; min-width: 4.2rem; font-variant-numeric: tabular-nums; }
.ei-google-places { margin: 0.55rem 0; padding: 0.55rem 0.65rem; border-radius: 8px; background: rgba(2,6,23,.45); border: 1px solid rgba(100,116,139,.35); }
.ei-google-places-head { display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; margin-bottom: 0.35rem; }
.ei-google-status { font-size: 0.68rem; color: #4ade80; margin-left: auto; }
.ei-google-status.ei-google-missing { color: #fbbf24; }
.ei-google-hint { font-size: 0.7rem; color: #64748b; margin: 0 0 0.45rem; }
.ei-google-key-row { display: flex; gap: 0.4rem; margin-bottom: 0.45rem; }
.ei-google-key-row .form-input { flex: 1; min-width: 0; font-size: 0.75rem; padding: 0.35rem 0.5rem; }
+9
View File
@@ -97,6 +97,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
grid-template-columns: 1fr !important; grid-template-columns: 1fr !important;
display: block !important; display: block !important;
} }
.app-main {
margin-left: 0 !important;
}
.app-sidebar { .app-sidebar {
position: fixed !important; position: fixed !important;
top: 0; left: 0; bottom: 0; top: 0; left: 0; bottom: 0;
@@ -113,6 +116,12 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
overflow-x: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.persona-nav {
flex: none;
min-height: auto;
overflow: visible;
padding-bottom: 0;
}
body.nav-open .app-sidebar { transform: translateX(0); } body.nav-open .app-sidebar { transform: translateX(0); }
body.nav-open { overflow: hidden; } body.nav-open { overflow: hidden; }
.app-topbar { .app-topbar {
+118
View File
@@ -892,3 +892,121 @@ body:has(.topbar) .main { margin-left: 0; }
border-radius: 8px; color: #94a3b8; font-size: 0.85rem; border-radius: 8px; color: #94a3b8; font-size: 0.85rem;
} }
.novnc-fallback a { color: var(--accent-cyan); } .novnc-fallback a { color: var(--accent-cyan); }
/* ── AI / LLM settings ── */
.llm-hero-card {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding: 1.1rem 1.25rem;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.28);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(8, 12, 18, 0.95));
}
.llm-hero-body { flex: 1 1 280px; min-width: 0; }
.llm-hero-badge {
display: inline-block;
margin-bottom: 0.45rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7dd3fc;
background: rgba(14, 165, 233, 0.15);
border: 1px solid rgba(56, 189, 248, 0.25);
}
.llm-hero-card h3 {
margin: 0 0 0.4rem;
font-size: 1.05rem;
font-weight: 600;
color: #f1f5f9;
}
.llm-hero-lead {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: #94a3b8;
max-width: 42rem;
}
.llm-hero-lead code {
font-size: 0.8rem;
padding: 0.1rem 0.35rem;
border-radius: 4px;
background: rgba(15, 23, 42, 0.8);
color: #bae6fd;
}
.llm-hero-steps {
margin: 0.65rem 0 0;
padding-left: 1.15rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #94a3b8;
}
.llm-hero-steps a { color: #38bdf8; }
.llm-section-title {
margin: 0 0 0.25rem;
font-size: 0.92rem;
font-weight: 600;
color: #e2e8f0;
}
.llm-section-sub {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
color: #64748b;
}
.llm-preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.65rem;
margin-bottom: 1.5rem;
}
.llm-preset-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
min-height: 7.5rem;
padding: 0.85rem 0.95rem;
text-align: left;
cursor: pointer;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(15, 23, 42, 0.55);
color: inherit;
font: inherit;
transition: border-color 0.15s, background 0.15s, transform 0.12s;
}
.llm-preset-card:hover {
border-color: rgba(56, 189, 248, 0.35);
background: rgba(14, 165, 233, 0.08);
transform: translateY(-1px);
}
.llm-preset-name {
font-size: 0.875rem;
font-weight: 600;
color: #f1f5f9;
line-height: 1.25;
}
.llm-preset-hint {
font-size: 0.75rem;
line-height: 1.4;
color: #64748b;
flex: 1;
}
.llm-preset-models {
font-size: 0.68rem;
font-family: ui-monospace, monospace;
color: #475569;
word-break: break-all;
line-height: 1.35;
}
.llm-provider-list { margin-top: 0.25rem; }
+302 -91
View File
@@ -1,13 +1,7 @@
/** /**
* Export Intel map — Leaflet + clustering + rich popups * Export Intel map — Leaflet + clustering (multi-instance per tab)
*/ */
window.ExportIntelMap = (function () { window.ExportIntelMap = (function () {
var map = null;
var clusterLayer = null;
var onEntityClick = null;
var halalMode = false;
var markersById = {};
var HALAL_COLORS = { var HALAL_COLORS = {
hot: '#22c55e', hot: '#22c55e',
warm: '#eab308', warm: '#eab308',
@@ -27,6 +21,9 @@ window.ExportIntelMap = (function () {
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' }, foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
}; };
var instances = {};
var DEFAULT_ID = 'ei-map';
function cfg(type) { function cfg(type) {
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') }; return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
} }
@@ -52,19 +49,128 @@ window.ExportIntelMap = (function () {
); );
} }
function normalizeUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u) return null;
if (/^mailto:/i.test(u) || /^tel:/i.test(u)) return u;
if (!/^https?:\/\//i.test(u)) u = 'https://' + u.replace(/^\/+/, '');
return u;
}
function channelList(items, cls) {
if (!items || !items.length) return '';
return items.map(function (it) {
var val = escapeHtml(it.value || it.display || '');
var label = escapeHtml(it.label || '');
var href = it.href || it.url;
if (href) {
return '<a class="ei-popup-link ' + (cls || '') + '" href="' + escapeHtml(href) + '" target="_blank" rel="noopener" title="' + val + '">' +
(label ? '<span class="ei-popup-link-label">' + label + '</span>' : '') +
'<span class="ei-popup-link-val">' + val + '</span></a>';
}
return '<div class="ei-popup-plain">' + (label ? '<span class="ei-popup-link-label">' + label + '</span>' : '') +
'<span class="ei-popup-link-val">' + val + '</span></div>';
}).join('');
}
function contactPeopleBlock(people) {
if (!people || !people.length) return '';
var cards = people.map(function (c) {
var head = escapeHtml(c.name || c.role || 'Contact');
var sub = escapeHtml([c.role, c.title, c.department].filter(Boolean).join(' · '));
var bits = [];
if (c.email) bits.push('<a class="ei-popup-link" href="mailto:' + escapeHtml(c.email) + '">📧 ' + escapeHtml(c.email) + '</a>');
if (c.email_alt) bits.push('<a class="ei-popup-link" href="mailto:' + escapeHtml(c.email_alt) + '">📧 ' + escapeHtml(c.email_alt) + ' (alt)</a>');
if (c.phone) bits.push('<a class="ei-popup-link" href="tel:' + escapeHtml(c.phone) + '">📞 ' + escapeHtml(c.phone) + '</a>');
if (c.mobile) bits.push('<a class="ei-popup-link" href="tel:' + escapeHtml(c.mobile) + '">📱 ' + escapeHtml(c.mobile) + '</a>');
if (c.whatsapp) bits.push('<a class="ei-popup-link" href="https://wa.me/' + escapeHtml(String(c.whatsapp).replace(/\D/g, '')) + '" target="_blank" rel="noopener">💬 ' + escapeHtml(c.whatsapp) + '</a>');
if (c.website) bits.push('<a class="ei-popup-link" href="' + escapeHtml(c.website) + '" target="_blank" rel="noopener">🌐 Website</a>');
if (c.linkedin_url) bits.push('<a class="ei-popup-link" href="' + escapeHtml(c.linkedin_url) + '" target="_blank" rel="noopener">in LinkedIn</a>');
return '<div class="ei-popup-person">' +
'<div class="ei-popup-person-head">' + head + (c.is_primary ? ' <span class="ei-popup-primary">primair</span>' : '') + '</div>' +
(sub ? '<div class="ei-popup-person-sub">' + sub + '</div>' : '') +
(bits.length ? '<div class="ei-popup-person-links">' + bits.join('') + '</div>' : '') +
'</div>';
}).join('');
return '<div class="ei-popup-section"><div class="ei-popup-section-title">Contactpersonen</div>' + cards + '</div>';
}
function reachBlock(p) {
var r = p.reach || {};
if (!r.has_any && !p.email && !p.phone && !(p.websites && p.websites.length)) {
return '<div class="ei-popup-empty-contact">Nog geen contact — sync contacten of vul handmatig aan.</div>';
}
var html = '<div class="ei-popup-reach">';
if ((r.emails && r.emails.length) || p.email) {
var emails = r.emails && r.emails.length ? r.emails : (p.email ? [{ label: 'E-mail', value: p.email, href: 'mailto:' + p.email }] : []);
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">E-mail</div><div class="ei-popup-channel-list">' + channelList(emails) + '</div></div>';
}
if ((r.phones && r.phones.length) || p.phone) {
var phones = r.phones && r.phones.length ? r.phones : (p.phone ? [{ label: 'Telefoon', value: p.phone, href: 'tel:' + p.phone }] : []);
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Telefoon</div><div class="ei-popup-channel-list">' + channelList(phones) + '</div></div>';
}
var webHtml = websitesBlock(p);
if (webHtml) html += webHtml.replace('ei-popup-row ei-popup-websites', 'ei-popup-section ei-popup-websites').replace('ei-popup-k">Websites', 'ei-popup-section-title">Websites').replace('ei-popup-v ei-popup-web-list', 'ei-popup-web-list');
if (r.addresses && r.addresses.length) {
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Adres</div><div class="ei-popup-channel-list">' + channelList(r.addresses) + '</div></div>';
}
if (r.social && r.social.length) {
html += '<div class="ei-popup-section"><div class="ei-popup-section-title">Social</div><div class="ei-popup-channel-list">' + channelList(r.social) + '</div></div>';
}
if (r.maps_url) {
html += '<div class="ei-popup-section"><a class="ei-popup-maps" href="' + escapeHtml(r.maps_url) + '" target="_blank" rel="noopener">📍 Open in Google Maps</a></div>';
}
html += contactPeopleBlock(r.contact_people);
html += '</div>';
return html;
}
function websitesBlock(p) {
var sites = p.websites;
if ((!sites || !sites.length) && p.website) {
var w = normalizeUrl(p.website);
if (w) sites = [{ label: 'Organisatie', url: w, display: p.website }];
}
if (!sites || !sites.length) return '';
var links = sites.map(function (s) {
var href = normalizeUrl(s.url || s);
if (!href) return '';
var label = escapeHtml(s.label || 'Website');
var disp = escapeHtml(s.display || s.url || '');
return '<a class="ei-popup-web" href="' + escapeHtml(href) + '" target="_blank" rel="noopener" title="' + disp + '">' + label + '</a>';
}).filter(Boolean).join('');
if (!links) return '';
return (
'<div class="ei-popup-row ei-popup-websites">' +
'<span class="ei-popup-k">Websites</span>' +
'<span class="ei-popup-v ei-popup-web-list">' + links + '</span>' +
'</div>'
);
}
function halalColor(p) { function halalColor(p) {
var tier = p.halal_tier || 'low'; var tier = p.halal_tier || 'low';
return HALAL_COLORS[tier] || HALAL_COLORS.low; return HALAL_COLORS[tier] || HALAL_COLORS.low;
} }
function popupHtml(p) { function MapInstance(containerId) {
this.containerId = containerId;
this.map = null;
this.clusterLayer = null;
this.onEntityClick = null;
this.halalMode = false;
this.markersById = {};
}
MapInstance.prototype.popupHtml = function (p) {
var c = cfg(p.entity_type); var c = cfg(p.entity_type);
var id = p.id; var id = p.id;
var web = p.website;
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
var halalRow = ''; var halalRow = '';
if (p.halal_score != null) { if (p.halal_score != null) {
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : '')); halalRow = row('Halal kans', p.halal_score + '/100' + (this.halalMode ? ' · ' + (p.halal_tier || '') : ''));
} }
var contactBits = []; var contactBits = [];
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email)); if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
@@ -75,7 +181,7 @@ window.ExportIntelMap = (function () {
return ( return (
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' + '<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
'<div class="ei-popup-top">' + '<div class="ei-popup-top">' +
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' + '<span class="ei-popup-badge" style="--badge-color:' + (this.halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' + '<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
'</div>' + '</div>' +
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' + '<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
@@ -86,7 +192,7 @@ window.ExportIntelMap = (function () {
row('Adres', p.address_line) + row('Adres', p.address_line) +
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) + row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) + row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
row('Website', p.website, web) + websitesBlock(p) +
row('Volume', p.volume_band) + row('Volume', p.volume_band) +
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) + row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
row('Confidence', p.confidence != null ? p.confidence + '%' : null) + row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
@@ -97,12 +203,12 @@ window.ExportIntelMap = (function () {
'</div>' + '</div>' +
'</div>' '</div>'
); );
} };
function pinIcon(type, p) { MapInstance.prototype.pinIcon = function (type, p) {
var c = cfg(type); var c = cfg(type);
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color; var color = this.halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : ''; var ring = this.halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
return L.divIcon({ return L.divIcon({
className: 'ei-pin-wrap', className: 'ei-pin-wrap',
html: html:
@@ -114,22 +220,58 @@ window.ExportIntelMap = (function () {
iconAnchor: [15, 15], iconAnchor: [15, 15],
popupAnchor: [0, -16], popupAnchor: [0, -16],
}); });
} };
function openEntityId(rawId) { MapInstance.prototype.openEntityId = function (rawId) {
var id = Number(rawId); var id = Number(rawId);
if (!Number.isFinite(id)) return; if (!Number.isFinite(id)) return;
if (onEntityClick) { if (this.onEntityClick) {
onEntityClick(id); this.onEntityClick(id);
return; return;
} }
try { try {
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } })); document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
} catch (e) { /* empty */ } } catch (e) { /* empty */ }
} };
function wirePopup(container) {
MapInstance.prototype.enrichPopup = function (entityId, popup) {
if (!entityId || !popup) return;
var self = this;
fetch('/api/export-intel/entities/' + entityId)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (ent) {
if (!ent || !popup || !popup.isOpen || !popup.isOpen()) return;
var merged = {
id: ent.id,
name: ent.name,
entity_type: ent.entity_type,
country_iso2: ent.country_iso2,
city: ent.city,
address_line: ent.address_line,
postal_code: ent.postal_code,
email: ent.email,
phone: ent.phone,
website: ent.website,
websites: ent.websites || [],
reach: ent.reach || null,
volume_band: ent.volume_band,
confidence: ent.confidence,
pipeline_stage: ent.pipeline_stage,
contact_count: ent.contact_count,
halal_score: ent.halal_score,
halal_tier: ent.halal_tier,
};
popup.setContent(self.popupHtml(merged));
var el = popup.getElement();
if (el) self.wirePopup(el.querySelector('.leaflet-popup-content') || el);
})
.catch(function () { /* keep cached popup */ });
};
MapInstance.prototype.wirePopup = function (container) {
if (!container) return; if (!container) return;
var self = this;
var popupRoot = container.closest('.leaflet-popup') || container; var popupRoot = container.closest('.leaflet-popup') || container;
if (window.L && L.DomEvent) { if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(popupRoot); L.DomEvent.disableClickPropagation(popupRoot);
@@ -143,7 +285,7 @@ window.ExportIntelMap = (function () {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
openEntityId(id); self.openEntityId(id);
}; };
if (window.L && L.DomEvent) { if (window.L && L.DomEvent) {
L.DomEvent.on(btn, 'click', onBtn); L.DomEvent.on(btn, 'click', onBtn);
@@ -156,38 +298,39 @@ window.ExportIntelMap = (function () {
container.addEventListener('click', function (e) { container.addEventListener('click', function (e) {
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return; if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
var card = e.target.closest('[data-entity-id]'); var card = e.target.closest('[data-entity-id]');
if (card) openEntityId(card.getAttribute('data-entity-id')); if (card) self.openEntityId(card.getAttribute('data-entity-id'));
}); });
} };
function invalidateMapSize() { MapInstance.prototype.invalidateSize = function () {
if (!map) return; if (!this.map) return;
var map = this.map;
map.invalidateSize(true); map.invalidateSize(true);
setTimeout(function () { if (map) map.invalidateSize(true); }, 120); setTimeout(function () { if (map) map.invalidateSize(true); }, 120);
setTimeout(function () { if (map) map.invalidateSize(true); }, 400); setTimeout(function () { if (map) map.invalidateSize(true); }, 400);
setTimeout(function () { if (map) map.invalidateSize(true); }, 800); };
}
function init(containerId) { MapInstance.prototype.init = function () {
var el = document.getElementById(containerId); var el = document.getElementById(this.containerId);
if (!el || !window.L) return null; if (!el || !window.L) return null;
if (map && map.getContainer() === el) { if (this.map && this.map.getContainer() === el) {
invalidateMapSize(); this.invalidateSize();
return map; return this.map;
} }
if (map) { if (this.map) {
map.remove(); this.map.remove();
map = null; this.map = null;
clusterLayer = null; this.clusterLayer = null;
} }
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2); var self = this;
this.map = L.map(this.containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
L.tileLayer('/api/map/tiles/{z}/{x}/{y}.png', { L.tileLayer('/api/map/tiles/{z}/{x}/{y}.png', {
attribution: '&copy; OSM &copy; CARTO', attribution: '&copy; OSM &copy; CARTO',
maxZoom: 19, maxZoom: 19,
}).addTo(map); }).addTo(this.map);
if (window.L.markerClusterGroup) { if (window.L.markerClusterGroup) {
clusterLayer = L.markerClusterGroup({ this.clusterLayer = L.markerClusterGroup({
maxClusterRadius: 42, maxClusterRadius: 42,
spiderfyOnMaxZoom: true, spiderfyOnMaxZoom: true,
showCoverageOnHover: false, showCoverageOnHover: false,
@@ -202,100 +345,168 @@ window.ExportIntelMap = (function () {
}); });
}, },
}); });
map.addLayer(clusterLayer); this.map.addLayer(this.clusterLayer);
} else { } else {
clusterLayer = L.layerGroup(); this.clusterLayer = L.layerGroup();
map.addLayer(clusterLayer); this.map.addLayer(this.clusterLayer);
} }
map.on('popupopen', function (e) { this.map.on('popupopen', function (e) {
var root = e.popup && e.popup.getElement(); var root = e.popup && e.popup.getElement();
if (!root) return; if (!root) return;
if (window.L && L.DomEvent) { if (window.L && L.DomEvent) {
L.DomEvent.disableClickPropagation(root); L.DomEvent.disableClickPropagation(root);
L.DomEvent.disableScrollPropagation(root); L.DomEvent.disableScrollPropagation(root);
} }
wirePopup(root.querySelector('.leaflet-popup-content') || root); self.wirePopup(root.querySelector('.leaflet-popup-content') || root);
var card = root.querySelector('[data-entity-id]');
var eid = card && card.getAttribute('data-entity-id');
if (eid) self.enrichPopup(Number(eid), e.popup);
}); });
invalidateMapSize(); this.invalidateSize();
return map; return this.map;
} };
function setFeatures(geojson, opts) { MapInstance.prototype.setFeatures = function (geojson, opts) {
opts = opts || {}; opts = opts || {};
if (!map || !clusterLayer) return; if (!this.map || !this.clusterLayer) return;
clusterLayer.clearLayers(); this.clusterLayer.clearLayers();
markersById = {}; this.markersById = {};
var features = (geojson && geojson.features) || []; var features = (geojson && geojson.features) || [];
var markers = []; var markers = [];
var self = this;
features.forEach(function (f) { features.forEach(function (f) {
var coords = f.geometry && f.geometry.coordinates; var coords = f.geometry && f.geometry.coordinates;
if (!coords) return; if (!coords) return;
var p = f.properties || {}; var p = f.properties || {};
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) }); var marker = L.marker([coords[1], coords[0]], { icon: self.pinIcon(p.entity_type, p) });
marker._eiId = p.id; marker._eiId = p.id;
if (p.id != null) markersById[p.id] = marker; if (p.id != null) self.markersById[p.id] = marker;
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' }); marker.bindPopup(self.popupHtml(p), { maxWidth: 360, minWidth: 280, maxHeight: 420, className: 'ei-leaflet-popup', autoClose: false, closeOnClick: false });
marker.on('dblclick', function () { marker.on('click', function (e) {
if (onEntityClick && p.id) onEntityClick(p.id); if (window.L && L.DomEvent) L.DomEvent.stopPropagation(e);
marker.openPopup();
if (self.onEntityClick && p.id != null) self.onEntityClick(p.id);
}); });
markers.push(marker); markers.push(marker);
}); });
if (clusterLayer.addLayers) clusterLayer.addLayers(markers); if (this.clusterLayer.addLayers) this.clusterLayer.addLayers(markers);
else markers.forEach(function (m) { clusterLayer.addLayer(m); }); else markers.forEach(function (m) { self.clusterLayer.addLayer(m); });
invalidateMapSize(); this.invalidateSize();
if (opts.autoFit === false) { if (opts.autoFit === false) {
map.setView([20, 10], 2); this.map.setView([20, 10], 2);
} else if (features.length === 1) { } else if (features.length === 1) {
var c = features[0].geometry.coordinates; var c = features[0].geometry.coordinates;
map.setView([c[1], c[0]], 10); this.map.setView([c[1], c[0]], 10);
} else if (opts.autoFit && markers.length > 1 && clusterLayer.getBounds) { } else if (opts.autoFit && markers.length > 1 && this.clusterLayer.getBounds) {
try { try {
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 }); this.map.fitBounds(this.clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
} catch (e) { /* empty */ } } catch (e) { /* empty */ }
} }
} };
function setHalalMode(on) { MapInstance.prototype.setHalalMode = function (on) {
halalMode = !!on; this.halalMode = !!on;
} };
function setOnEntityClick(fn) { MapInstance.prototype.setOnEntityClick = function (fn) {
onEntityClick = fn; this.onEntityClick = typeof fn === 'function' ? fn : null;
} };
function flyTo(lat, lon, zoom) { MapInstance.prototype.flyTo = function (lat, lon, zoom) {
if (map && lat && lon) map.setView([lat, lon], zoom || 10); if (this.map && lat && lon) this.map.setView([lat, lon], zoom || 10);
} };
function highlightEntity(id) { MapInstance.prototype.highlightEntity = function (id) {
var marker = markersById[id]; var marker = this.markersById[id];
if (!marker || !map) return; if (!marker || !this.map) return;
var latlng = marker.getLatLng(); var latlng = marker.getLatLng();
map.setView(latlng, Math.max(map.getZoom(), 14)); this.map.setView(latlng, Math.max(this.map.getZoom(), 14));
marker.openPopup(); marker.openPopup();
} };
function closePopup() { MapInstance.prototype.closePopup = function () {
if (map) map.closePopup(); if (this.map) this.map.closePopup();
} };
function destroy() { MapInstance.prototype.destroy = function () {
if (map) { if (this.map) {
map.remove(); this.map.remove();
map = null; this.map = null;
clusterLayer = null; this.clusterLayer = null;
} }
delete instances[this.containerId];
};
function resolveId(containerId) {
return containerId || DEFAULT_ID;
}
function getOrCreate(containerId) {
var id = resolveId(containerId);
if (!instances[id]) instances[id] = new MapInstance(id);
return instances[id];
}
function init(containerId) {
return getOrCreate(containerId).init();
}
function setFeatures(geojson, opts, containerId) {
getOrCreate(containerId).setFeatures(geojson, opts);
}
function setHalalMode(on, containerId) {
getOrCreate(containerId).setHalalMode(on);
}
function setOnEntityClick(fn, containerId) {
getOrCreate(containerId).setOnEntityClick(fn);
}
function flyTo(lat, lon, zoom, containerId) {
getOrCreate(containerId).flyTo(lat, lon, zoom);
}
function highlightEntity(id, containerId) {
getOrCreate(containerId).highlightEntity(id);
}
function closePopup(containerId) {
getOrCreate(containerId).closePopup();
}
function destroy(containerId) {
if (containerId) getOrCreate(containerId).destroy();
else {
Object.keys(instances).forEach(function (k) { instances[k].destroy(); });
}
}
function invalidateAll() {
Object.keys(instances).forEach(function (k) { instances[k].invalidateSize(); });
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('resize', function () { invalidateMapSize(); }); window.addEventListener('resize', invalidateAll);
} }
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg }; return {
init: init,
setFeatures: setFeatures,
setOnEntityClick: setOnEntityClick,
setHalalMode: setHalalMode,
flyTo: flyTo,
closePopup: closePopup,
highlightEntity: highlightEntity,
destroy: destroy,
invalidateAll: invalidateAll,
TYPE_CFG: TYPE_CFG,
cfg: cfg,
};
})(); })();
+591
View File
@@ -0,0 +1,591 @@
/**
* Export Intel — profiel popup (imperatief, geen Alpine in modal).
*/
(function (global) {
'use strict';
function normalizeWebUrl(u) {
if (!u) return '';
u = String(u).trim();
if (!u) return '';
if (!/^https?:\/\//i.test(u)) u = 'https://' + u.replace(/^\/+/, '');
return u;
}
function contactGapHint(e) {
var r = e.reach || {};
var hasDirect = !!(e.phone || e.email || e.website || (r.phones && r.phones.length) || (r.emails && r.emails.length) || (r.websites && r.websites.length));
if (hasDirect) return '';
var src = e.source || 'osm';
return '<p class="ei-drawer-empty ei-drawer-hint">Geen telefoon of e-mail in bron (' + esc(src) + '). Gratis opties: <strong>Bewerken</strong> (handmatig), of <strong>Sync contacten</strong> (scant websites) / <strong>Sync distributeurs</strong> (haalt OSM-tags opnieuw).</p>';
}
function reachSectionHtml(e) {
var r = e.reach || {};
var html = '';
function links(title, items, render) {
if (!items || !items.length) return;
html += '<div class="ei-reach-block"><h5>' + esc(title) + '</h5>';
for (var i = 0; i < items.length; i++) html += render(items[i]);
html += '</div>';
}
links('E-mail', r.emails, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'E-mail') + '</span><a href="' + esc(it.href || ('mailto:' + it.value)) + '">' + esc(it.value) + '</a></div>';
});
links('Telefoon', r.phones, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Tel') + '</span><a href="' + esc(it.href || ('tel:' + it.value)) + '">' + esc(it.value) + '</a></div>';
});
if (!r.emails || !r.emails.length) {
if (e.email) html += '<div class="ei-drawer-kv"><span>E-mail</span><a href="mailto:' + esc(e.email) + '">' + esc(e.email) + '</a></div>';
}
if (!r.phones || !r.phones.length) {
if (e.phone) html += '<div class="ei-drawer-kv"><span>Telefoon</span><a href="tel:' + esc(e.phone) + '">' + esc(e.phone) + '</a></div>';
}
html += websitesHtml(e);
links('Adres', r.addresses, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Adres') + '</span><span>' + esc(it.value) + '</span></div>';
});
links('Social', r.social, function (it) {
return '<div class="ei-drawer-kv"><span>' + esc(it.label || 'Link') + '</span><a href="' + esc(it.href) + '" target="_blank" rel="noopener">' + esc(it.value) + '</a></div>';
});
if (r.maps_url) {
html += '<div class="ei-drawer-kv"><span>Maps</span><a href="' + esc(r.maps_url) + '" target="_blank" rel="noopener">Google Maps →</a></div>';
}
return html;
}
function websitesHtml(e) {
var sites = e.websites;
if ((!sites || !sites.length) && e.website) {
sites = [{ label: 'Organisatie', url: e.website, display: e.website }];
}
if (!sites || !sites.length) return '';
var rows = '';
for (var i = 0; i < sites.length; i++) {
var s = sites[i];
var href = normalizeWebUrl(s.url || s);
if (!href) continue;
rows += '<div class="ei-drawer-kv"><span>' + esc(s.label || 'Website') + '</span>' +
'<a href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(s.display || s.url || href) + '</a></div>';
}
return rows;
}
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function typeLabel(t) {
return (t || '').replace(/_/g, ' ');
}
function getApp() {
return global.__exportIntelApp || null;
}
function territoryName(iso2) {
var app = getApp();
if (!iso2) return '';
if (app && app.territories) {
for (var i = 0; i < app.territories.length; i++) {
if (app.territories[i].country_iso2 === iso2) return app.territories[i].name_nl;
}
}
return iso2;
}
function formatCoords(lat, lon) {
if (lat == null || lon == null) return '';
var a = Number(lat);
var b = Number(lon);
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return a.toFixed(5) + ', ' + b.toFixed(5);
}
function mountEl() {
return document.getElementById('ei-profile-mount');
}
function activeMapId(app) {
if (!app) return 'ei-map';
if (app.tab === 'map') return 'ei-map';
if (app.tabMapId && typeof app.tabMapId === 'function') {
return app.tabMapId() || 'ei-map';
}
return 'ei-map-' + (app.tab || 'map');
}
var Profile = {
_busy: false,
showShell: function (on) {
var el = document.getElementById('ei-profile-modal');
if (!el) return;
if (on) {
el.classList.add('is-open');
el.setAttribute('aria-hidden', 'false');
document.body.classList.add('ei-profile-open');
} else {
el.classList.remove('is-open');
el.setAttribute('aria-hidden', 'true');
document.body.classList.remove('ei-profile-open');
}
},
clearMount: function () {
var m = mountEl();
if (m) m.innerHTML = '';
},
renderLoading: function () {
var m = mountEl();
if (!m) return;
m.innerHTML =
'<div class="ei-drawer-loading" style="padding:3rem 1.5rem;text-align:center">' +
'<span class="ei-map-spinner"></span>' +
'<p style="margin-top:1rem;color:#94a3b8">Profiel laden…</p></div>';
},
renderError: function (msg) {
var m = mountEl();
if (!m) return;
m.innerHTML =
'<div class="ei-drawer-loading" style="padding:2rem 1.5rem">' +
'<p style="color:#fecdd3;margin-bottom:1rem">' + esc(msg || 'Laden mislukt') + '</p>' +
'<button type="button" class="btn btn-sm" data-ei-action="close">Sluiten</button></div>';
},
patchEntityInApp: function (app, entity) {
if (!app || !entity || !entity.id) return;
var id = entity.id;
if (Array.isArray(app.entities)) {
for (var i = 0; i < app.entities.length; i++) {
if (app.entities[i].id === id) {
Object.assign(app.entities[i], entity);
}
}
}
if (Array.isArray(app.crmPipeline)) {
for (var j = 0; j < app.crmPipeline.length; j++) {
if (app.crmPipeline[j].id === id) {
Object.assign(app.crmPipeline[j], entity);
}
}
}
if (Array.isArray(app.halalTopEntities)) {
for (var k = 0; k < app.halalTopEntities.length; k++) {
if (app.halalTopEntities[k].id === id) {
Object.assign(app.halalTopEntities[k], entity);
}
}
}
},
renderFull: function (e, app) {
var m = mountEl();
if (!m || !e) return;
app = app || getApp();
var contacts = e.contacts || [];
var contactRows = '';
for (var i = 0; i < contacts.length; i++) {
var c = contacts[i];
contactRows +=
'<div class="ei-contact-row">' +
'<strong>' + esc(c.name || c.role || 'Contact') + '</strong>' +
(c.role ? '<span class="ei-contact-role">' + esc(c.role) + '</span>' : '') +
(c.email ? '<div>📧 <a href="mailto:' + esc(c.email) + '">' + esc(c.email) + '</a></div>' : '') +
(c.phone ? '<div>📞 <a href="tel:' + esc(c.phone) + '">' + esc(c.phone) + '</a></div>' : '') +
(c.website_contact_url ? '<div>🌐 <a href="' + esc(normalizeWebUrl(c.website_contact_url)) + '" target="_blank" rel="noopener">' + esc(c.website_contact_url) + '</a></div>' : '') +
(c.linkedin_url ? '<div>in <a href="' + esc(normalizeWebUrl(c.linkedin_url)) + '" target="_blank" rel="noopener">LinkedIn</a></div>' : '') +
'</div>';
}
var halalTags = '';
var labels = e.halal_reason_labels || [];
for (var j = 0; j < labels.length; j++) {
halalTags += '<span class="ei-halal-tag">' + esc(labels[j]) + '</span>';
}
var crmBtn = e.client_id
? '<a class="ei-action-tile ei-action-crm-done" href="/clients"><span class="ei-action-icon">✓</span><span class="ei-action-label">Open CRM</span><span class="ei-action-sub">' +
esc(e.crm_client_name || ('Klant #' + e.client_id)) + '</span></a>'
: '<button type="button" class="ei-action-tile ei-action-crm" data-ei-action="crm" data-ei-id="' + e.id + '"><span class="ei-action-icon">➕</span><span class="ei-action-label">Naar CRM</span><span class="ei-action-sub">Klant + deal</span></button>';
var mapsBtn = e.maps_url
? '<a class="ei-action-tile" href="' + esc(e.maps_url) + '" target="_blank" rel="noopener"><span class="ei-action-icon">📍</span><span class="ei-action-label">Maps</span><span class="ei-action-sub">Locatie</span></a>'
: '';
var dealBtn = e.deal_id
? '<a class="ei-action-tile" href="/deals"><span class="ei-action-icon">💼</span><span class="ei-action-label">Deal</span><span class="ei-action-sub">Pipeline</span></a>'
: '';
var favOn = !!e.is_favorite;
m.innerHTML =
'<header class="ei-drawer-head-rich">' +
'<div class="ei-drawer-head-top">' +
'<div class="ei-drawer-hero">' +
'<span class="ei-drawer-badge ei-type-' + esc(e.entity_type) + '">' + esc(typeLabel(e.entity_type)) + '</span>' +
'<span class="ei-drawer-flag">' + esc(e.country_iso2) + '</span>' +
(e.client_id ? '<span class="ei-crm-badge">In CRM</span>' : '') +
(favOn ? '<span class="ei-crm-badge" style="color:#facc15;border-color:rgba(251,191,36,.4)">★ Favoriet</span>' : '') +
(e.halal_score != null ? '<span class="ei-halal-pill ei-halal-hot">' + Math.round(Number(e.halal_score)) + '/100</span>' : '') +
'</div>' +
'<button type="button" class="ei-drawer-close" data-ei-action="close" title="Sluiten">✕</button>' +
'</div>' +
'<h2 class="ei-drawer-title" id="ei-profile-title">' + esc(e.name) + '</h2>' +
'<p class="ei-drawer-meta">' + esc(territoryName(e.country_iso2)) +
' · Confidence <strong>' + (e.confidence || 0) + '%</strong>' +
' · Pipeline <strong>' + esc(e.pipeline_stage || '—') + '</strong>' +
' · Bron <strong>' + esc(e.source || '—') + '</strong></p>' +
'</header>' +
'<div class="ei-drawer-toolbar">' +
crmBtn +
'<button type="button" class="ei-action-tile ei-action-edit" data-ei-action="edit"><span class="ei-action-icon">✎</span><span class="ei-action-label">Bewerken</span><span class="ei-action-sub">Velden</span></button>' +
'<button type="button" class="ei-action-tile' + (favOn ? ' ei-action-fav-on' : '') + '" data-ei-action="favorite" data-ei-id="' + e.id + '" data-ei-fav="' + (favOn ? '1' : '0') + '"><span class="ei-action-icon">' + (favOn ? '★' : '☆') + '</span><span class="ei-action-label">' + (favOn ? 'Favoriet' : 'Favoriet maken') + '</span></button>' +
mapsBtn +
'<button type="button" class="ei-action-tile" data-ei-action="sync-contacts"><span class="ei-action-icon">↻</span><span class="ei-action-label">Contacten</span></button>' +
dealBtn +
'<a class="ei-action-tile" href="/herman?context=export_catalog&entity=' + e.id + '"><span class="ei-action-icon">📋</span><span class="ei-action-label">Herman</span></a>' +
'</div>' +
'<div class="ei-drawer-body ei-drawer-body-rich">' +
(halalTags ? '<div class="ei-halal-tags">' + halalTags + '</div>' : '') +
((e.city || e.address_line || (e.lat && e.lon))
? '<section class="ei-drawer-section"><h4>Locatie</h4>' +
(e.address_line ? '<p>' + esc(e.address_line) + '</p>' : '') +
(e.city ? '<p>' + esc(e.city) + (e.postal_code ? ', ' + esc(e.postal_code) : '') + '</p>' : '') +
((e.lat && e.lon)
? '<p class="ei-drawer-coords">' + esc(formatCoords(e.lat, e.lon)) +
(e.maps_url ? ' <a href="' + esc(e.maps_url) + '" target="_blank" rel="noopener">Maps →</a>' : '') + '</p>'
: '') +
'</section>'
: '') +
'<section class="ei-drawer-section"><h4>Contact & web</h4>' +
reachSectionHtml(e) + contactGapHint(e) +
((!e.reach || !e.reach.has_any) && !e.email && !e.phone && !(e.websites && e.websites.length) && !e.website && !contacts.length ? '<p class="ei-drawer-empty">Geen contact — sync contacten om te verrijken.</p>' : '') +
'</section>' +
(e.client_id
? '<section class="ei-drawer-section"><h4>CRM</h4>' +
'<div class="ei-drawer-kv"><span>Klant</span><a href="/clients">' + esc(e.crm_client_name || ('#' + e.client_id)) + '</a></div>' +
(e.crm_deal_title ? '<div class="ei-drawer-kv"><span>Deal</span><span>' + esc(e.crm_deal_title) + '</span></div>' : '') +
'</section>'
: '') +
'<section class="ei-drawer-section"><h4>Contactpersonen (' + contacts.length + ')</h4>' +
(contactRows || '<p class="ei-drawer-empty">Geen contactpersonen.</p>') +
'</section></div>';
if (app) {
app.selected = e;
app.drawerOpen = true;
app.drawerLoading = false;
app.listFocusDetail = e;
app.listFocusId = e.id;
}
},
fetchEntity: function (id) {
return fetch('/api/export-intel/entities/' + id).then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error(d.error || d.detail || ('HTTP ' + r.status));
if (d && d.error) throw new Error(d.error);
return d;
});
});
},
highlightOnMap: function (entity, app) {
if (!entity || !entity.lat || !entity.lon || !global.ExportIntelMap) return;
try {
var mapId = activeMapId(app);
global.ExportIntelMap.flyTo(entity.lat, entity.lon, 14, mapId);
global.ExportIntelMap.highlightEntity(entity.id, mapId);
} catch (e) { /* ignore */ }
},
open: function (id) {
id = Number(id);
if (!Number.isFinite(id) || id <= 0) return;
if (this._busy) return;
this._busy = true;
this.showShell(true);
this.renderLoading();
var self = this;
var app = getApp();
this.fetchEntity(id)
.then(function (entity) {
self.renderFull(entity, app);
self.highlightOnMap(entity, app);
})
.catch(function (err) {
self.renderError(err.message || 'Profiel laden mislukt');
})
.finally(function () {
self._busy = false;
});
},
close: function () {
this._busy = false;
this.showShell(false);
this.clearMount();
var app = getApp();
if (app) {
app.drawerOpen = false;
app.drawerLoading = false;
app.selected = null;
app.editMode = false;
}
},
setFavorite: function (id, currentlyFavorite) {
var self = this;
var app = getApp();
id = Number(id);
if (!Number.isFinite(id)) return Promise.reject(new Error('Ongeldig id'));
return fetch('/api/export-intel/entities/favorites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_ids: [id], favorite: !currentlyFavorite }),
})
.then(function (r) {
return r.json().then(function (d) {
if (!r.ok) throw new Error((d && (d.error || d.detail)) || 'Favoriet mislukt');
return d;
});
})
.then(function () {
return self.fetchEntity(id);
})
.then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
app.listFocusId = entity.id;
app.syncMsg = entity.is_favorite ? '★ Favoriet opgeslagen' : 'Favoriet verwijderd';
if (app.stats && typeof app.stats.favorites === 'number') {
app.stats.favorites += entity.is_favorite ? 1 : -1;
if (app.stats.favorites < 0) app.stats.favorites = 0;
}
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
return entity;
});
},
doCrm: function (id) {
var self = this;
var app = getApp();
id = Number(id);
if (!app || !app.pushEntityToCrm) return;
app.pushEntityToCrm(id).then(function () {
return self.fetchEntity(id);
}).then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
}).catch(function (err) {
if (app) app.syncMsg = 'CRM mislukt: ' + (err.message || err);
});
},
doEdit: function () {
var app = getApp();
if (!app || !app.selected) return;
app.startEdit();
this.renderEdit(app);
},
renderEdit: function (app) {
var e = app.selected;
var f = app.editForm || {};
var m = mountEl();
if (!m || !e) return;
m.innerHTML =
'<header class="ei-drawer-head-rich"><div class="ei-drawer-head-top">' +
'<h2 class="ei-drawer-title">Bewerken · ' + esc(e.name) + '</h2>' +
'<button type="button" class="ei-drawer-close" data-ei-action="close">✕</button></div></header>' +
'<div class="ei-drawer-body ei-drawer-body-rich"><div class="ei-drawer-edit">' +
'<div class="ei-edit-grid">' +
'<label>Naam<input type="text" id="ei-edit-name" value="' + esc(f.name || '') + '"></label>' +
'<label>Stad<input type="text" id="ei-edit-city" value="' + esc(f.city || '') + '"></label>' +
'<label>E-mail<input type="email" id="ei-edit-email" value="' + esc(f.email || '') + '"></label>' +
'<label>Telefoon<input type="text" id="ei-edit-phone" value="' + esc(f.phone || '') + '"></label>' +
'<label>Website<input type="url" id="ei-edit-website" value="' + esc(f.website || '') + '"></label>' +
'</div><div class="ei-edit-actions">' +
'<button type="button" class="btn btn-sm btn-pulse-green" data-ei-action="save-edit">Opslaan</button>' +
'<button type="button" class="btn btn-sm" data-ei-action="cancel-edit">Annuleren</button>' +
'</div></div></div>';
},
saveEdit: function () {
var app = getApp();
if (!app || !app.selected) return;
var self = this;
app.editForm.name = (document.getElementById('ei-edit-name') || {}).value || '';
app.editForm.city = (document.getElementById('ei-edit-city') || {}).value || '';
app.editForm.email = (document.getElementById('ei-edit-email') || {}).value || '';
app.editForm.phone = (document.getElementById('ei-edit-phone') || {}).value || '';
app.editForm.website = (document.getElementById('ei-edit-website') || {}).value || '';
var id = app.selected.id;
Promise.resolve(app.saveEdit()).then(function () {
return self.fetchEntity(id);
}).then(function (entity) {
if (app) {
app.selected = entity;
app.listFocusDetail = entity;
self.patchEntityInApp(app, entity);
}
self.renderFull(entity, app);
});
},
cancelEdit: function () {
var app = getApp();
if (!app) return;
app.cancelEdit();
if (app.selected) this.renderFull(app.selected, app);
},
doFavorite: function (id, isFav) {
var self = this;
this.setFavorite(id, isFav).catch(function (err) {
self.renderError(err.message || 'Favoriet mislukt');
});
},
doSyncContacts: function () {
var app = getApp();
if (app && app.syncKind) app.syncKind('contacts');
},
onModalClick: function (ev) {
var t = ev.target;
if (!t || !t.closest) return;
if (!t.closest('#ei-profile-modal')) return;
if (t.closest('.ei-profile-backdrop')) {
ev.preventDefault();
this.close();
return;
}
var actionEl = t.closest('[data-ei-action]');
if (!actionEl) return;
var action = actionEl.getAttribute('data-ei-action');
if (!action) return;
ev.preventDefault();
ev.stopPropagation();
var id = Number(actionEl.getAttribute('data-ei-id') || (getApp() && getApp().selected && getApp().selected.id));
if (action === 'close') this.close();
else if (action === 'crm') this.doCrm(id);
else if (action === 'edit') this.doEdit();
else if (action === 'favorite') {
var isFav = actionEl.getAttribute('data-ei-fav') === '1';
this.doFavorite(id, isFav);
}
else if (action === 'sync-contacts') this.doSyncContacts();
else if (action === 'save-edit') this.saveEdit();
else if (action === 'cancel-edit') this.cancelEdit();
},
onRowClick: function (ev) {
var t = ev.target;
if (!t || !t.closest) return;
if (t.closest('#ei-profile-modal')) return;
if (t.closest('.ei-td-check, input[type=checkbox], .ei-fav-btn, .ei-td-actions')) return;
if (t.closest('a[href]') && !t.closest('.ei-row-open-btn, .ei-link-btn')) return;
var row = t.closest('[data-entity-id]');
if (!row) return;
var id = row.getAttribute('data-entity-id');
if (!id) return;
ev.preventDefault();
ev.stopPropagation();
this.open(id);
},
bindApp: function (app) {
if (!app) return;
global.__exportIntelApp = app;
var self = this;
app.openEntity = function (id) { self.open(id); };
app.closeDrawer = function () { self.close(); };
},
wireModal: function () {
var modal = document.getElementById('ei-profile-modal');
if (!modal || modal._eiWired) return;
modal._eiWired = true;
var self = this;
modal.addEventListener('click', function (ev) { self.onModalClick(ev); }, true);
},
boot: function () {
var self = this;
this.wireModal();
document.addEventListener('click', function (ev) { self.onRowClick(ev); }, true);
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape') {
var modal = document.getElementById('ei-profile-modal');
if (modal && modal.classList.contains('is-open')) self.close();
}
});
var params = new URLSearchParams(global.location.search);
var openId = params.get('open');
function tryBind() {
var page = document.querySelector('.ei-page');
if (page && page._x_dataStack && page._x_dataStack[0]) {
self.bindApp(page._x_dataStack[0]);
if (openId) self.open(openId);
return true;
}
if (global.__exportIntelApp) {
self.bindApp(global.__exportIntelApp);
if (openId) self.open(openId);
return true;
}
return false;
}
if (!tryBind()) {
var n = 0;
var timer = setInterval(function () {
n++;
if (tryBind() || n > 120) clearInterval(timer);
}, 100);
}
document.addEventListener('alpine:initialized', function () {
tryBind();
});
},
};
global.ExportIntelProfile = Profile;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { Profile.boot(); });
} else {
Profile.boot();
}
})(window);
+435 -52
View File
@@ -5,6 +5,12 @@ function exportIntelApp() {
tabIndicatorH: 40, tabIndicatorH: 40,
busy: false, busy: false,
syncMsg: '', syncMsg: '',
syncMonitor: null,
syncMonitorOpen: true,
syncMonitorPoll: null,
googleMapsKey: '',
googleMapsKeySet: false,
googleKeySaving: false,
stats: {}, stats: {},
regions: [], regions: [],
territories: [], territories: [],
@@ -27,6 +33,7 @@ function exportIntelApp() {
mapHalalMin: 55, mapHalalMin: 55,
mapError: '', mapError: '',
mapLoading: false, mapLoading: false,
tabMapLoading: false,
mapTruncated: false, mapTruncated: false,
halalTopMarkets: [], halalTopMarkets: [],
halalTopEntities: [], halalTopEntities: [],
@@ -37,6 +44,11 @@ function exportIntelApp() {
filterHasEmail: '', filterHasEmail: '',
filterFavoritesOnly: false, filterFavoritesOnly: false,
filterCrmStatus: '', filterCrmStatus: '',
filterConfidenceMin: '',
filterCity: '',
filterTenderStatus: '',
filterGovCategory: '',
_cityTimer: null,
selectedIds: [], selectedIds: [],
crmPipeline: [], crmPipeline: [],
crmModalOpen: false, crmModalOpen: false,
@@ -58,12 +70,65 @@ function exportIntelApp() {
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' }, { id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
], ],
TYPE_GROUPS: {
b2b: ['distributor', 'wholesaler', 'importer', 'logistics', 'cold_storage', 'port_agent'],
horeca: ['restaurant', 'doner_shoarma', 'butcher', 'foodservice'],
caterer: ['contract_caterer'],
},
TYPE_CHIP_EXPAND: {
distributor: 'b2b',
importer: 'b2b',
logistics: 'b2b',
restaurant: 'horeca',
doner_shoarma: 'horeca',
butcher: 'horeca',
foodservice: 'horeca',
},
expandEntityTypes(typeOrList) {
var self = this;
var raw = Array.isArray(typeOrList) ? typeOrList : [typeOrList];
var out = [];
raw.forEach(function (v) {
if (!v) return;
var key = String(v).trim();
var group = self.TYPE_CHIP_EXPAND[key];
if (group && self.TYPE_GROUPS[group]) {
out = out.concat(self.TYPE_GROUPS[group]);
} else {
out.push(key);
}
});
return out.filter(function (v, i, a) { return a.indexOf(v) === i; });
},
mapTypesParam() {
if (this.entityTypeFilter) {
var expanded = this.expandEntityTypes(this.entityTypeFilter);
if (expanded.length === 1) return { entity_type: expanded[0] };
if (expanded.length) return { entity_types: expanded.join(',') };
}
if (this.mapTypesSelected.length) {
var all = this.expandEntityTypes(this.mapTypesSelected);
if (all.length === 1) return { entity_type: all[0] };
if (all.length) return { entity_types: all.join(',') };
}
return {};
},
shouldAutoFitMap(featCount) {
return !!(this.country || this.region || this.q || this.entityTypeFilter ||
this.mapTypesSelected.length || this.mapHalalMode || this.filterFavoritesOnly ||
this.filterCrmStatus || (featCount > 0 && featCount <= 800));
},
mapTypeChips: [ mapTypeChips: [
{ v: 'distributor', l: 'Distributeur', icon: '📦' }, { v: 'distributor', l: 'B2B / distri', icon: '📦' },
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' }, { v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
{ v: 'importer', l: 'Importeur', icon: '🚢' }, { v: 'importer', l: 'Importeur', icon: '🚢' },
{ v: 'logistics', l: 'Logistiek', icon: '🚚' }, { v: 'logistics', l: 'Logistiek', icon: '🚚' },
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' }, { v: 'restaurant', l: 'Horeca', icon: '🍽️' },
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' }, { v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
{ v: 'butcher', l: 'Slager', icon: '🥩' }, { v: 'butcher', l: 'Slager', icon: '🥩' },
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' }, { v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
@@ -75,11 +140,13 @@ function exportIntelApp() {
var t = params.get('tab'); var t = params.get('tab');
if (t) this.tab = t; if (t) this.tab = t;
var self = this; var self = this;
window.__exportIntelApp = self;
document.addEventListener('export-intel:open-entity', function (ev) { document.addEventListener('export-intel:open-entity', function (ev) {
if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id); if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id);
}); });
await this.loadMeta(); await this.loadMeta();
await this.refresh(); await this.refresh();
this.startSyncMonitor(true);
this.$nextTick(() => this.updateTabIndicator()); this.$nextTick(() => this.updateTabIndicator());
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.target.matches('input, textarea, select')) return; if (e.target.matches('input, textarea, select')) return;
@@ -94,6 +161,7 @@ function exportIntelApp() {
async loadMeta() { async loadMeta() {
try { try {
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json()); this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
await this.loadGoogleSettings();
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json()); this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -141,6 +209,10 @@ function exportIntelApp() {
this.filterHasEmail = ''; this.filterHasEmail = '';
this.filterFavoritesOnly = false; this.filterFavoritesOnly = false;
this.filterCrmStatus = ''; this.filterCrmStatus = '';
this.filterConfidenceMin = '';
this.filterCity = '';
this.filterTenderStatus = '';
this.filterGovCategory = '';
this.selectedIds = []; this.selectedIds = [];
this.applyMapFilters(); this.applyMapFilters();
}, },
@@ -153,6 +225,14 @@ function exportIntelApp() {
return this.tab === 'map'; return this.tab === 'map';
}, },
mapTypeCount(v) {
var geo = (this.stats && this.stats.entity_types_geo) || {};
var expanded = this.expandEntityTypes(v);
var n = 0;
expanded.forEach(function (t) { n += Number(geo[t] || 0); });
return n;
},
isMapTypeActive(v) { isMapTypeActive(v) {
return this.mapTypesSelected.indexOf(v) >= 0; return this.mapTypesSelected.indexOf(v) >= 0;
}, },
@@ -170,14 +250,6 @@ function exportIntelApp() {
this.applyMapFilters(); this.applyMapFilters();
}, },
mapTypesParam() {
if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter };
if (this.mapTypesSelected.length) {
return { entity_types: this.mapTypesSelected.join(',') };
}
return {};
},
applyMapFilters() { applyMapFilters() {
if (this.tab !== 'map') { if (this.tab !== 'map') {
this.loadTabData(); this.loadTabData();
@@ -187,11 +259,31 @@ function exportIntelApp() {
}, },
showEmailFilter() { showEmailFilter() {
return this.tab === 'contacts'; return ['map', 'distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
}, },
showCrmFilter() { showCrmFilter() {
return this.tab === 'map' || this.tab === 'contacts'; return ['map', 'distributors', 'customers', 'caterers', 'contacts', 'pipeline'].indexOf(this.tab) >= 0;
},
showCollectionFilters() {
return this.showCrmFilter() || this.showFavoritesFilter();
},
showFavoritesFilter() {
return ['map', 'distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showConfidenceFilter() {
return this.tab === 'distributors';
},
showCityFilter() {
return ['distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0;
},
showContextFilters() {
return this.showEntityTypeFilter() || this.showEmailFilter() || this.showConfidenceFilter() || this.showCityFilter() || this.tab === 'tenders' || this.tab === 'gov';
}, },
showSelectionBar() { showSelectionBar() {
@@ -210,6 +302,75 @@ function exportIntelApp() {
return 'Bezig…'; return 'Bezig…';
}, },
displayEntities() {
var list = this.entities || [];
if (this.filterHasEmail === "yes") {
list = list.filter(function (e) {
return e.email || e.primary_email || e.phone || e.primary_phone || e.has_contact;
});
} else if (this.filterHasEmail === "no") {
list = list.filter(function (e) {
return !e.email && !e.primary_email && !e.phone && !e.primary_phone && !e.has_contact;
});
}
if (this.filterConfidenceMin) {
var min = Number(this.filterConfidenceMin);
list = list.filter(function (e) { return (e.confidence || 0) >= min; });
}
if (this.filterCity && this.filterCity.trim()) {
var c = this.filterCity.trim().toLowerCase();
list = list.filter(function (e) { return (e.city || "").toLowerCase().includes(c); });
}
return list;
},
displayCount() {
if (this.tab === "contacts") return (this.contacts || []).length;
return this.displayEntities().length;
},
displayPipeline() {
var list = this.crmPipeline || [];
if (this.q && this.q.trim()) {
var ql = this.q.trim().toLowerCase();
list = list.filter(function (p) { return (p.name || "").toLowerCase().includes(ql); });
}
return list;
},
hasActiveFilters() {
return !!(this.q || this.entityTypeFilter || this.mapTypesSelected.length || this.mapHalalMode ||
this.filterHasEmail || this.filterFavoritesOnly || this.filterCrmStatus ||
this.filterConfidenceMin || this.filterCity || this.filterTenderStatus || this.filterGovCategory);
},
applyEntityFilters() {
if (this.tab === "map") this.applyMapFilters();
else {
this.loadTabData();
}
},
onCityFilterInput() {
clearTimeout(this._cityTimer);
},
onPipelineFilter() {
clearTimeout(this._searchTimer);
},
applyGovFilters() {
this.loadTabData();
},
visibleEntities() {
if (this.tab === "map" && this.mapHalalMode && this.halalTopEntities.length) {
return this.halalTopEntities;
}
return this.displayEntities();
},
entityTypeOptions() { entityTypeOptions() {
if (this.tab === 'distributors') { if (this.tab === 'distributors') {
return [ return [
@@ -232,6 +393,15 @@ function exportIntelApp() {
if (this.tab === 'caterers') { if (this.tab === 'caterers') {
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }]; return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
} }
if (this.tab === 'pipeline') {
return [
{ v: '', l: 'Alle types' },
{ v: 'distributor', l: 'Distributeur' },
{ v: 'wholesaler', l: 'Groothandel' },
{ v: 'contract_caterer', l: 'Cateraar' },
{ v: 'restaurant', l: 'Restaurant' },
];
}
if (this.tab === 'contacts') { if (this.tab === 'contacts') {
return [ return [
{ v: '', l: 'Alle types' }, { v: '', l: 'Alle types' },
@@ -253,6 +423,91 @@ function exportIntelApp() {
]; ];
}, },
tabMapId() {
var tabs = ['distributors', 'customers', 'caterers', 'contacts', 'pipeline'];
if (tabs.indexOf(this.tab) < 0) return null;
return 'ei-map-' + this.tab;
},
entitiesToGeojson(list) {
var features = [];
(list || []).forEach(function (e) {
if (e.lat == null || e.lon == null) return;
features.push({
type: 'Feature',
geometry: { type: 'Point', coordinates: [Number(e.lon), Number(e.lat)] },
properties: {
id: e.id,
name: e.name,
entity_type: e.entity_type,
country_iso2: e.country_iso2,
city: e.city,
address_line: e.address_line,
email: e.email || e.primary_email,
phone: e.phone || e.primary_phone,
website: e.website,
confidence: e.confidence,
halal_score: e.halal_score,
halal_tier: e.halal_tier,
contact_count: e.contact_count,
pipeline_stage: e.pipeline_stage,
volume_band: e.volume_band,
},
});
});
return { type: 'FeatureCollection', features: features };
},
async fetchTabMapBundle() {
var url = '/api/export-intel/map/bundle';
var qs = [];
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
if (this.region) qs.push('region=' + encodeURIComponent(this.region));
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
if (this.filterFavoritesOnly) qs.push('favorite_only=true');
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
var tabTypes = null;
if (this.tab === 'distributors') tabTypes = this.TYPE_GROUPS.b2b;
else if (this.tab === 'customers') tabTypes = this.TYPE_GROUPS.horeca;
else if (this.tab === 'caterers') tabTypes = this.TYPE_GROUPS.caterer;
if (this.entityTypeFilter) {
var ef = this.expandEntityTypes(this.entityTypeFilter);
tabTypes = ef;
}
if (tabTypes) {
if (tabTypes.length === 1) qs.push('entity_type=' + encodeURIComponent(tabTypes[0]));
else qs.push('entity_types=' + encodeURIComponent(tabTypes.join(',')));
}
if (this.tab === 'pipeline') qs.push('crm_linked=true');
if (qs.length) url += '?' + qs.join('&');
var bundle = await fetch(url).then(function (r) { return r.json(); });
this._lastMapBundleMeta = bundle.meta || {};
return bundle.entities || { type: 'FeatureCollection', features: [] };
},
async loadTabMap() {
var cid = this.tabMapId();
if (!cid || !document.getElementById(cid)) return;
if (!window.L || !window.ExportIntelMap) return;
this.tabMapLoading = true;
try {
var geo = await this.fetchTabMapBundle();
var featN = (geo.features || []).length;
var self = this;
await this.$nextTick();
window.ExportIntelMap.init(cid);
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); }, cid);
window.ExportIntelMap.setHalalMode(false, cid);
window.ExportIntelMap.setFeatures(geo, { autoFit: this.shouldAutoFitMap(featN) }, cid);
if (window.ExportIntelMap.invalidateAll) window.ExportIntelMap.invalidateAll();
} catch (e) {
console.error(e);
}
this.tabMapLoading = false;
},
async loadTabData(opts) { async loadTabData(opts) {
opts = opts || {}; opts = opts || {};
var base = '/api/export-intel'; var base = '/api/export-intel';
@@ -260,20 +515,18 @@ function exportIntelApp() {
if (this.tab === 'map') { if (this.tab === 'map') {
return; return;
} else if (this.tab === 'distributors') { } else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics'; var types = this.expandEntityTypes(this.entityTypeFilter || 'b2b').join(',');
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : ''; var r = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
this.entities = r.items || []; this.entities = r.items || [];
this.resultCount = r.total != null ? r.total : this.entities.length; this.resultCount = r.total != null ? r.total : this.entities.length;
} else if (this.tab === 'customers') { } else if (this.tab === 'customers') {
var types2 = 'restaurant,doner_shoarma,butcher,foodservice'; var types2 = this.expandEntityTypes(this.entityTypeFilter || 'horeca').join(',');
var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : ''; var r2 = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types2) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json());
this.entities = r2.items || []; this.entities = r2.items || [];
this.resultCount = r2.total != null ? r2.total : this.entities.length; this.resultCount = r2.total != null ? r2.total : this.entities.length;
} else if (this.tab === 'caterers') { } else if (this.tab === 'caterers') {
var et3 = this.entityTypeFilter || 'contract_caterer'; var types3 = this.expandEntityTypes(this.entityTypeFilter || 'contract_caterer').join(',');
var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json()); var r3 = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types3) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
this.entities = r3.items || []; this.entities = r3.items || [];
this.resultCount = r3.total != null ? r3.total : this.entities.length; this.resultCount = r3.total != null ? r3.total : this.entities.length;
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json()); this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
@@ -297,6 +550,9 @@ function exportIntelApp() {
var ql = this.q.trim().toLowerCase(); var ql = this.q.trim().toLowerCase();
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql)); items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
} }
if (this.filterTenderStatus) {
items = items.filter((t) => (t.status || '').toLowerCase() === this.filterTenderStatus);
}
this.tenders = items; this.tenders = items;
this.resultCount = items.length; this.resultCount = items.length;
} else if (this.tab === 'gov') { } else if (this.tab === 'gov') {
@@ -313,6 +569,9 @@ function exportIntelApp() {
this.crmPipeline = pipe.items || []; this.crmPipeline = pipe.items || [];
this.resultCount = this.crmPipeline.length; this.resultCount = this.crmPipeline.length;
} }
if (['distributors','customers','caterers','contacts','pipeline'].indexOf(this.tab) >= 0) {
this.$nextTick(() => this.loadTabMap());
}
}, },
async loadMap() { async loadMap() {
@@ -355,16 +614,25 @@ function exportIntelApp() {
if (!document.getElementById('ei-map')) { this.mapLoading = false; return; } if (!document.getElementById('ei-map')) { this.mapLoading = false; return; }
var self = this; var self = this;
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); }, 'ei-map');
window.ExportIntelMap.setHalalMode(this.mapHalalMode); window.ExportIntelMap.setHalalMode(this.mapHalalMode, 'ei-map');
var autoFit = !!(this.country || this.region); var feats = (bundle.entities && bundle.entities.features) || [];
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit }); var autoFit = this.shouldAutoFitMap(feats.length);
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit }, 'ei-map');
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, 'ei-map');
} }
} }
this.mapLoading = false; this.mapLoading = false;
var self2 = this;
this.$nextTick(function () {
requestAnimationFrame(function () {
if (window.ExportIntelMap && window.ExportIntelMap.invalidateAll) {
window.ExportIntelMap.invalidateAll();
}
});
});
}, },
setTab(id, el) { setTab(id, el) {
@@ -378,6 +646,9 @@ function exportIntelApp() {
history.replaceState(null, '', u.pathname + u.search); history.replaceState(null, '', u.pathname + u.search);
this.loadTabData(); this.loadTabData();
if (id === 'map') this.$nextTick(() => this.loadMap()); if (id === 'map') this.$nextTick(() => this.loadMap());
else if (['distributors','customers','caterers','contacts','pipeline'].indexOf(id) >= 0) {
this.$nextTick(() => this.loadTabMap());
}
this.$nextTick(() => this.updateTabIndicator(el)); this.$nextTick(() => this.updateTabIndicator(el));
}, },
@@ -440,34 +711,20 @@ function exportIntelApp() {
this.listFocusDetail = null; this.listFocusDetail = null;
}, },
async openEntity(id) { openEntity(id) {
if (!id) return; if (!id) return;
this.drawerLoading = true; if (window.ExportIntelProfile && typeof window.ExportIntelProfile.open === 'function') {
this.drawerOpen = true; window.ExportIntelProfile.open(id);
try { return;
if (this.listFocusDetail && this.listFocusDetail.id === id) {
this.selected = this.listFocusDetail;
} else {
this.selected = await this.fetchEntityDetail(id);
this.listFocusId = id;
this.listFocusDetail = this.selected;
}
if (window.ExportIntelMap && this.selected.lat && this.selected.lon) {
window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14);
window.ExportIntelMap.highlightEntity(this.selected.id);
}
if (window.ExportIntelMap && window.ExportIntelMap.closePopup) {
window.ExportIntelMap.closePopup();
}
} catch (e) {
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
this.drawerOpen = false;
this.selected = null;
} }
this.drawerLoading = false; this.syncMsg = 'Profiel-module niet geladen — ververs de pagina';
}, },
closeDrawer() { closeDrawer() {
if (window.ExportIntelProfile && typeof window.ExportIntelProfile.close === 'function') {
window.ExportIntelProfile.close();
return;
}
this.drawerOpen = false; this.drawerOpen = false;
this.selected = null; this.selected = null;
this.drawerLoading = false; this.drawerLoading = false;
@@ -528,7 +785,7 @@ function exportIntelApp() {
async syncKind(kind) { async syncKind(kind) {
if (kind === 'world') { if (kind === 'world') {
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return; if (!confirm('Wereld-sync: alle regio\'s (Europa, MENA, Afrika, Amerika, Asia, Oceanië). OSM + horeca + contacten — kan uren duren. Doorgaan?')) return;
} }
if (kind === 'region' && !this.region) { if (kind === 'region' && !this.region) {
this.syncMsg = 'Selecteer eerst een regio in de filterbalk'; this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
@@ -542,7 +799,12 @@ function exportIntelApp() {
region_code: kind === 'region' ? this.region : (this.region || null), region_code: kind === 'region' ? this.region : (this.region || null),
max_priority: 2, max_priority: 2,
}; };
if (kind === 'world') body = { max_priority: 2 }; if (kind === 'world') body = { max_priority: 3 };
if (kind === 'places' && !this.country && !this.region) {
this.syncMsg = 'Selecteer een land of regio voor Google Places';
this.busy = false;
return;
}
var endpoint = kind === 'region' ? 'region' : kind; var endpoint = kind === 'region' ? 'region' : kind;
var r = await fetch('/api/export-intel/sync/' + endpoint, { var r = await fetch('/api/export-intel/sync/' + endpoint, {
method: 'POST', method: 'POST',
@@ -604,7 +866,128 @@ function exportIntelApp() {
}, },
typeLabel(t) { typeLabel(t) {
return (t || '').replace(/_/g, ' '); var labels = {
distributor: 'Distrib.',
wholesaler: 'Grooth.',
contract_caterer: 'Cateraar',
end_customer: 'Eindkl.',
hotel: 'Hotel',
restaurant: 'Rest.',
retailer: 'Retail',
importer: 'Import',
};
return labels[t] || (t || '').replace(/_/g, ' ');
},
filteredTerritories() {
var list = (this.territories || []).filter(function (t) {
return !this.region || t.region_code === this.region;
}, this);
var seen = {};
var out = [];
list.forEach(function (t) {
var iso = t.country_iso2;
if (!iso || seen[iso]) return;
seen[iso] = true;
out.push(t);
});
out.sort(function (a, b) {
return (a.name_nl || a.country_iso2 || '').localeCompare(b.name_nl || b.country_iso2 || '', 'nl');
});
return out;
},
countryEntityCount(iso2) {
if (!iso2) return 0;
var cc = (this.stats && this.stats.country_entity_counts) || {};
return Number(cc[iso2] || 0);
},
countryOptionLabel(t) {
if (!t) return '';
var n = this.countryEntityCount(t.country_iso2);
return n > 0 ? (t.name_nl + ' (' + n + ')') : t.name_nl;
},
async loadGoogleSettings() {
try {
var s = await fetch('/api/export-intel/settings').then(function (r) { return r.json(); });
this.googleMapsKeySet = !!(s && s.google_maps_api_key_set);
this.googleMapsKey = '';
} catch (e) { console.error(e); }
},
async saveGoogleMapsKey() {
if (!this.googleMapsKey || !this.googleMapsKey.trim()) return;
this.googleKeySaving = true;
try {
await fetch('/api/export-intel/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ google_maps_api_key: this.googleMapsKey.trim() }),
});
this.googleMapsKey = '';
this.googleMapsKeySet = true;
this.syncMsg = 'Google Places API-key opgeslagen';
} catch (e) {
this.syncMsg = 'API-key opslaan mislukt';
}
this.googleKeySaving = false;
},
startSyncMonitor(silent) {
var self = this;
if (this.syncMonitorPoll) return;
this.pollSyncStatus();
this.syncMonitorPoll = setInterval(function () { self.pollSyncStatus(); }, 3000);
},
stopSyncMonitor() {
if (this.syncMonitorPoll) {
clearInterval(this.syncMonitorPoll);
this.syncMonitorPoll = null;
}
},
async pollSyncStatus() {
try {
var s = await fetch('/api/export-intel/sync/status').then(function (r) { return r.json(); });
this.syncMonitor = s;
if (s.any_running && s.active_jobs && s.active_jobs.length) {
var j = s.active_jobs[0];
this.syncMsg = (j.message || j.label || 'Sync bezig') + (j.percent ? ' · ' + j.percent + '%' : '');
}
} catch (e) {
console.error(e);
}
},
toggleSyncMonitor() {
this.syncMonitorOpen = !this.syncMonitorOpen;
},
syncMonitorPercent() {
var jobs = (this.syncMonitor && this.syncMonitor.active_jobs) || [];
if (!jobs.length) return 0;
return jobs[0].percent || 0;
},
formatSyncTime(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
return d.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
syncEventLabel(ev) {
if (!ev) return '';
var parts = [ev.event || ''];
if (ev.message) parts.push(ev.message);
else if (ev.country) parts.push(ev.country);
if (ev.step) parts.push('(' + ev.step + ')');
return parts.filter(Boolean).join(' · ');
}, },
territoryName(iso2) { territoryName(iso2) {
@@ -644,7 +1027,7 @@ function exportIntelApp() {
} }
return; return;
} }
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities; var list = this.visibleEntities();
if (!list.length) return; if (!list.length) return;
var allSelected = list.every((e) => this.isSelected(e.id)); var allSelected = list.every((e) => this.isSelected(e.id));
if (allSelected) { if (allSelected) {
+1 -1
View File
@@ -24,7 +24,7 @@
<script defer src="/static/js/alpine.min.js?v=2"></script> <script defer src="/static/js/alpine.min.js?v=2"></script>
<script defer src="/static/js/htmx.min.js?v=2"></script> <script defer src="/static/js/htmx.min.js?v=2"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=8" /> <link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=10" />
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" /> <link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" /> <link rel="stylesheet" href="/static/css/herman-assistant.css?v=1" />
</head> </head>
File diff suppressed because it is too large Load Diff
+29 -8
View File
@@ -119,27 +119,48 @@
<div class="settings-panel-head"> <div class="settings-panel-head">
<div> <div>
<h2>AI / LLM providers</h2> <h2>AI / LLM providers</h2>
<p class="page-tip">Koppel cloud API's (DeepSeek, Gemini, Groq, OpenRouter) naast lokale Ollama. Standaard provider wordt gebruikt in chat, Herman en documenten.</p> <p class="page-tip">Cloud LLM naast lokale Ollama. Standaard provider = Herman, briefing, documenten.</p>
</div> </div>
<button type="button" class="btn btn-primary" @click="openForm()">+ Nieuwe provider</button> <button type="button" class="btn btn-primary" @click="openForm()">+ Nieuwe provider</button>
</div> </div>
<div class="llm-hero-card">
<div class="llm-hero-body">
<div class="llm-hero-badge">Aanbevolen</div>
<h3>OpenRouter · DeepSeek V4 Flash</h3>
<p class="llm-hero-lead">
Eén API key voor Herman, briefing en document-chat.
Model: <code>deepseek/deepseek-v4-flash</code>
</p>
<ol class="llm-hero-steps">
<li>Key op <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a></li>
<li><strong>Configureren</strong> → API key plakken → Opslaan → Test</li>
<li><strong>Maak standaard</strong> op de provider-kaart hieronder</li>
</ol>
</div>
<button type="button" class="btn btn-primary" @click="quickAddOpenRouter()">Configureren</button>
</div>
<div class="settings-active-banner" x-show="defaultProvider" x-cloak> <div class="settings-active-banner" x-show="defaultProvider" x-cloak>
<span class="badge badge-positive">Standaard</span> <span class="badge badge-positive">Standaard</span>
<span x-text="defaultProvider ? defaultProvider.label + ' · ' + defaultProvider.model : ''"></span> <span x-text="defaultProvider ? defaultProvider.label + ' · ' + defaultProvider.model : ''"></span>
</div> </div>
<div class="llm-preset-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:0.65rem;margin-bottom:1.25rem"> <h3 class="llm-section-title">Snel toevoegen</h3>
<p class="llm-section-sub">Klik een preset — daarna API key invullen in het formulier.</p>
<div class="llm-preset-grid">
<template x-for="pr in presets" :key="pr.id"> <template x-for="pr in presets" :key="pr.id">
<div class="perm-card" style="cursor:pointer" @click="quickAdd(pr)"> <button type="button" class="llm-preset-card" @click="quickAdd(pr)">
<strong x-text="pr.label"></strong> <span class="llm-preset-name" x-text="pr.label"></span>
<p class="page-tip" style="margin:0.35rem 0" x-text="pr.hint"></p> <span class="llm-preset-hint" x-text="pr.hint"></span>
<small x-text="(pr.models||[]).slice(0,2).join(', ')"></small> <span class="llm-preset-models" x-text="(pr.models||[]).slice(0,2).join(' · ')"></span>
</div> </button>
</template> </template>
</div> </div>
<div class="email-account-list"> <h3 class="llm-section-title">Geconfigureerde providers</h3>
<div class="email-account-list llm-provider-list">
<template x-for="p in providers" :key="p.id"> <template x-for="p in providers" :key="p.id">
<article class="email-account-card" :class="{ 'is-active': p.is_default }"> <article class="email-account-card" :class="{ 'is-active': p.is_default }">
<div class="email-account-card-head"> <div class="email-account-card-head">
+7
View File
@@ -37,6 +37,8 @@ services:
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-} SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY:-}
GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY:-}
ports: ports:
- "8700:8700" - "8700:8700"
volumes: volumes:
@@ -63,6 +65,11 @@ services:
BROWSER_AGENT_URL: http://browser-agent:7790 BROWSER_AGENT_URL: http://browser-agent:7790
COMFYUI_URL: http://10.4.7.18:8188 COMFYUI_URL: http://10.4.7.18:8188
HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090 HERMAN_ORCHESTRATOR_URL: http://10.4.7.19:8090
# OpenRouter — vul later in (.env of Instellingen → AI / LLM)
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_MODEL: ${OPENROUTER_MODEL:-deepseek/deepseek-v4-flash}
OPENROUTER_BASE_URL: ${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HERMAN_LLM_BACKEND: ${HERMAN_LLM_BACKEND:-auto}
HERMES_BUILD_URL: http://10.4.7.27:8798 HERMES_BUILD_URL: http://10.4.7.27:8798
HERMES_PREVIEW_BASE: http://10.4.7.27:8080 HERMES_PREVIEW_BASE: http://10.4.7.27:8080
NAS_CLIENTS_ROOT: /data/nas-clients NAS_CLIENTS_ROOT: /data/nas-clients
+14
View File
@@ -0,0 +1,14 @@
-- OpenRouter + DeepSeek V4 Flash placeholder (API key via Settings UI)
INSERT INTO llm_providers (label, provider_type, api_base_url, api_key, model, is_active, is_default, extra_config)
SELECT
'OpenRouter · DeepSeek V4 Flash',
'openrouter',
'https://openrouter.ai/api/v1',
'',
'deepseek/deepseek-v4-flash',
FALSE,
FALSE,
'{"referer":"https://foodlinkk.local","title":"Foodlinkk Command Center"}'::jsonb
WHERE NOT EXISTS (
SELECT 1 FROM llm_providers WHERE provider_type = 'openrouter'
);
+462 -9
View File
@@ -6,6 +6,7 @@ import io
import hashlib import hashlib
import json import json
import time import time
from collections import defaultdict
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
@@ -38,6 +39,241 @@ def _halal_reason_labels(reasons: list[str]) -> list[str]:
return out return out
def _normalize_url(url: Any) -> Optional[str]:
if url is None:
return None
s = str(url).strip()
if not s or s.lower() in ("null", "none", "-", "—"):
return None
if s.startswith("mailto:") or s.startswith("tel:"):
return s
if not s.startswith(("http://", "https://")):
s = "https://" + s.lstrip("/")
return s
def _display_url(url: str) -> str:
return url.replace("https://", "").replace("http://", "").rstrip("/")[:96]
def _collect_websites(
row: dict[str, Any],
contacts: Optional[list[dict[str, Any]]] = None,
brand_website: Optional[str] = None,
) -> list[dict[str, str]]:
seen: set[str] = set()
out: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
href = _normalize_url(raw)
if not href:
return
key = href.lower().rstrip("/")
if key in seen:
return
seen.add(key)
out.append({
"label": (label or "Website")[:64],
"url": href,
"display": _display_url(href),
})
add("Organisatie", row.get("website"))
add("Brand", brand_website)
for c in contacts or []:
role = (c.get("role") or c.get("name") or "Contact").strip()
add(role, c.get("website_contact_url"))
if c.get("linkedin_url"):
add("LinkedIn", c.get("linkedin_url"))
src = c.get("source_url")
if src and "linkedin.com" not in str(src).lower():
add("Bron", src)
return out
def _clean_str(val: Any) -> Optional[str]:
if val is None:
return None
s = str(val).strip()
if not s or s.lower() in ("null", "none", "-", "—", "n/a"):
return None
return s
def _dedupe_items(items: list[dict[str, str]], key: str = "value") -> list[dict[str, str]]:
seen: set[str] = set()
out: list[dict[str, str]] = []
for item in items:
val = _clean_str(item.get(key))
if not val:
continue
k = val.lower()
if k in seen:
continue
seen.add(k)
out.append({**item, key: val})
return out
def _collect_emails(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
val = _clean_str(raw)
if not val or "@" not in val:
return
items.append({"label": (label or "E-mail")[:64], "value": val, "href": f"mailto:{val}"})
add("Organisatie", row.get("email"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("email"))
alt = _clean_str(c.get("email_alt"))
if alt and alt != _clean_str(c.get("email")):
add(f"{who} (alt)", alt)
return _dedupe_items(items)
def _collect_phones(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any, kind: str = "tel") -> None:
val = _clean_str(raw)
if not val:
return
digits = "".join(ch for ch in val if ch.isdigit() or ch == "+")
href = f"https://wa.me/{digits.lstrip('+')}" if kind == "wa" and digits else f"tel:{val}"
items.append({"label": (label or "Telefoon")[:64], "value": val, "href": href, "kind": kind})
add("Organisatie", row.get("phone"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("phone"))
add(f"{who} mobiel", c.get("mobile"))
add(f"{who} WhatsApp", c.get("whatsapp"), "wa")
add(f"{who} fax", c.get("fax"))
return _dedupe_items(items)
def _collect_addresses(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, line: Any, city: Any = None, postal: Any = None) -> None:
parts = [_clean_str(line), _clean_str(postal), _clean_str(city)]
parts = [p for p in parts if p]
if not parts:
return
val = ", ".join(parts)
items.append({"label": (label or "Adres")[:64], "value": val})
add("Locatie", row.get("address_line"), row.get("city"), row.get("postal_code"))
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
add(who, c.get("address_line"), c.get("city"), c.get("postal_code"))
return _dedupe_items(items)
def _collect_social(row: dict[str, Any], contacts: Optional[list[dict[str, Any]]] = None) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
def add(label: str, raw: Any) -> None:
href = _normalize_url(raw)
if not href:
return
items.append({"label": label[:64], "value": _display_url(href), "href": href})
for c in contacts or []:
who = _clean_str(c.get("name")) or _clean_str(c.get("role")) or "Contact"
if c.get("linkedin_url"):
add(f"LinkedIn · {who}", c.get("linkedin_url"))
return _dedupe_items(items, key="href")
def _public_contacts(contacts: Optional[list[dict[str, Any]]], limit: int = 8) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for c in (contacts or [])[:limit]:
out.append({
"name": _clean_str(c.get("name")),
"role": _clean_str(c.get("role")),
"title": _clean_str(c.get("title")),
"department": _clean_str(c.get("department")),
"email": _clean_str(c.get("email")),
"email_alt": _clean_str(c.get("email_alt")),
"phone": _clean_str(c.get("phone")),
"mobile": _clean_str(c.get("mobile")),
"whatsapp": _clean_str(c.get("whatsapp")),
"fax": _clean_str(c.get("fax")),
"linkedin_url": _normalize_url(c.get("linkedin_url")),
"website": _normalize_url(c.get("website_contact_url")),
"is_primary": bool(c.get("is_primary")),
"source": _clean_str(c.get("source")),
})
return out
def _build_reach(
row: dict[str, Any],
contacts: Optional[list[dict[str, Any]]] = None,
brand_website: Optional[str] = None,
) -> dict[str, Any]:
lat, lon = row.get("lat"), row.get("lon")
maps_url = row.get("maps_url")
if not maps_url and lat is not None and lon is not None:
maps_url = f"https://www.google.com/maps?q={lat},{lon}"
place_id = _clean_str(row.get("google_place_id"))
if place_id and not maps_url:
maps_url = f"https://www.google.com/maps/search/?api=1&query_place_id={place_id}"
reach = {
"emails": _collect_emails(row, contacts),
"phones": _collect_phones(row, contacts),
"websites": _collect_websites(row, contacts, brand_website),
"addresses": _collect_addresses(row, contacts),
"social": _collect_social(row, contacts),
"maps_url": maps_url,
"google_place_id": place_id,
"postal_code": _clean_str(row.get("postal_code")),
"contact_people": _public_contacts(contacts),
}
reach["has_any"] = bool(
reach["emails"] or reach["phones"] or reach["websites"]
or reach["addresses"] or reach["social"] or reach["maps_url"]
)
return reach
def _map_contacts_batch(entity_ids: list[int]) -> dict[int, list[dict[str, Any]]]:
if not entity_ids:
return {}
rows = fetch_all(
"""
SELECT entity_id, role, name, title, department, email, email_alt,
phone, mobile, whatsapp, fax, linkedin_url, website_contact_url,
source_url, address_line, city, postal_code, is_primary, confidence, source
FROM export_entity_contacts
WHERE entity_id = ANY(%s)
ORDER BY is_primary DESC NULLS LAST, confidence DESC NULLS LAST, id
""",
(entity_ids,),
)
grouped: dict[int, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
grouped[int(row["entity_id"])].append(row)
return grouped
def _brand_websites(codes: list[str]) -> dict[str, Optional[str]]:
if not codes:
return {}
rows = fetch_all(
"SELECT code, website FROM export_caterer_brands WHERE code = ANY(%s)",
(codes,),
)
return {r["code"]: r.get("website") for r in rows}
def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[str, Any]: def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[str, Any]:
row["contacts"] = contacts row["contacts"] = contacts
row["contact_count"] = len(contacts) row["contact_count"] = len(contacts)
@@ -50,6 +286,15 @@ def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[
row["phone"] = phone row["phone"] = phone
primary = contacts[0] if contacts else None primary = contacts[0] if contacts else None
row["primary_contact"] = primary row["primary_contact"] = primary
brand_website = row.pop("brand_website", None)
row["reach"] = _build_reach(row, contacts, brand_website)
row["websites"] = row["reach"]["websites"]
if row["websites"] and not row.get("website"):
row["website"] = row["websites"][0]["url"]
if row["reach"]["emails"] and not row.get("email"):
row["email"] = row["reach"]["emails"][0]["value"]
if row["reach"]["phones"] and not row.get("phone"):
row["phone"] = row["reach"]["phones"][0]["value"]
h_score, h_reasons = score_entity(row) h_score, h_reasons = score_entity(row)
row["halal_score"] = h_score row["halal_score"] = h_score
row["halal_reasons"] = h_reasons row["halal_reasons"] = h_reasons
@@ -87,7 +332,7 @@ def _map_entity_limit(
crm_linked: Optional[bool], crm_linked: Optional[bool],
) -> int: ) -> int:
if q or entity_type or entity_types or halal_min is not None or favorite_only or crm_linked is not None: if q or entity_type or entity_types or halal_min is not None or favorite_only or crm_linked is not None:
return 4000 return 8000
if country: if country:
return 12000 return 12000
if region: if region:
@@ -118,6 +363,49 @@ DISTRIBUTOR_TYPES = ("distributor", "wholesaler", "importer", "logistics", "cold
CUSTOMER_TYPES = ("restaurant", "doner_shoarma", "butcher", "foodservice") CUSTOMER_TYPES = ("restaurant", "doner_shoarma", "butcher", "foodservice")
CATERER_TYPES = ("contract_caterer") CATERER_TYPES = ("contract_caterer")
TYPE_FILTER_ALIASES: dict[str, tuple[str, ...]] = {
"b2b": DISTRIBUTOR_TYPES,
"distributor": DISTRIBUTOR_TYPES,
"importer": DISTRIBUTOR_TYPES,
"logistics": DISTRIBUTOR_TYPES,
"horeca": CUSTOMER_TYPES,
"restaurant": CUSTOMER_TYPES,
"doner_shoarma": CUSTOMER_TYPES,
"butcher": CUSTOMER_TYPES,
"foodservice": CUSTOMER_TYPES,
"caterer": CATERER_TYPES,
"contract_caterer": CATERER_TYPES,
}
def _expand_entity_types(
entity_type: Optional[str],
entity_types: Optional[str],
) -> tuple[Optional[str], Optional[str]]:
expanded: list[str] = []
if entity_types:
for raw in entity_types.split(","):
key = raw.strip()
if not key:
continue
alias = TYPE_FILTER_ALIASES.get(key)
expanded.extend(alias if alias else (key,))
if entity_type:
key = entity_type.strip()
alias = TYPE_FILTER_ALIASES.get(key)
expanded.extend(alias if alias else (key,))
if not expanded:
return entity_type, entity_types
deduped: list[str] = []
seen: set[str] = set()
for key in expanded:
if key not in seen:
seen.add(key)
deduped.append(key)
if len(deduped) == 1:
return deduped[0], None
return None, ",".join(deduped)
class ContactCreateIn(BaseModel): class ContactCreateIn(BaseModel):
role: str = "general" role: str = "general"
@@ -186,6 +474,7 @@ def _entity_filters(
clauses.append("t.region_code = %s") clauses.append("t.region_code = %s")
params.append(region) params.append(region)
clauses.append("e.territory_code = t.code") clauses.append("e.territory_code = t.code")
entity_type, entity_types = _expand_entity_types(entity_type, entity_types)
if entity_type: if entity_type:
clauses.append("e.entity_type = %s") clauses.append("e.entity_type = %s")
params.append(entity_type) params.append(entity_type)
@@ -228,7 +517,24 @@ def export_stats(
return int(row["n"] or 0) return int(row["n"] or 0)
entities_n = count("") entities_n = count("")
distributors_n = count("entity_type IN ('distributor','wholesaler','importer','logistics')") type_geo_rows = fetch_all(
"""
SELECT entity_type, COUNT(*) AS n
FROM export_market_entities
WHERE lat IS NOT NULL AND lon IS NOT NULL
GROUP BY entity_type
"""
)
entity_types_geo = {r["entity_type"]: int(r["n"] or 0) for r in type_geo_rows}
country_count_rows = fetch_all(
"""
SELECT country_iso2, COUNT(*) AS n
FROM export_market_entities
GROUP BY country_iso2
"""
)
country_entity_counts = {r["country_iso2"]: int(r["n"] or 0) for r in country_count_rows}
distributors_n = count("entity_type IN ('distributor','wholesaler','importer','logistics','cold_storage','port_agent')")
caterers_n = count("entity_type = 'contract_caterer'") caterers_n = count("entity_type = 'contract_caterer'")
contacts = fetch_one("SELECT COUNT(*) AS n FROM export_entity_contacts") contacts = fetch_one("SELECT COUNT(*) AS n FROM export_entity_contacts")
with_email = fetch_one( with_email = fetch_one(
@@ -259,6 +565,8 @@ def export_stats(
"crm_linked": int(crm_row["n"] or 0) if crm_row else 0, "crm_linked": int(crm_row["n"] or 0) if crm_row else 0,
"phase": "B", "phase": "B",
"enrichment_status": "active", "enrichment_status": "active",
"entity_types_geo": entity_types_geo,
"country_entity_counts": country_entity_counts,
} }
@@ -267,16 +575,31 @@ def list_regions() -> list[dict[str, Any]]:
return fetch_all("SELECT * FROM export_regions ORDER BY sort_order, code") return fetch_all("SELECT * FROM export_regions ORDER BY sort_order, code")
def _dedupe_territories(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
out: list[dict[str, Any]] = []
for row in rows:
iso = row.get("country_iso2")
if iso and iso in seen:
continue
if iso:
seen.add(iso)
out.append(row)
return out
@router.get("/territories") @router.get("/territories")
def list_territories(region: Optional[str] = Query(None)) -> list[dict[str, Any]]: def list_territories(region: Optional[str] = Query(None)) -> list[dict[str, Any]]:
if region: if region:
return fetch_all( rows = fetch_all(
"SELECT * FROM export_territories WHERE is_active = TRUE AND region_code = %s ORDER BY sync_priority, name_nl", "SELECT * FROM export_territories WHERE is_active = TRUE AND region_code = %s ORDER BY sync_priority, name_nl",
(region,), (region,),
) )
return fetch_all( else:
"SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl" rows = fetch_all(
) "SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl"
)
return _dedupe_territories(rows)
@router.get("/entities") @router.get("/entities")
@@ -328,10 +651,12 @@ def list_entities(
def get_entity(entity_id: int) -> dict[str, Any]: def get_entity(entity_id: int) -> dict[str, Any]:
row = fetch_one( row = fetch_one(
""" """
SELECT e.*, cl.name AS crm_client_name, d.title AS crm_deal_title, d.stage AS crm_deal_stage SELECT e.*, cl.name AS crm_client_name, d.title AS crm_deal_title, d.stage AS crm_deal_stage,
b.website AS brand_website
FROM export_market_entities e FROM export_market_entities e
LEFT JOIN clients cl ON cl.id = e.client_id LEFT JOIN clients cl ON cl.id = e.client_id
LEFT JOIN deals d ON d.id = e.deal_id LEFT JOIN deals d ON d.id = e.deal_id
LEFT JOIN export_caterer_brands b ON b.code = e.brand_code
WHERE e.id = %s WHERE e.id = %s
""", """,
(entity_id,), (entity_id,),
@@ -614,9 +939,9 @@ def map_bundle(
entities = fetch_all( entities = fetch_all(
f""" f"""
SELECT e.id, e.name, e.entity_type, e.country_iso2, e.lat, e.lon, SELECT e.id, e.name, e.entity_type, e.country_iso2, e.lat, e.lon,
e.confidence, e.pipeline_stage, e.city, e.address_line, e.confidence, e.pipeline_stage, e.city, e.address_line, e.postal_code,
e.email, e.phone, e.website, e.volume_band, e.source, e.email, e.phone, e.website, e.volume_band, e.source,
e.is_favorite, e.client_id, e.deal_id, e.brand_code, e.is_favorite, e.client_id, e.deal_id,
cp.contact_email, cp.contact_phone, cp.contact_email, cp.contact_phone,
COALESCE(cc.contact_count, 0) AS contact_count COALESCE(cc.contact_count, 0) AS contact_count
FROM export_market_entities e FROM export_market_entities e
@@ -641,6 +966,11 @@ def map_bundle(
(tuple(params) + (row_limit,)) if params else (row_limit,), (tuple(params) + (row_limit,)) if params else (row_limit,),
) )
entity_ids = [int(r["id"]) for r in entities]
contacts_by_entity = _map_contacts_batch(entity_ids)
brand_codes = list({r["brand_code"] for r in entities if r.get("brand_code")})
brands_by_code = _brand_websites(brand_codes)
scored: list[tuple[dict[str, Any], float, list[str]]] = [] scored: list[tuple[dict[str, Any], float, list[str]]] = []
halal_by_country: dict[str, dict[str, Any]] = {} halal_by_country: dict[str, dict[str, Any]] = {}
for row in entities: for row in entities:
@@ -678,8 +1008,23 @@ def map_bundle(
"entity_type": row["entity_type"], "entity_type": row["entity_type"],
"country_iso2": row["country_iso2"], "country_iso2": row["country_iso2"],
"city": row.get("city"), "city": row.get("city"),
"address_line": row.get("address_line"),
"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"),
"website": row.get("website"),
"reach": _build_reach(
row,
contacts_by_entity.get(int(row["id"]), []),
brands_by_code.get(row.get("brand_code")),
),
"websites": _build_reach(
row,
contacts_by_entity.get(int(row["id"]), []),
brands_by_code.get(row.get("brand_code")),
)["websites"],
"volume_band": row.get("volume_band"),
"confidence": row.get("confidence"),
"pipeline_stage": row.get("pipeline_stage"),
"contact_count": int(row.get("contact_count") or 0), "contact_count": int(row.get("contact_count") or 0),
"halal_score": h_score, "halal_score": h_score,
"halal_tier": halal_pin_tier(h_score), "halal_tier": halal_pin_tier(h_score),
@@ -971,6 +1316,114 @@ def api_sync_all(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
raise HTTPException(500, str(exc)) from exc raise HTTPException(500, str(exc)) from exc
@router.post("/sync/missing")
def api_sync_missing(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
try:
from app.export_intel_sync import sync_missing_countries
result = sync_missing_countries(max_priority=payload.max_priority or 3)
return {"status": "completed", "phase": "B", "result": result}
except Exception as exc:
raise HTTPException(500, str(exc)) from exc
class ExportIntelSettingsIn(BaseModel):
google_maps_api_key: Optional[str] = None
@router.get("/settings")
def export_intel_settings_get() -> dict[str, Any]:
from app.export_intel_places import google_api_key_status
return google_api_key_status()
@router.put("/settings")
def export_intel_settings_put(payload: ExportIntelSettingsIn) -> dict[str, Any]:
from app.export_intel_places import google_api_key_status, set_google_api_key
if payload.google_maps_api_key is not None:
set_google_api_key(payload.google_maps_api_key)
return google_api_key_status()
@router.post("/sync/places")
def api_sync_places(payload: SyncRequestIn) -> dict[str, Any]:
from app.export_intel_places import enrich_entities_places_country, sync_places_country
from app.export_intel_sync import _countries_for_sync
from app.export_intel_sync_progress import finish_job, job_step, start_job, set_active_job
if payload.country_iso2:
job_id = start_job("places", f"Google Places · {payload.country_iso2.upper()}", total=1)
try:
job_step(job_id, message=f"Google Places {payload.country_iso2.upper()}", current=1, total=1)
result = sync_places_country(payload.country_iso2.upper())
finish_job(job_id, result=result)
return {"status": "completed", "phase": "B", "result": result}
except Exception as exc:
finish_job(job_id, error=str(exc))
raise HTTPException(500, str(exc)) from exc
finally:
set_active_job(None)
countries = _countries_for_sync(region_code=payload.region_code, max_priority=payload.max_priority or 3)
if not countries:
raise HTTPException(400, "country_iso2 or region_code required")
job_id = start_job("places", f"Google Places · {len(countries)} landen", total=len(countries))
results: dict[str, Any] = {}
try:
for i, c in enumerate(countries, start=1):
job_step(job_id, message=f"Google Places {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
results[c] = sync_places_country(c)
time.sleep(1)
finish_job(job_id, result={"countries": len(countries)})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise HTTPException(500, str(exc)) from exc
finally:
set_active_job(None)
return {"status": "completed", "phase": "B", "result": results}
@router.get("/sync/status")
def sync_status() -> dict[str, Any]:
from app.export_intel_sync_progress import get_monitor_snapshot
total_row = fetch_one("SELECT COUNT(*) AS n FROM export_market_entities")
terr_row = fetch_one("SELECT COUNT(*) AS n FROM export_territories WHERE is_active = TRUE")
with_data = fetch_one(
"""
SELECT COUNT(DISTINCT country_iso2) AS n FROM export_market_entities
WHERE country_iso2 IS NOT NULL
"""
)
zero_rows = fetch_all(
"""
SELECT t.country_iso2, t.name_nl, t.region_code
FROM export_territories t
WHERE t.is_active
AND NOT EXISTS (
SELECT 1 FROM export_market_entities e WHERE e.country_iso2 = t.country_iso2
)
ORDER BY t.region_code, t.name_nl
"""
)
type_rows = fetch_all(
"""
SELECT entity_type, COUNT(*) AS n FROM export_market_entities GROUP BY entity_type ORDER BY n DESC
"""
)
coverage = {
"entities_total": int(total_row["n"] or 0) if total_row else 0,
"territories_total": int(terr_row["n"] or 0) if terr_row else 0,
"countries_with_data": int(with_data["n"] or 0) if with_data else 0,
"countries_empty": len(zero_rows),
"countries_empty_list": zero_rows[:30],
"entity_types": {r["entity_type"]: int(r["n"] or 0) for r in type_rows},
}
snap = get_monitor_snapshot(coverage)
return snap
@router.post("/sync/world") @router.post("/sync/world")
def api_sync_world(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]: def api_sync_world(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
"""Sync Europe, Middle East, Africa, Americas — long running.""" """Sync Europe, Middle East, Africa, Americas — long running."""
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Background sync with live progress tracking."""
import time
from app.export_intel_sync import sync_customers, sync_missing_countries
from app.export_intel_sync_progress import finish_job, job_step, start_job
REGIONS = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean", "asia", "oceania"]
def main():
job_id = start_job("background", "Volledige wereld-update", total=len(REGIONS) + 1)
try:
job_step(job_id, message="Lege landen synchroniseren…", step="missing", current=0, total=len(REGIONS) + 1)
sync_missing_countries(max_priority=3)
for i, region in enumerate(REGIONS, start=1):
job_step(job_id, message=f"Horeca regio {i}/{len(REGIONS)}: {region}", step="horeca", current=i, total=len(REGIONS) + 1)
sync_customers(region_code=region)
time.sleep(2)
finish_job(job_id, result={"regions": len(REGIONS)})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
if __name__ == "__main__":
main()
+216 -41
View File
@@ -1,20 +1,26 @@
"""Google Places sync for Export Intel (optional — requires GOOGLE_MAPS_API_KEY).""" """Google Places sync + entity enrichment for Export Intel."""
from __future__ import annotations from __future__ import annotations
import json import json
import math
import os import os
import time import time
from typing import Any, Optional from typing import Any, Optional
import httpx import httpx
from app.db import fetch_one from app.db import execute, fetch_all, fetch_one
from app.export_intel_sync import _get_territory, _upsert_contact from app.export_intel_sync import _get_territory, _upsert_contact
PLACES_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json" PLACES_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json"
DETAILS_URL = "https://maps.googleapis.com/maps/api/place/details/json" DETAILS_URL = "https://maps.googleapis.com/maps/api/place/details/json"
FIND_URL = "https://maps.googleapis.com/maps/api/place/findplacefromtext/json"
SETTINGS_KEY = "export_intel.google_maps_api_key"
QUERIES = [ QUERIES = [
"groothandel groente fruit",
"wholesale food",
"meat wholesaler", "meat wholesaler",
"halal meat distributor", "halal meat distributor",
"poultry distributor", "poultry distributor",
@@ -22,13 +28,58 @@ QUERIES = [
"contract catering", "contract catering",
"food importer", "food importer",
"halal butcher", "halal butcher",
"horeca groothandel",
] ]
LARGE_COUNTRIES = frozenset({"US", "CA", "BR", "MX", "AR", "AU", "RU", "CN", "IN", "CD", "DZ", "EG", "IR", "SA"}) LARGE_COUNTRIES = frozenset({"US", "CA", "BR", "MX", "AR", "AU", "RU", "CN", "IN", "CD", "DZ", "EG", "IR", "SA"})
def get_google_api_key() -> Optional[str]:
env = (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY") or "").strip()
if env:
return env
row = fetch_one("SELECT value FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
if not row or not row.get("value"):
return None
val = row["value"]
if isinstance(val, dict):
key = (val.get("api_key") or val.get("key") or "").strip()
return key or None
if isinstance(val, str):
try:
parsed = json.loads(val)
if isinstance(parsed, dict):
return (parsed.get("api_key") or parsed.get("key") or "").strip() or None
except Exception:
return val.strip() or None
return None
def set_google_api_key(api_key: str) -> None:
key = (api_key or "").strip()
if not key:
execute("DELETE FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
return
execute(
"""
INSERT INTO app_settings (key, value, updated_at)
VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(SETTINGS_KEY, json.dumps({"api_key": key})),
)
def google_api_key_status() -> dict[str, Any]:
key = get_google_api_key()
return {
"google_maps_api_key_set": bool(key),
"source": "env" if (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY")) else ("db" if key else None),
}
def _api_key() -> Optional[str]: def _api_key() -> Optional[str]:
return os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY") return get_google_api_key()
def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) -> list[dict[str, Any]]: def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) -> list[dict[str, Any]]:
@@ -51,6 +102,46 @@ def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) ->
return data.get("results", [])[:12] return data.get("results", [])[:12]
def _find_place_id(name: str, lat: float, lon: float, radius_m: int = 3000) -> Optional[str]:
key = _api_key()
if not key or not name.strip():
return None
params = {
"input": name.strip()[:128],
"inputtype": "textquery",
"locationbias": f"circle:{radius_m}@{lat},{lon}",
"fields": "place_id,name,geometry",
"key": key,
}
with httpx.Client(timeout=20.0) as client:
resp = client.get(FIND_URL, params=params)
if resp.status_code >= 400:
return None
data = resp.json()
if data.get("status") != "OK":
return None
cands = data.get("candidates") or []
if not cands:
return None
best = cands[0]
geo = best.get("geometry", {}).get("location", {})
clat, clon = geo.get("lat"), geo.get("lng")
if clat is not None and clon is not None:
dist = _haversine_m(lat, lon, float(clat), float(clon))
if dist > max(radius_m * 2, 8000):
return None
return best.get("place_id")
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def _place_details(place_id: str) -> dict[str, Any]: def _place_details(place_id: str) -> dict[str, Any]:
key = _api_key() key = _api_key()
if not key: if not key:
@@ -60,7 +151,7 @@ def _place_details(place_id: str) -> dict[str, Any]:
DETAILS_URL, DETAILS_URL,
params={ params={
"place_id": place_id, "place_id": place_id,
"fields": "name,formatted_phone_number,website,formatted_address,geometry,international_phone_number", "fields": "name,formatted_phone_number,website,formatted_address,geometry,international_phone_number,place_id",
"key": key, "key": key,
}, },
) )
@@ -84,55 +175,132 @@ def _bbox_points(bbox: Any, grid: int = 3) -> list[tuple[float, float]]:
return points return points
def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2: str, territory_code: str, query: str) -> tuple[str, int]: def _apply_place_to_entity(entity_id: int, place_id: str, details: dict[str, Any]) -> bool:
from app.db import execute, execute_returning phone = details.get("formatted_phone_number") or details.get("international_phone_number")
website = details.get("website")
geo = details.get("geometry", {}).get("location", {})
plat, plon = geo.get("lat"), geo.get("lng")
execute(
"""
UPDATE export_market_entities SET
phone = COALESCE(%s, phone),
website = COALESCE(%s, website),
google_place_id = COALESCE(google_place_id, %s),
lat = COALESCE(%s, lat),
lon = COALESCE(%s, lon),
confidence = GREATEST(confidence, 68),
updated_at = NOW(),
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb
WHERE id = %s
""",
(
phone,
website,
place_id,
plat,
plon,
json.dumps({"google_place_id": place_id, "google_enriched": True}),
entity_id,
),
)
if phone:
_upsert_contact(entity_id, None, phone, "google_places", 72, role="sales", name="Google Places")
return bool(phone or website)
pid = place.get("place_id")
def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2: str, territory_code: str, query: str) -> tuple[str, int]:
from app.db import execute_returning
pid = place.get("place_id") or details.get("place_id")
if not pid:
return "skipped", 0
name = details.get("name") or place.get("name") or "Unknown" name = details.get("name") or place.get("name") or "Unknown"
geo = details.get("geometry", {}).get("location", place.get("geometry", {}).get("location", {})) geo = details.get("geometry", {}).get("location", place.get("geometry", {}).get("location", {}))
plat = geo.get("lat") plat = geo.get("lat")
plon = geo.get("lng") plon = geo.get("lng")
phone = details.get("formatted_phone_number") or details.get("international_phone_number") phone = details.get("formatted_phone_number") or details.get("international_phone_number")
website = details.get("website") website = details.get("website")
entity_type = "contract_caterer" if "catering" in query else "distributor" entity_type = "contract_caterer" if "catering" in query else "wholesaler"
existing = fetch_one( existing = fetch_one(
"SELECT id FROM export_market_entities WHERE metadata->>'google_place_id' = %s", "SELECT id FROM export_market_entities WHERE google_place_id = %s",
(pid,), (pid,),
) )
if existing: if existing:
execute( _apply_place_to_entity(int(existing["id"]), pid, details)
""" return "updated", int(existing["id"])
UPDATE export_market_entities SET
phone = COALESCE(%s, phone), website = COALESCE(%s, website), row = execute_returning(
lat = COALESCE(%s, lat), lon = COALESCE(%s, lon), """
confidence = GREATEST(confidence, 65), updated_at = NOW() INSERT INTO export_market_entities (
WHERE id = %s name, entity_type, country_iso2, territory_code, lat, lon,
""", phone, website, google_place_id, source, sources, confidence, metadata
(phone, website, plat, plon, existing["id"]), ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'google_places','["google_places"]'::jsonb,65,%s)
) RETURNING id
entity_id = existing["id"] """,
action = "updated" (
else: name[:255],
row = execute_returning( entity_type,
""" country_iso2.upper(),
INSERT INTO export_market_entities ( territory_code,
name, entity_type, country_iso2, territory_code, lat, lon, plat,
phone, website, google_place_id, source, sources, confidence, metadata plon,
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'google_places','["google_places"]'::jsonb,65,%s) phone,
RETURNING id website,
""", pid,
( json.dumps({"google_place_id": pid}),
name[:255], entity_type, country_iso2.upper(), territory_code, ),
plat, plon, phone, website, pid, )
json.dumps({"google_place_id": pid}), entity_id = int(row["id"])
),
)
entity_id = row["id"]
action = "inserted"
if phone: if phone:
_upsert_contact(entity_id, None, phone, "google_places", 70, role="sales", name="Google Places") _upsert_contact(entity_id, None, phone, "google_places", 70, role="sales", name="Google Places")
return action, entity_id return "inserted", entity_id
def enrich_entities_places_country(country_iso2: str, limit: int = 400) -> dict[str, Any]:
"""Match existing OSM entities to Google Places for phone/website."""
if not _api_key():
return {"skipped": True, "reason": "GOOGLE_MAPS_API_KEY not set"}
entities = fetch_all(
"""
SELECT id, name, lat, lon, city, phone, website
FROM export_market_entities
WHERE country_iso2 = %s
AND lat IS NOT NULL AND lon IS NOT NULL
AND (phone IS NULL OR phone = '')
AND (google_place_id IS NULL OR google_place_id = '')
ORDER BY confidence DESC NULLS LAST, id
LIMIT %s
""",
(country_iso2.upper(), limit),
)
enriched = 0
not_found = 0
errors = 0
for ent in entities:
query = ent["name"] or ""
if ent.get("city"):
query = f"{query} {ent['city']}"
try:
pid = _find_place_id(query, float(ent["lat"]), float(ent["lon"]))
if not pid:
not_found += 1
time.sleep(0.12)
continue
details = _place_details(pid)
if _apply_place_to_entity(int(ent["id"]), pid, details):
enriched += 1
except Exception:
errors += 1
time.sleep(0.15)
return {
"country": country_iso2.upper(),
"candidates": len(entities),
"enriched": enriched,
"not_found": not_found,
"errors": errors,
}
def sync_places_country(country_iso2: str) -> dict[str, Any]: def sync_places_country(country_iso2: str) -> dict[str, Any]:
@@ -165,12 +333,19 @@ def sync_places_country(country_iso2: str) -> dict[str, Any]:
action, _ = _upsert_place(place, details, iso, territory_code, q) action, _ = _upsert_place(place, details, iso, territory_code, q)
if action == "inserted": if action == "inserted":
inserted += 1 inserted += 1
else: elif action == "updated":
updated += 1 updated += 1
time.sleep(0.15) time.sleep(0.15)
time.sleep(0.5) time.sleep(0.5)
return {"inserted": inserted, "updated": updated, "queries": len(QUERIES), "search_points": len(search_points)} enrich = enrich_entities_places_country(iso, limit=500)
return {
"inserted": inserted,
"updated": updated,
"queries": len(QUERIES),
"search_points": len(search_points),
"enrich_existing": enrich,
}
def sync_places_countries(countries: list[str]) -> dict[str, Any]: def sync_places_countries(countries: list[str]) -> dict[str, Any]:
+210 -35
View File
@@ -25,6 +25,15 @@ TED_RSS_URLS = [
from app.export_territory_seeds import BBOX_ONLY_ISO2 from app.export_territory_seeds import BBOX_ONLY_ISO2
from app.export_intel_sync_progress import (
finish_job,
get_active_job,
job_step,
set_active_job,
start_job,
update_job,
)
EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
SKIP_EMAIL_SUFFIX = ("example.com", "sentry.io", "wixpress.com", "png", "jpg", "jpeg") SKIP_EMAIL_SUFFIX = ("example.com", "sentry.io", "wixpress.com", "png", "jpg", "jpeg")
@@ -140,9 +149,22 @@ def _upsert_osm_entity(
hn = tags.get("addr:housenumber") or "" hn = tags.get("addr:housenumber") or ""
address = f"{street} {hn}".strip() or None address = f"{street} {hn}".strip() or None
city = tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village") city = tags.get("addr:city") or tags.get("addr:town") or tags.get("addr:village")
phone = (tags.get("phone") or tags.get("contact:phone") or "")[:64] or None phone = (
tags.get("phone")
or tags.get("contact:phone")
or tags.get("contact:mobile")
or tags.get("mobile")
or tags.get("phone:mobile")
or ""
)[:64] or None
email = (tags.get("email") or tags.get("contact:email") or "")[:255] or None email = (tags.get("email") or tags.get("contact:email") or "")[:255] or None
website = (tags.get("website") or tags.get("contact:website") or "")[:512] or None website = (
tags.get("website")
or tags.get("contact:website")
or tags.get("contact:url")
or tags.get("url")
or ""
)[:512] or None
existing = fetch_one( existing = fetch_one(
"SELECT id FROM export_market_entities WHERE metadata->>'osm_external_id' = %s", "SELECT id FROM export_market_entities WHERE metadata->>'osm_external_id' = %s",
@@ -185,7 +207,7 @@ def _upsert_osm_entity(
entity_id = row["id"] entity_id = row["id"]
action = "inserted" action = "inserted"
if email: if email or phone:
_upsert_contact(entity_id, email, phone, "osm", 60) _upsert_contact(entity_id, email, phone, "osm", 60)
return action, entity_id return action, entity_id
@@ -285,6 +307,9 @@ def sync_osm_country(country_iso2: str, kinds: Optional[list[str]] = None) -> di
parts = mapping.get(kind, []) parts = mapping.get(kind, [])
if not parts: if not parts:
continue continue
active = get_active_job()
if active:
job_step(active, message=f"OSM {kind} · {country_iso2}", country=country_iso2, step=kind)
query = _build_country_query(parts, country_iso2, bbox) query = _build_country_query(parts, country_iso2, bbox)
elements = _fetch_overpass(query) elements = _fetch_overpass(query)
ki = {"fetched": len(elements), "inserted": 0, "updated": 0} ki = {"fetched": len(elements), "inserted": 0, "updated": 0}
@@ -352,6 +377,46 @@ def materialize_caterer_presence(country_iso2: str) -> int:
return created return created
PHONE_RE = re.compile(
r"(?:tel:|phone|telefoon)[^\d+]{0,12}(\+?[\d\s\(\)\-\.]{8,20})|(\+31[\s\-\.]?(?:\(?\d{2}\)?[\s\-\.]?)?[\d\s\-\.]{6,12})",
re.I,
)
def _normalize_phone(raw: str) -> Optional[str]:
s = re.sub(r"[^\d+]", "", (raw or "").strip())
if len(s) < 8:
return None
if s.startswith("00"):
s = "+" + s[2:]
if s.startswith("0") and not s.startswith("+"):
s = "+31" + s[1:]
return s[:32]
def _scrape_phones(url: str) -> list[str]:
if not url:
return []
if not url.startswith("http"):
url = "https://" + url
try:
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
resp = client.get(url, headers={"User-Agent": "FoodlinkkExportIntel/1.0"})
if resp.status_code >= 400:
return []
text = resp.text[:500000]
except Exception:
return []
found: set[str] = set()
for m in PHONE_RE.finditer(text):
raw = (m.group(1) or m.group(2) or "").strip()
norm = _normalize_phone(raw)
if norm:
found.add(norm)
return list(found)[:3]
def _scrape_emails(url: str) -> list[str]: def _scrape_emails(url: str) -> list[str]:
if not url: if not url:
return [] return []
@@ -386,8 +451,25 @@ def enrich_contacts_country(country_iso2: str) -> dict[str, Any]:
) )
added = 0 added = 0
scraped = 0 scraped = 0
phones_added = 0
for ent in entities: for ent in entities:
emails = _scrape_emails(ent["website"]) site = ent["website"]
emails = _scrape_emails(site)
phones = _scrape_phones(site) if not ent.get("phone") else []
# probe common contact pages (gratis, geen API)
if not emails or not phones:
base = site if site.startswith("http") else ("https://" + site)
base = base.rstrip("/")
for path in ("/contact", "/contact/", "/contact-ons", "/contactus", "/over-ons", "/about"):
try:
emails = list(dict.fromkeys(emails + _scrape_emails(base + path)))
if not ent.get("phone"):
phones = list(dict.fromkeys(phones + _scrape_phones(base + path)))
except Exception:
pass
if emails and (ent.get("phone") or phones):
break
time.sleep(0.15)
scraped += 1 scraped += 1
for em in emails: for em in emails:
role = "procurement" if any(x in em for x in ("inkoop", "procurement", "sales", "info", "order")) else "general" role = "procurement" if any(x in em for x in ("inkoop", "procurement", "sales", "info", "order")) else "general"
@@ -398,13 +480,27 @@ def enrich_contacts_country(country_iso2: str) -> dict[str, Any]:
if not before: if not before:
_upsert_contact(ent["id"], em, None, "website_scrape", 65, role=role, name="Website") _upsert_contact(ent["id"], em, None, "website_scrape", 65, role=role, name="Website")
added += 1 added += 1
for ph in phones:
before_p = fetch_one(
"SELECT id FROM export_entity_contacts WHERE entity_id = %s AND phone = %s",
(ent["id"], ph),
)
if not before_p:
_upsert_contact(ent["id"], None, ph, "website_scrape", 60, role="general", name="Website")
added += 1
phones_added += 1
if not ent.get("email") and emails: if not ent.get("email") and emails:
execute( execute(
"UPDATE export_market_entities SET email = %s, updated_at = NOW() WHERE id = %s", "UPDATE export_market_entities SET email = %s, updated_at = NOW() WHERE id = %s",
(emails[0], ent["id"]), (emails[0], ent["id"]),
) )
if not ent.get("phone") and phones:
execute(
"UPDATE export_market_entities SET phone = %s, updated_at = NOW() WHERE id = %s",
(phones[0], ent["id"]),
)
time.sleep(0.3) time.sleep(0.3)
return {"entities_scraped": scraped, "contacts_added": added} return {"entities_scraped": scraped, "contacts_added": added, "phones_added": phones_added}
def sync_ted_tenders(limit: int = 50) -> dict[str, Any]: def sync_ted_tenders(limit: int = 50) -> dict[str, Any]:
@@ -476,46 +572,105 @@ def sync_ted_tenders(limit: int = 50) -> dict[str, Any]:
def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None) -> dict[str, Any]: def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None) -> dict[str, Any]:
"""Full OSM + contacts + Places sync for active territories.""" """Full OSM + contacts sync for active territories (gratis)."""
iso_list = _countries_for_sync(region_code=region_code, max_priority=max_priority) iso_list = _countries_for_sync(region_code=region_code, max_priority=max_priority)
label = f"Sync {region_code or 'alle landen'} ({len(iso_list)} landen)"
job_id = start_job("all", label, total=len(iso_list), meta={"region": region_code, "max_priority": max_priority})
report: dict[str, Any] = { report: dict[str, Any] = {
"countries": iso_list, "countries": iso_list,
"country_count": len(iso_list), "country_count": len(iso_list),
"region": region_code, "region": region_code,
"max_priority": max_priority, "max_priority": max_priority,
"steps": {}, "steps": {},
"job_id": job_id,
} }
try:
update_job(job_id, step="ted", message="TED tenders ophalen…")
report["steps"]["ted"] = sync_ted_tenders(40)
report["steps"]["ted"] = sync_ted_tenders(40) dist: dict[str, Any] = {}
cater: dict[str, Any] = {}
cust: dict[str, Any] = {}
contacts: dict[str, Any] = {}
for i, c in enumerate(iso_list, start=1):
job_step(job_id, message=f"Land {i}/{len(iso_list)}: {c}", country=c, step="osm", current=i, total=len(iso_list))
try:
materialize_caterer_presence(c)
dist[c] = sync_osm_country(c, kinds=["distributors"])
cater[c] = sync_osm_country(c, kinds=["caterers"])
cust[c] = sync_osm_country(c, kinds=["customers"])
contacts[c] = enrich_contacts_country(c)
except Exception as exc:
dist[c] = {"error": str(exc)}
time.sleep(2)
report["steps"]["distributors"] = dist
report["steps"]["caterers"] = cater
report["steps"]["customers"] = cust
report["steps"]["contacts"] = contacts
report["steps"]["google_places"] = {"skipped": True, "reason": "gratis-modus: geen Google Places"}
finish_job(job_id, result={"country_count": len(iso_list), "region": region_code})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return report
def sync_missing_countries(max_priority: int = 3) -> dict[str, Any]:
"""OSM + contacts sync for active territories with zero entities."""
rows = fetch_all(
"""
SELECT t.country_iso2
FROM export_territories t
WHERE t.is_active
AND t.sync_priority <= %s
AND NOT EXISTS (
SELECT 1 FROM export_market_entities e WHERE e.country_iso2 = t.country_iso2
)
GROUP BY t.country_iso2
ORDER BY MIN(t.sync_priority), t.country_iso2
""",
(max_priority,),
)
iso_list = [r["country_iso2"] for r in rows]
job_id = start_job("missing", f"Lege landen ({len(iso_list)})", total=len(iso_list))
report: dict[str, Any] = {"countries": iso_list, "country_count": len(iso_list), "steps": {}, "job_id": job_id}
dist: dict[str, Any] = {} dist: dict[str, Any] = {}
cater: dict[str, Any] = {} cater: dict[str, Any] = {}
cust: dict[str, Any] = {} cust: dict[str, Any] = {}
contacts: dict[str, Any] = {} contacts: dict[str, Any] = {}
for c in iso_list: try:
try: for i, c in enumerate(iso_list, start=1):
materialize_caterer_presence(c) job_step(job_id, message=f"Leeg land {i}/{len(iso_list)}: {c}", country=c, current=i, total=len(iso_list))
dist[c] = sync_osm_country(c, kinds=["distributors"]) try:
cater[c] = sync_osm_country(c, kinds=["caterers"]) materialize_caterer_presence(c)
cust[c] = sync_osm_country(c, kinds=["customers"]) dist[c] = sync_osm_country(c, kinds=["distributors"])
contacts[c] = enrich_contacts_country(c) cater[c] = sync_osm_country(c, kinds=["caterers"])
except Exception as exc: cust[c] = sync_osm_country(c, kinds=["customers"])
dist[c] = {"error": str(exc)} contacts[c] = enrich_contacts_country(c)
time.sleep(2) except Exception as exc:
report["steps"]["distributors"] = dist dist[c] = {"error": str(exc)}
report["steps"]["caterers"] = cater time.sleep(2)
report["steps"]["customers"] = cust report["steps"] = {
report["steps"]["contacts"] = contacts "distributors": dist,
"caterers": cater,
from app.export_intel_places import sync_places_countries "customers": cust,
report["steps"]["google_places"] = sync_places_countries(iso_list) "contacts": contacts,
}
finish_job(job_id, result={"country_count": len(iso_list)})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return report return report
def sync_world_regions(max_priority: int = 2) -> dict[str, Any]: def sync_world_regions(max_priority: int = 2) -> dict[str, Any]:
"""Sync all target regions sequentially (Europe, MENA, Africa, Americas).""" """Sync all target regions sequentially (Europe, MENA, Africa, Americas)."""
regions = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean"] regions = ["europe", "middle_east", "gcc", "africa", "americas", "caribbean", "asia", "oceania"]
out: dict[str, Any] = {"regions": {}, "totals": {"countries": 0}} out: dict[str, Any] = {"regions": {}, "totals": {"countries": 0}}
for reg in regions: for reg in regions:
countries = _countries_for_sync(region_code=reg, max_priority=max_priority) countries = _countries_for_sync(region_code=reg, max_priority=max_priority)
@@ -571,13 +726,33 @@ def sync_contacts(country_iso2: Optional[str] = None, region_code: Optional[str]
def sync_customers(country_iso2: Optional[str] = None, region_code: Optional[str] = None) -> dict[str, Any]: def sync_customers(country_iso2: Optional[str] = None, region_code: Optional[str] = None) -> dict[str, Any]:
if country_iso2: if country_iso2:
return sync_osm_country(country_iso2, kinds=["customers"]) job_id = start_job("customers", f"Horeca {country_iso2.upper()}", total=1, meta={"country": country_iso2.upper()})
countries = _countries_for_sync(region_code=region_code)
results: dict[str, Any] = {}
for c in countries:
try: try:
results[c] = sync_osm_country(c, kinds=["customers"]) job_step(job_id, message=f"Horeca OSM · {country_iso2.upper()}", country=country_iso2.upper(), current=1, total=1)
result = sync_osm_country(country_iso2, kinds=["customers"])
finish_job(job_id, result=result)
return result
except Exception as exc: except Exception as exc:
results[c] = {"error": str(exc)} finish_job(job_id, error=str(exc))
time.sleep(3) raise
return {"osm": results} finally:
set_active_job(None)
countries = _countries_for_sync(region_code=region_code)
label = f"Horeca {region_code or 'regio'} ({len(countries)} landen)"
job_id = start_job("customers", label, total=len(countries), meta={"region": region_code})
results: dict[str, Any] = {}
try:
for i, c in enumerate(countries, start=1):
job_step(job_id, message=f"Horeca {i}/{len(countries)}: {c}", country=c, current=i, total=len(countries))
try:
results[c] = sync_osm_country(c, kinds=["customers"])
except Exception as exc:
results[c] = {"error": str(exc)}
time.sleep(3)
finish_job(job_id, result={"countries": len(countries), "region": region_code})
except Exception as exc:
finish_job(job_id, error=str(exc))
raise
finally:
set_active_job(None)
return {"osm": results, "job_id": job_id}
+186
View File
@@ -0,0 +1,186 @@
"""File-backed sync progress for Export Intel background and UI jobs."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any, Optional
STATUS_PATH = Path("/tmp/export-intel-sync-status.json")
LOG_PATH = Path("/tmp/export-intel-sync-progress.jsonl")
LOCK_PATH = Path("/tmp/export-intel-sync.lock")
_active_job_id: Optional[str] = None
def _now() -> float:
return time.time()
def _read_json(path: Path, default: Any) -> Any:
try:
if path.exists():
return json.loads(path.read_text())
except Exception:
pass
return default
def _write_json(path: Path, data: Any) -> None:
path.write_text(json.dumps(data, indent=2, default=str))
def log_event(event: str, **data: Any) -> None:
row = {"ts": _now(), "event": event, **data}
with LOG_PATH.open("a") as fh:
fh.write(json.dumps(row, default=str) + "\n")
def set_active_job(job_id: Optional[str]) -> None:
global _active_job_id
_active_job_id = job_id
def get_active_job() -> Optional[str]:
return _active_job_id
def _load_status() -> dict[str, Any]:
data = _read_json(STATUS_PATH, {"jobs": {}, "history": []})
if "jobs" not in data:
data["jobs"] = {}
if "history" not in data:
data["history"] = []
return data
def _save_status(data: dict[str, Any]) -> None:
_write_json(STATUS_PATH, data)
def _touch_lock(job_id: str, label: str) -> None:
LOCK_PATH.write_text(json.dumps({"pid": os.getpid(), "job_id": job_id, "label": label, "ts": _now()}))
def _clear_lock() -> None:
LOCK_PATH.unlink(missing_ok=True)
def start_job(job_type: str, label: str, total: int = 0, meta: Optional[dict[str, Any]] = None) -> str:
job_id = f"{job_type}-{_now():.0f}"
status = _load_status()
job = {
"id": job_id,
"type": job_type,
"label": label,
"status": "running",
"started_at": _now(),
"updated_at": _now(),
"current": 0,
"total": total,
"percent": 0,
"country": None,
"step": None,
"message": label,
"meta": meta or {},
"error": None,
}
status["jobs"][job_id] = job
_save_status(status)
_touch_lock(job_id, label)
set_active_job(job_id)
log_event("job_start", job_id=job_id, type=job_type, label=label, total=total)
return job_id
def update_job(job_id: str, **fields: Any) -> None:
status = _load_status()
job = status["jobs"].get(job_id)
if not job:
return
job.update(fields)
job["updated_at"] = _now()
cur = int(job.get("current") or 0)
tot = int(job.get("total") or 0)
if tot > 0:
job["percent"] = min(100, round(cur * 100 / tot))
status["jobs"][job_id] = job
_save_status(status)
if LOCK_PATH.exists():
_touch_lock(job_id, job.get("label") or job_id)
def job_step(
job_id: str,
*,
message: str,
country: Optional[str] = None,
step: Optional[str] = None,
current: Optional[int] = None,
total: Optional[int] = None,
) -> None:
fields: dict[str, Any] = {"message": message}
if country is not None:
fields["country"] = country
if step is not None:
fields["step"] = step
if current is not None:
fields["current"] = current
if total is not None:
fields["total"] = total
update_job(job_id, **fields)
log_event("step", job_id=job_id, message=message, country=country, step=step, current=current, total=total)
def finish_job(job_id: str, result: Optional[dict[str, Any]] = None, error: Optional[str] = None) -> None:
status = _load_status()
job = status["jobs"].get(job_id)
if not job:
return
job["status"] = "error" if error else "completed"
job["updated_at"] = _now()
job["finished_at"] = _now()
job["percent"] = 100 if not error else job.get("percent", 0)
job["error"] = error
if result is not None:
job["result"] = result
status["jobs"][job_id] = job
status["history"] = ([job_id] + [h for h in status.get("history", []) if h != job_id])[:20]
_save_status(status)
log_event("job_finish", job_id=job_id, status=job["status"], error=error)
if get_active_job() == job_id:
set_active_job(None)
running = [j for j in status["jobs"].values() if j.get("status") == "running"]
if not running:
_clear_lock()
def tail_log(limit: int = 40) -> list[dict[str, Any]]:
if not LOG_PATH.exists():
return []
lines = LOG_PATH.read_text().strip().splitlines()
out: list[dict[str, Any]] = []
for line in lines[-limit:]:
try:
out.append(json.loads(line))
except Exception:
continue
return out
def get_monitor_snapshot(db_counts: Optional[dict[str, Any]] = None) -> dict[str, Any]:
status = _load_status()
jobs = status.get("jobs", {})
active = [j for j in jobs.values() if j.get("status") == "running"]
recent = [jobs[jid] for jid in status.get("history", [])[:8] if jid in jobs and jobs[jid].get("status") != "running"]
lock = _read_json(LOCK_PATH, None) if LOCK_PATH.exists() else None
return {
"active_jobs": active,
"recent_jobs": recent,
"events": tail_log(50),
"lock": lock,
"any_running": bool(active) or bool(lock),
"coverage": db_counts or {},
"updated_at": _now(),
}