SysOps: manual — 2026-07-30 09:33 UTC
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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: '© OSM © CARTO',
|
attribution: '© OSM © 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();
|
||||||
|
};
|
||||||
|
|
||||||
|
MapInstance.prototype.closePopup = function () {
|
||||||
|
if (this.map) this.map.closePopup();
|
||||||
|
};
|
||||||
|
|
||||||
|
MapInstance.prototype.destroy = function () {
|
||||||
|
if (this.map) {
|
||||||
|
this.map.remove();
|
||||||
|
this.map = null;
|
||||||
|
this.clusterLayer = null;
|
||||||
|
}
|
||||||
|
delete instances[this.containerId];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveId(containerId) {
|
||||||
|
return containerId || DEFAULT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePopup() {
|
function getOrCreate(containerId) {
|
||||||
if (map) map.closePopup();
|
var id = resolveId(containerId);
|
||||||
|
if (!instances[id]) instances[id] = new MapInstance(id);
|
||||||
|
return instances[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroy() {
|
function init(containerId) {
|
||||||
if (map) {
|
return getOrCreate(containerId).init();
|
||||||
map.remove();
|
|
||||||
map = null;
|
|
||||||
clusterLayer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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) {
|
this.syncMsg = 'Profiel-module niet geladen — ververs de pagina';
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+374
-405
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
@@ -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:
|
||||||
|
rows = fetch_all(
|
||||||
"SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl"
|
"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."""
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,36 +175,61 @@ 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),
|
|
||||||
lat = COALESCE(%s, lat), lon = COALESCE(%s, lon),
|
|
||||||
confidence = GREATEST(confidence, 65), updated_at = NOW()
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(phone, website, plat, plon, existing["id"]),
|
|
||||||
)
|
|
||||||
entity_id = existing["id"]
|
|
||||||
action = "updated"
|
|
||||||
else:
|
|
||||||
row = execute_returning(
|
row = execute_returning(
|
||||||
"""
|
"""
|
||||||
INSERT INTO export_market_entities (
|
INSERT INTO export_market_entities (
|
||||||
@@ -123,16 +239,68 @@ def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2:
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
name[:255], entity_type, country_iso2.upper(), territory_code,
|
name[:255],
|
||||||
plat, plon, phone, website, pid,
|
entity_type,
|
||||||
|
country_iso2.upper(),
|
||||||
|
territory_code,
|
||||||
|
plat,
|
||||||
|
plon,
|
||||||
|
phone,
|
||||||
|
website,
|
||||||
|
pid,
|
||||||
json.dumps({"google_place_id": pid}),
|
json.dumps({"google_place_id": pid}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
entity_id = row["id"]
|
entity_id = int(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]:
|
||||||
|
|||||||
@@ -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,23 +572,28 @@ 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] = {}
|
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:
|
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:
|
try:
|
||||||
materialize_caterer_presence(c)
|
materialize_caterer_presence(c)
|
||||||
dist[c] = sync_osm_country(c, kinds=["distributors"])
|
dist[c] = sync_osm_country(c, kinds=["distributors"])
|
||||||
@@ -507,15 +608,69 @@ def sync_all_priority(max_priority: int = 2, region_code: Optional[str] = None)
|
|||||||
report["steps"]["customers"] = cust
|
report["steps"]["customers"] = cust
|
||||||
report["steps"]["contacts"] = contacts
|
report["steps"]["contacts"] = contacts
|
||||||
|
|
||||||
from app.export_intel_places import sync_places_countries
|
report["steps"]["google_places"] = {"skipped": True, "reason": "gratis-modus: geen Google Places"}
|
||||||
report["steps"]["google_places"] = sync_places_countries(iso_list)
|
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
|
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] = {}
|
||||||
|
cater: dict[str, Any] = {}
|
||||||
|
cust: dict[str, Any] = {}
|
||||||
|
contacts: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
for i, c in enumerate(iso_list, start=1):
|
||||||
|
job_step(job_id, message=f"Leeg land {i}/{len(iso_list)}: {c}", country=c, 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,
|
||||||
|
"caterers": cater,
|
||||||
|
"customers": cust,
|
||||||
|
"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
|
||||||
|
|
||||||
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()})
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
finish_job(job_id, error=str(exc))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
set_active_job(None)
|
||||||
countries = _countries_for_sync(region_code=region_code)
|
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] = {}
|
results: dict[str, Any] = {}
|
||||||
for c in countries:
|
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:
|
try:
|
||||||
results[c] = sync_osm_country(c, kinds=["customers"])
|
results[c] = sync_osm_country(c, kinds=["customers"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
results[c] = {"error": str(exc)}
|
results[c] = {"error": str(exc)}
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
return {"osm": results}
|
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}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user