From 5dd69a11381c15c82f54e98cb4e744d2f2584e48 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 30 Jul 2026 09:33:03 +0000 Subject: [PATCH] =?UTF-8?q?SysOps:=20manual=20=E2=80=94=202026-07-30=2009:?= =?UTF-8?q?33=20UTC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 + cockpit/app/config.py | 6 + cockpit/app/routes/export_intel.py | 35 +- cockpit/app/routes/settings_api.py | 3 +- cockpit/app/services/herman.py | 58 ++ cockpit/app/services/llm_router.py | 72 +- cockpit/briefing.py | 188 ++++- cockpit/static/css/app-sidebar-neo.css | 41 +- cockpit/static/css/export-intel-tabs.css | 143 +++- cockpit/static/css/export-intel.css | 708 +++++++++++++++++- cockpit/static/css/mobile.css | 9 + cockpit/static/css/palantir-theme.css | 118 +++ cockpit/static/js/export-intel-map.js | 393 +++++++--- cockpit/static/js/export-intel-profile.js | 591 +++++++++++++++ cockpit/static/js/export-intel.js | 487 ++++++++++-- cockpit/templates/base.html | 2 +- cockpit/templates/export_intel.html | 777 ++++++++++---------- cockpit/templates/settings.html | 37 +- docker-compose.yml | 7 + migrations/040_openrouter_deepseek.sql | 14 + tools-api/app/export_intel.py | 471 +++++++++++- tools-api/app/export_intel_bg_sync.py | 24 + tools-api/app/export_intel_places.py | 257 +++++-- tools-api/app/export_intel_sync.py | 245 +++++- tools-api/app/export_intel_sync_progress.py | 186 +++++ 25 files changed, 4148 insertions(+), 734 deletions(-) create mode 100644 cockpit/static/js/export-intel-profile.js create mode 100644 migrations/040_openrouter_deepseek.sql create mode 100644 tools-api/app/export_intel_bg_sync.py create mode 100644 tools-api/app/export_intel_sync_progress.py diff --git a/.env.example b/.env.example index dee0245..661764b 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,13 @@ PROXMOX_HOST=10.4.7.14 PROXMOX_USER=root@pam PROXMOX_TOKEN_NAME= 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= diff --git a/cockpit/app/config.py b/cockpit/app/config.py index b5cc9ba..96643f8 100644 --- a/cockpit/app/config.py +++ b/cockpit/app/config.py @@ -10,6 +10,12 @@ class Settings: OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "qwen3:8b") 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") + # 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_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") diff --git a/cockpit/app/routes/export_intel.py b/cockpit/app/routes/export_intel.py index aba47aa..440d961 100644 --- a/cockpit/app/routes/export_intel.py +++ b/cockpit/app/routes/export_intel.py @@ -276,6 +276,39 @@ async def api_tenders(country: Optional[str] = None, status: Optional[str] = "op 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}") async def api_sync(kind: str, request: Request): body = {} @@ -284,7 +317,7 @@ async def api_sync(kind: str, request: Request): body = await request.json() except Exception: 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"} timeout = 7200.0 if kind in ("world", "region", "all") else 600.0 async with httpx.AsyncClient(timeout=timeout) as client: diff --git a/cockpit/app/routes/settings_api.py b/cockpit/app/routes/settings_api.py index d859a23..8b43b4b 100644 --- a/cockpit/app/routes/settings_api.py +++ b/cockpit/app/routes/settings_api.py @@ -406,6 +406,7 @@ def llm_presets() -> dict[str, Any]: @settings_router.get("/llm") def llm_list() -> dict[str, Any]: + llm_router.ensure_openrouter_placeholder() items = llm_router.list_providers() 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()} @@ -415,7 +416,7 @@ def llm_list() -> dict[str, Any]: def llm_create(body: LlmProviderBody) -> dict[str, Any]: 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 - 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: execute("UPDATE llm_providers SET is_default = FALSE, updated_at = NOW()") row = fetch_one( diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py index a2f16a6..7560a29 100644 --- a/cockpit/app/services/herman.py +++ b/cockpit/app/services/herman.py @@ -119,6 +119,40 @@ def _agent_steps(delegated: list[str], routing_reason: str = "", extra: list[dic 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( message: str, channel: str = "cockpit", @@ -344,6 +378,17 @@ async def chat( "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: async with httpx.AsyncClient(timeout=620.0) as client: r = await client.post( @@ -373,6 +418,19 @@ async def chat( "crm_loaded": data.get("crm_loaded", False), } 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 { "agent": "herman", "agent_label": "Herman", diff --git a/cockpit/app/services/llm_router.py b/cockpit/app/services/llm_router.py index e41aa59..4f937ee 100644 --- a/cockpit/app/services/llm_router.py +++ b/cockpit/app/services/llm_router.py @@ -48,13 +48,17 @@ LLM_PRESETS: dict[str, dict[str, Any]] = { "label": "OpenRouter", "api_base_url": "https://openrouter.ai/api/v1", "models": [ + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash:free", + "deepseek/deepseek-chat", + "deepseek/deepseek-r1", "google/gemini-2.0-flash-exp:free", - "deepseek/deepseek-r1:free", "meta-llama/llama-3.3-70b-instruct:free", ], + "default_model": "deepseek/deepseek-v4-flash", "needs_key": True, - "signup_url": "https://openrouter.ai/", - "hint": "Veel gratis modellen via één API.", + "signup_url": "https://openrouter.ai/keys", + "hint": "Eén API key · DeepSeek V4 Flash via openrouter.ai. Vul key in Instellingen → AI.", }, "mistral": { "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]]: out = [] 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]: row = get_provider(provider_id) if not row: + env_or = _env_openrouter_provider() + if env_or: + return env_or return { "id": 0, "label": "Ollama lokaal", @@ -136,6 +189,19 @@ def resolve_provider(provider_id: int | None = None) -> dict[str, Any]: "model": settings.OLLAMA_MODEL, "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 diff --git a/cockpit/briefing.py b/cockpit/briefing.py index 95bb5f5..a818a02 100644 --- a/cockpit/briefing.py +++ b/cockpit/briefing.py @@ -671,57 +671,134 @@ def build_live_digest(data: dict[str, Any]) -> dict[str, Any]: } - def build_template_report(data: dict[str, Any]) -> str: 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}*", "", - "------------------------------", - "KWALITEITSBEGELEIDING", - "------------------------------", - f"- **Klanten:** {data['clients']} | **Deals:** {data['deals']} | **Pipeline:** €{data['pipeline_eur']:,.0f}", - f"- **Supermarkten:** {data.get('supermarkets', 0)} | **Partnerships:** {data.get('crm_partnerships', 0)}", - f"- **Groothandels:** {data.get('wholesalers', 0)}", + "## KPI's", + 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"- **Groothandels:** {data.get('wholesalers', 0)} · **Goedkeuringen open:** {data['pending_approvals']}", "", - "------------------------------", - "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"): - lines.extend(["Top halal kansen:"]) - for row in data["top_opportunities"][:3]: - lines.append(f" - {row.get('chain')} {row.get('name')} ({row.get('city')})") + lines.extend(["## Top halal-markt kansen (Retail 360)"]) + for row in data["top_opportunities"]: + 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"): - lines.extend(["", "Markt trends:"]) + lines.extend(["## Markt trends"]) for row in data["market_trends"]: pct = round(float(row.get("opportunity_score") or 0) * 100) - lines.append(f" - {row.get('trend_name')} ({pct}%)") - lines.extend([ - "", - "------------------------------", - "ACTIVITEITEN", - "------------------------------", - ]) + lines.append(f"- **{row.get('trend_name')}** ({pct}% kans) — {row.get('description') or ''}") + lines.append("") + + lines.extend(["## Pipeline per stage"]) + 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"): - for row in data["recent_handoffs"][:4]: - lines.append(f" - {row.get('from_agent')} {row.get('to_agent')} ({row.get('handoff_type')})") + lines.extend(["", "## Agent samenwerking (handoffs 24u)"]) + 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) +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: digest = build_live_digest(data) @@ -757,4 +834,39 @@ def _save_briefing(content: str, data: dict[str, Any]) -> None: ( f"Briefing {data['date']}", 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 diff --git a/cockpit/static/css/app-sidebar-neo.css b/cockpit/static/css/app-sidebar-neo.css index 8dc9b3b..107be6e 100644 --- a/cockpit/static/css/app-sidebar-neo.css +++ b/cockpit/static/css/app-sidebar-neo.css @@ -1,24 +1,36 @@ /* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */ .app-shell { - display: grid; - grid-template-columns: 220px 1fr; + display: block; min-height: 100vh; } .app-sidebar { - position: sticky; + position: fixed; top: 0; - align-self: start; - height: auto; - max-height: none; - overflow: visible; - overflow-x: hidden; - padding: 0.75rem 0.6rem; + left: 0; + bottom: 0; + width: 220px; + z-index: 200; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0.75rem 0.6rem 0; background: #080c12; 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 { padding: 0.25rem 0.5rem 0.75rem; 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-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 { margin-bottom: 0.65rem; diff --git a/cockpit/static/css/export-intel-tabs.css b/cockpit/static/css/export-intel-tabs.css index 54d7220..e5e1be2 100644 --- a/cockpit/static/css/export-intel-tabs.css +++ b/cockpit/static/css/export-intel-tabs.css @@ -1,19 +1,146 @@ -/* Wereldexport — KPIs + layout lock */ +/* Wereldexport — full-width map workspace */ .wereldexport-workspace.vtabs-layout { display: flex !important; - flex-direction: row !important; - align-items: flex-start !important; - gap: 1rem !important; + flex-direction: column !important; + align-items: stretch !important; + gap: 0.65rem !important; + width: 100%; + min-height: calc(100dvh - 220px); } + .wereldexport-workspace > .vtabs-nav { - flex: 0 0 220px !important; - width: 220px !important; - max-width: 220px !important; + flex: 0 0 auto !important; + width: 100% !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 { - flex: 1 1 0 !important; + flex: 1 1 auto !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 { diff --git a/cockpit/static/css/export-intel.css b/cockpit/static/css/export-intel.css index 8f5d85b..98ff50b 100644 --- a/cockpit/static/css/export-intel.css +++ b/cockpit/static/css/export-intel.css @@ -13,33 +13,102 @@ .ei-filter-bar { display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 0.65rem 1rem; + flex-direction: column; + gap: 0.65rem; margin-bottom: 1rem; padding: 0.75rem 1rem; 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-radius: 12px; } -.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; } -.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; } -.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-grid { + display: grid; + 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 input { +.ei-filter-label input[type="search"], +.ei-filter-label input[type="text"] { + width: 100%; + box-sizing: border-box; font-size: 0.82rem; padding: 0.45rem 0.65rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.22); background: #0a0e12; color: #e2e8f0; - min-width: 8rem; } -.ei-filter-search-wrap input { width: 100%; min-width: 0; } -.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } -.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; } +.ei-filter-meta { + display: flex; + 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-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 > .ei-map-wrap { width: 100%; } @@ -93,21 +162,106 @@ .ei-split { display: flex; flex-direction: column; - gap: 1rem; + gap: 0; min-height: 0; } -@media (min-width: 1200px) { +@media (min-width: 900px) { .ei-split.ei-split-side { display: grid; - grid-template-columns: minmax(0, 1.65fr) minmax(320px, 1fr); - grid-template-rows: minmax(480px, 62vh); + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 400px); + grid-template-rows: minmax(0, 1fr); 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-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 { position: absolute; inset: 0; z-index: 500; 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-wrap { - border-radius: 14px; 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); 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%; - min-height: 480px; 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 */ @@ -204,7 +378,6 @@ 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 { display: flex; flex-wrap: wrap; @@ -228,12 +401,6 @@ 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 { display: inline-flex; align-items: center; @@ -354,10 +521,10 @@ .ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; } .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 { position: relative; - z-index: 1302; + z-index: 10051; width: min(420px, 96vw); padding: 1.25rem; 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-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-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-body { padding: 1rem; overflow-y: auto; flex: 1; } .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-td-actions { white-space: nowrap; } .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; } diff --git a/cockpit/static/css/mobile.css b/cockpit/static/css/mobile.css index 524c009..62dc3ef 100644 --- a/cockpit/static/css/mobile.css +++ b/cockpit/static/css/mobile.css @@ -97,6 +97,9 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } grid-template-columns: 1fr !important; display: block !important; } + .app-main { + margin-left: 0 !important; + } .app-sidebar { position: fixed !important; top: 0; left: 0; bottom: 0; @@ -113,6 +116,12 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); } overflow-x: hidden; -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 { overflow: hidden; } .app-topbar { diff --git a/cockpit/static/css/palantir-theme.css b/cockpit/static/css/palantir-theme.css index 8fc9d39..9932f5d 100644 --- a/cockpit/static/css/palantir-theme.css +++ b/cockpit/static/css/palantir-theme.css @@ -892,3 +892,121 @@ body:has(.topbar) .main { margin-left: 0; } border-radius: 8px; color: #94a3b8; font-size: 0.85rem; } .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; } diff --git a/cockpit/static/js/export-intel-map.js b/cockpit/static/js/export-intel-map.js index 8f0880f..2a9be5a 100644 --- a/cockpit/static/js/export-intel-map.js +++ b/cockpit/static/js/export-intel-map.js @@ -1,13 +1,7 @@ /** - * Export Intel map — Leaflet + clustering + rich popups + * Export Intel map — Leaflet + clustering (multi-instance per tab) */ window.ExportIntelMap = (function () { - var map = null; - var clusterLayer = null; - var onEntityClick = null; - var halalMode = false; - var markersById = {}; - var HALAL_COLORS = { hot: '#22c55e', warm: '#eab308', @@ -27,6 +21,9 @@ window.ExportIntelMap = (function () { foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' }, }; + var instances = {}; + var DEFAULT_ID = 'ei-map'; + function cfg(type) { 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 '' + + (label ? '' + label + '' : '') + + '' + val + ''; + } + return '
' + (label ? '' + label + '' : '') + + '' + val + '
'; + }).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('📧 ' + escapeHtml(c.email) + ''); + if (c.email_alt) bits.push('📧 ' + escapeHtml(c.email_alt) + ' (alt)'); + if (c.phone) bits.push('📞 ' + escapeHtml(c.phone) + ''); + if (c.mobile) bits.push('📱 ' + escapeHtml(c.mobile) + ''); + if (c.whatsapp) bits.push('💬 ' + escapeHtml(c.whatsapp) + ''); + if (c.website) bits.push('🌐 Website'); + if (c.linkedin_url) bits.push('in LinkedIn'); + return '
' + + '
' + head + (c.is_primary ? ' primair' : '') + '
' + + (sub ? '
' + sub + '
' : '') + + (bits.length ? '' : '') + + '
'; + }).join(''); + return '
Contactpersonen
' + cards + '
'; + } + + function reachBlock(p) { + var r = p.reach || {}; + if (!r.has_any && !p.email && !p.phone && !(p.websites && p.websites.length)) { + return '
Nog geen contact — sync contacten of vul handmatig aan.
'; + } + var html = '
'; + 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 += '
E-mail
' + channelList(emails) + '
'; + } + 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 += '
Telefoon
' + channelList(phones) + '
'; + } + 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 += '
Adres
' + channelList(r.addresses) + '
'; + } + if (r.social && r.social.length) { + html += '
Social
' + channelList(r.social) + '
'; + } + if (r.maps_url) { + html += ''; + } + html += contactPeopleBlock(r.contact_people); + html += '
'; + 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 '' + label + ''; + }).filter(Boolean).join(''); + if (!links) return ''; + return ( + '
' + + 'Websites' + + '' + links + '' + + '
' + ); + } + function halalColor(p) { var tier = p.halal_tier || '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 id = p.id; - var web = p.website; - if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web; var halalRow = ''; 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 = []; if (p.email) contactBits.push('📧 ' + escapeHtml(p.email)); @@ -75,7 +181,7 @@ window.ExportIntelMap = (function () { return ( '
' + '
' + - '' + c.icon + ' ' + escapeHtml(c.label) + '' + + '' + c.icon + ' ' + escapeHtml(c.label) + '' + '' + escapeHtml(p.country_iso2 || '') + '' + '
' + '

' + escapeHtml(p.name) + '

' + @@ -86,7 +192,7 @@ window.ExportIntelMap = (function () { row('Adres', p.address_line) + row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) + row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) + - row('Website', p.website, web) + + websitesBlock(p) + row('Volume', p.volume_band) + row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) + row('Confidence', p.confidence != null ? p.confidence + '%' : null) + @@ -97,12 +203,12 @@ window.ExportIntelMap = (function () { '
' + '' ); - } + }; - function pinIcon(type, p) { + MapInstance.prototype.pinIcon = function (type, p) { var c = cfg(type); - var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color; - var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : ''; + var color = this.halalMode && p && p.halal_score != null ? halalColor(p) : c.color; + var ring = this.halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : ''; return L.divIcon({ className: 'ei-pin-wrap', html: @@ -114,22 +220,58 @@ window.ExportIntelMap = (function () { iconAnchor: [15, 15], popupAnchor: [0, -16], }); - } + }; - function openEntityId(rawId) { + MapInstance.prototype.openEntityId = function (rawId) { var id = Number(rawId); if (!Number.isFinite(id)) return; - if (onEntityClick) { - onEntityClick(id); + if (this.onEntityClick) { + this.onEntityClick(id); return; } try { document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } })); } 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; + var self = this; var popupRoot = container.closest('.leaflet-popup') || container; if (window.L && L.DomEvent) { L.DomEvent.disableClickPropagation(popupRoot); @@ -143,7 +285,7 @@ window.ExportIntelMap = (function () { e.preventDefault(); e.stopPropagation(); } - openEntityId(id); + self.openEntityId(id); }; if (window.L && L.DomEvent) { L.DomEvent.on(btn, 'click', onBtn); @@ -156,38 +298,39 @@ window.ExportIntelMap = (function () { container.addEventListener('click', function (e) { if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return; 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() { - if (!map) return; + MapInstance.prototype.invalidateSize = function () { + if (!this.map) return; + var map = this.map; map.invalidateSize(true); setTimeout(function () { if (map) map.invalidateSize(true); }, 120); setTimeout(function () { if (map) map.invalidateSize(true); }, 400); - setTimeout(function () { if (map) map.invalidateSize(true); }, 800); - } + }; - function init(containerId) { - var el = document.getElementById(containerId); + MapInstance.prototype.init = function () { + var el = document.getElementById(this.containerId); if (!el || !window.L) return null; - if (map && map.getContainer() === el) { - invalidateMapSize(); - return map; + if (this.map && this.map.getContainer() === el) { + this.invalidateSize(); + return this.map; } - if (map) { - map.remove(); - map = null; - clusterLayer = null; + if (this.map) { + this.map.remove(); + this.map = 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', { attribution: '© OSM © CARTO', maxZoom: 19, - }).addTo(map); + }).addTo(this.map); if (window.L.markerClusterGroup) { - clusterLayer = L.markerClusterGroup({ + this.clusterLayer = L.markerClusterGroup({ maxClusterRadius: 42, spiderfyOnMaxZoom: true, showCoverageOnHover: false, @@ -202,100 +345,168 @@ window.ExportIntelMap = (function () { }); }, }); - map.addLayer(clusterLayer); + this.map.addLayer(this.clusterLayer); } else { - clusterLayer = L.layerGroup(); - map.addLayer(clusterLayer); + this.clusterLayer = L.layerGroup(); + this.map.addLayer(this.clusterLayer); } - map.on('popupopen', function (e) { + this.map.on('popupopen', function (e) { var root = e.popup && e.popup.getElement(); if (!root) return; if (window.L && L.DomEvent) { L.DomEvent.disableClickPropagation(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(); - return map; - } + this.invalidateSize(); + return this.map; + }; - function setFeatures(geojson, opts) { + MapInstance.prototype.setFeatures = function (geojson, opts) { opts = opts || {}; - if (!map || !clusterLayer) return; - clusterLayer.clearLayers(); - markersById = {}; + if (!this.map || !this.clusterLayer) return; + this.clusterLayer.clearLayers(); + this.markersById = {}; var features = (geojson && geojson.features) || []; var markers = []; + var self = this; features.forEach(function (f) { var coords = f.geometry && f.geometry.coordinates; if (!coords) return; 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; - if (p.id != null) markersById[p.id] = marker; - marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' }); - marker.on('dblclick', function () { - if (onEntityClick && p.id) onEntityClick(p.id); + if (p.id != null) self.markersById[p.id] = marker; + marker.bindPopup(self.popupHtml(p), { maxWidth: 360, minWidth: 280, maxHeight: 420, className: 'ei-leaflet-popup', autoClose: false, closeOnClick: false }); + marker.on('click', function (e) { + if (window.L && L.DomEvent) L.DomEvent.stopPropagation(e); + marker.openPopup(); + if (self.onEntityClick && p.id != null) self.onEntityClick(p.id); }); markers.push(marker); }); - if (clusterLayer.addLayers) clusterLayer.addLayers(markers); - else markers.forEach(function (m) { clusterLayer.addLayer(m); }); + if (this.clusterLayer.addLayers) this.clusterLayer.addLayers(markers); + else markers.forEach(function (m) { self.clusterLayer.addLayer(m); }); - invalidateMapSize(); + this.invalidateSize(); if (opts.autoFit === false) { - map.setView([20, 10], 2); + this.map.setView([20, 10], 2); } else if (features.length === 1) { var c = features[0].geometry.coordinates; - map.setView([c[1], c[0]], 10); - } else if (opts.autoFit && markers.length > 1 && clusterLayer.getBounds) { + this.map.setView([c[1], c[0]], 10); + } else if (opts.autoFit && markers.length > 1 && this.clusterLayer.getBounds) { try { - map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 }); + this.map.fitBounds(this.clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 }); } catch (e) { /* empty */ } } - } + }; - function setHalalMode(on) { - halalMode = !!on; - } + MapInstance.prototype.setHalalMode = function (on) { + this.halalMode = !!on; + }; - function setOnEntityClick(fn) { - onEntityClick = fn; - } + MapInstance.prototype.setOnEntityClick = function (fn) { + this.onEntityClick = typeof fn === 'function' ? fn : null; + }; - function flyTo(lat, lon, zoom) { - if (map && lat && lon) map.setView([lat, lon], zoom || 10); - } + MapInstance.prototype.flyTo = function (lat, lon, zoom) { + if (this.map && lat && lon) this.map.setView([lat, lon], zoom || 10); + }; - function highlightEntity(id) { - var marker = markersById[id]; - if (!marker || !map) return; + MapInstance.prototype.highlightEntity = function (id) { + var marker = this.markersById[id]; + if (!marker || !this.map) return; var latlng = marker.getLatLng(); - map.setView(latlng, Math.max(map.getZoom(), 14)); + this.map.setView(latlng, Math.max(this.map.getZoom(), 14)); marker.openPopup(); - } + }; - function closePopup() { - if (map) map.closePopup(); - } + MapInstance.prototype.closePopup = function () { + if (this.map) this.map.closePopup(); + }; - function destroy() { - if (map) { - map.remove(); - map = null; - clusterLayer = null; + 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 getOrCreate(containerId) { + var id = resolveId(containerId); + if (!instances[id]) instances[id] = new MapInstance(id); + return instances[id]; + } + + function init(containerId) { + return getOrCreate(containerId).init(); + } + + function setFeatures(geojson, opts, containerId) { + getOrCreate(containerId).setFeatures(geojson, opts); + } + + function setHalalMode(on, containerId) { + getOrCreate(containerId).setHalalMode(on); + } + + function setOnEntityClick(fn, containerId) { + getOrCreate(containerId).setOnEntityClick(fn); + } + + function flyTo(lat, lon, zoom, containerId) { + getOrCreate(containerId).flyTo(lat, lon, zoom); + } + + function highlightEntity(id, containerId) { + getOrCreate(containerId).highlightEntity(id); + } + + function closePopup(containerId) { + getOrCreate(containerId).closePopup(); + } + + function destroy(containerId) { + if (containerId) getOrCreate(containerId).destroy(); + else { + Object.keys(instances).forEach(function (k) { instances[k].destroy(); }); + } + } + + function invalidateAll() { + Object.keys(instances).forEach(function (k) { instances[k].invalidateSize(); }); } if (typeof window !== 'undefined') { - 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, + }; })(); diff --git a/cockpit/static/js/export-intel-profile.js b/cockpit/static/js/export-intel-profile.js new file mode 100644 index 0000000..8f3cd0c --- /dev/null +++ b/cockpit/static/js/export-intel-profile.js @@ -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 '

Geen telefoon of e-mail in bron (' + esc(src) + '). Gratis opties: Bewerken (handmatig), of Sync contacten (scant websites) / Sync distributeurs (haalt OSM-tags opnieuw).

'; + } + + function reachSectionHtml(e) { + var r = e.reach || {}; + var html = ''; + function links(title, items, render) { + if (!items || !items.length) return; + html += '
' + esc(title) + '
'; + for (var i = 0; i < items.length; i++) html += render(items[i]); + html += '
'; + } + links('E-mail', r.emails, function (it) { + return '
' + esc(it.label || 'E-mail') + '' + esc(it.value) + '
'; + }); + links('Telefoon', r.phones, function (it) { + return '
' + esc(it.label || 'Tel') + '' + esc(it.value) + '
'; + }); + if (!r.emails || !r.emails.length) { + if (e.email) html += '
E-mail' + esc(e.email) + '
'; + } + if (!r.phones || !r.phones.length) { + if (e.phone) html += '
Telefoon' + esc(e.phone) + '
'; + } + html += websitesHtml(e); + links('Adres', r.addresses, function (it) { + return '
' + esc(it.label || 'Adres') + '' + esc(it.value) + '
'; + }); + links('Social', r.social, function (it) { + return '
' + esc(it.label || 'Link') + '' + esc(it.value) + '
'; + }); + if (r.maps_url) { + html += '
MapsGoogle Maps →
'; + } + 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 += '
' + esc(s.label || 'Website') + '' + + '' + esc(s.display || s.url || href) + '
'; + } + return rows; + } + + function esc(s) { + if (s == null) return ''; + return String(s) + .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 = + '
' + + '' + + '

Profiel laden…

'; + }, + + renderError: function (msg) { + var m = mountEl(); + if (!m) return; + m.innerHTML = + '
' + + '

' + esc(msg || 'Laden mislukt') + '

' + + '
'; + }, + + 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 += + '
' + + '' + esc(c.name || c.role || 'Contact') + '' + + (c.role ? '' + esc(c.role) + '' : '') + + (c.email ? '
📧 ' + esc(c.email) + '
' : '') + + (c.phone ? '
📞 ' + esc(c.phone) + '
' : '') + + (c.website_contact_url ? '
🌐 ' + esc(c.website_contact_url) + '
' : '') + + (c.linkedin_url ? '
in LinkedIn
' : '') + + '
'; + } + + var halalTags = ''; + var labels = e.halal_reason_labels || []; + for (var j = 0; j < labels.length; j++) { + halalTags += '' + esc(labels[j]) + ''; + } + + var crmBtn = e.client_id + ? '✓Open CRM' + + esc(e.crm_client_name || ('Klant #' + e.client_id)) + '' + : ''; + + var mapsBtn = e.maps_url + ? '📍MapsLocatie' + : ''; + + var dealBtn = e.deal_id + ? '💼DealPipeline' + : ''; + + var favOn = !!e.is_favorite; + + m.innerHTML = + '
' + + '
' + + '
' + + '' + esc(typeLabel(e.entity_type)) + '' + + '' + esc(e.country_iso2) + '' + + (e.client_id ? 'In CRM' : '') + + (favOn ? '★ Favoriet' : '') + + (e.halal_score != null ? '' + Math.round(Number(e.halal_score)) + '/100' : '') + + '
' + + '' + + '
' + + '

' + esc(e.name) + '

' + + '

' + esc(territoryName(e.country_iso2)) + + ' · Confidence ' + (e.confidence || 0) + '%' + + ' · Pipeline ' + esc(e.pipeline_stage || '—') + '' + + ' · Bron ' + esc(e.source || '—') + '

' + + '
' + + '
' + + crmBtn + + '' + + '' + + mapsBtn + + '' + + dealBtn + + '📋Herman' + + '
' + + '
' + + (halalTags ? '
' + halalTags + '
' : '') + + ((e.city || e.address_line || (e.lat && e.lon)) + ? '

Locatie

' + + (e.address_line ? '

' + esc(e.address_line) + '

' : '') + + (e.city ? '

' + esc(e.city) + (e.postal_code ? ', ' + esc(e.postal_code) : '') + '

' : '') + + ((e.lat && e.lon) + ? '

' + esc(formatCoords(e.lat, e.lon)) + + (e.maps_url ? ' Maps →' : '') + '

' + : '') + + '
' + : '') + + '

Contact & web

' + + reachSectionHtml(e) + contactGapHint(e) + + ((!e.reach || !e.reach.has_any) && !e.email && !e.phone && !(e.websites && e.websites.length) && !e.website && !contacts.length ? '

Geen contact — sync contacten om te verrijken.

' : '') + + '
' + + (e.client_id + ? '

CRM

' + + '' + + (e.crm_deal_title ? '
Deal' + esc(e.crm_deal_title) + '
' : '') + + '
' + : '') + + '

Contactpersonen (' + contacts.length + ')

' + + (contactRows || '

Geen contactpersonen.

') + + '
'; + + 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 = + '
' + + '

Bewerken · ' + esc(e.name) + '

' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + }, + + 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); diff --git a/cockpit/static/js/export-intel.js b/cockpit/static/js/export-intel.js index 83d95a1..e52ff38 100644 --- a/cockpit/static/js/export-intel.js +++ b/cockpit/static/js/export-intel.js @@ -5,6 +5,12 @@ function exportIntelApp() { tabIndicatorH: 40, busy: false, syncMsg: '', + syncMonitor: null, + syncMonitorOpen: true, + syncMonitorPoll: null, + googleMapsKey: '', + googleMapsKeySet: false, + googleKeySaving: false, stats: {}, regions: [], territories: [], @@ -27,6 +33,7 @@ function exportIntelApp() { mapHalalMin: 55, mapError: '', mapLoading: false, + tabMapLoading: false, mapTruncated: false, halalTopMarkets: [], halalTopEntities: [], @@ -37,6 +44,11 @@ function exportIntelApp() { filterHasEmail: '', filterFavoritesOnly: false, filterCrmStatus: '', + filterConfidenceMin: '', + filterCity: '', + filterTenderStatus: '', + filterGovCategory: '', + _cityTimer: null, selectedIds: [], crmPipeline: [], crmModalOpen: false, @@ -58,12 +70,65 @@ function exportIntelApp() { { 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: [ - { v: 'distributor', l: 'Distributeur', icon: '📦' }, + { v: 'distributor', l: 'B2B / distri', icon: '📦' }, { v: 'wholesaler', l: 'Groothandel', icon: '🏪' }, { v: 'importer', l: 'Importeur', icon: '🚢' }, { v: 'logistics', l: 'Logistiek', icon: '🚚' }, - { v: 'restaurant', l: 'Restaurant', icon: '🍽️' }, + { v: 'restaurant', l: 'Horeca', icon: '🍽️' }, { v: 'doner_shoarma', l: 'Döner', icon: '🥙' }, { v: 'butcher', l: 'Slager', icon: '🥩' }, { v: 'contract_caterer', l: 'Cateraar', icon: '🏢' }, @@ -75,11 +140,13 @@ function exportIntelApp() { var t = params.get('tab'); if (t) this.tab = t; var self = this; + window.__exportIntelApp = self; document.addEventListener('export-intel:open-entity', function (ev) { if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id); }); await this.loadMeta(); await this.refresh(); + this.startSyncMonitor(true); this.$nextTick(() => this.updateTabIndicator()); window.addEventListener('keydown', (e) => { if (e.target.matches('input, textarea, select')) return; @@ -94,6 +161,7 @@ function exportIntelApp() { async loadMeta() { try { 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()); } catch (e) { console.error(e); @@ -141,6 +209,10 @@ function exportIntelApp() { this.filterHasEmail = ''; this.filterFavoritesOnly = false; this.filterCrmStatus = ''; + this.filterConfidenceMin = ''; + this.filterCity = ''; + this.filterTenderStatus = ''; + this.filterGovCategory = ''; this.selectedIds = []; this.applyMapFilters(); }, @@ -153,6 +225,14 @@ function exportIntelApp() { 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) { return this.mapTypesSelected.indexOf(v) >= 0; }, @@ -170,14 +250,6 @@ function exportIntelApp() { this.applyMapFilters(); }, - mapTypesParam() { - if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter }; - if (this.mapTypesSelected.length) { - return { entity_types: this.mapTypesSelected.join(',') }; - } - return {}; - }, - applyMapFilters() { if (this.tab !== 'map') { this.loadTabData(); @@ -187,11 +259,31 @@ function exportIntelApp() { }, showEmailFilter() { - return this.tab === 'contacts'; + return ['map', 'distributors', 'customers', 'caterers', 'contacts'].indexOf(this.tab) >= 0; }, 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() { @@ -210,6 +302,75 @@ function exportIntelApp() { 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() { if (this.tab === 'distributors') { return [ @@ -232,6 +393,15 @@ function exportIntelApp() { if (this.tab === 'caterers') { 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') { return [ { 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) { opts = opts || {}; var base = '/api/export-intel'; @@ -260,20 +515,18 @@ function exportIntelApp() { if (this.tab === 'map') { return; } else if (this.tab === 'distributors') { - var types = 'distributor,wholesaler,importer,logistics'; - var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : ''; - var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json()); + var types = this.expandEntityTypes(this.entityTypeFilter || 'b2b').join(','); + var r = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json()); this.entities = r.items || []; this.resultCount = r.total != null ? r.total : this.entities.length; } else if (this.tab === 'customers') { - var types2 = 'restaurant,doner_shoarma,butcher,foodservice'; - var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : ''; - var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json()); + var types2 = this.expandEntityTypes(this.entityTypeFilter || 'horeca').join(','); + var r2 = await fetch(base + '/entities?entity_types=' + encodeURIComponent(types2) + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json()); this.entities = r2.items || []; this.resultCount = r2.total != null ? r2.total : this.entities.length; } else if (this.tab === 'caterers') { - var et3 = this.entityTypeFilter || 'contract_caterer'; - var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json()); + var types3 = this.expandEntityTypes(this.entityTypeFilter || 'contract_caterer').join(','); + 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.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()); @@ -297,6 +550,9 @@ function exportIntelApp() { var ql = this.q.trim().toLowerCase(); 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.resultCount = items.length; } else if (this.tab === 'gov') { @@ -313,6 +569,9 @@ function exportIntelApp() { this.crmPipeline = pipe.items || []; this.resultCount = this.crmPipeline.length; } + if (['distributors','customers','caterers','contacts','pipeline'].indexOf(this.tab) >= 0) { + this.$nextTick(() => this.loadTabMap()); + } }, async loadMap() { @@ -355,16 +614,25 @@ function exportIntelApp() { if (!document.getElementById('ei-map')) { this.mapLoading = false; return; } var self = this; window.ExportIntelMap.init('ei-map'); - window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); }); - window.ExportIntelMap.setHalalMode(this.mapHalalMode); - var autoFit = !!(this.country || this.region); - window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit }); + window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); }, 'ei-map'); + window.ExportIntelMap.setHalalMode(this.mapHalalMode, 'ei-map'); + var feats = (bundle.entities && bundle.entities.features) || []; + var autoFit = this.shouldAutoFitMap(feats.length); + window.ExportIntelMap.setFeatures(bundle.entities || { features: [] }, { autoFit: autoFit }, 'ei-map'); if (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; + var self2 = this; + this.$nextTick(function () { + requestAnimationFrame(function () { + if (window.ExportIntelMap && window.ExportIntelMap.invalidateAll) { + window.ExportIntelMap.invalidateAll(); + } + }); + }); }, setTab(id, el) { @@ -378,6 +646,9 @@ function exportIntelApp() { history.replaceState(null, '', u.pathname + u.search); this.loadTabData(); 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)); }, @@ -440,34 +711,20 @@ function exportIntelApp() { this.listFocusDetail = null; }, - async openEntity(id) { + openEntity(id) { if (!id) return; - this.drawerLoading = true; - this.drawerOpen = true; - try { - if (this.listFocusDetail && this.listFocusDetail.id === id) { - this.selected = this.listFocusDetail; - } else { - this.selected = await this.fetchEntityDetail(id); - this.listFocusId = id; - this.listFocusDetail = this.selected; - } - if (window.ExportIntelMap && this.selected.lat && this.selected.lon) { - window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14); - window.ExportIntelMap.highlightEntity(this.selected.id); - } - if (window.ExportIntelMap && window.ExportIntelMap.closePopup) { - window.ExportIntelMap.closePopup(); - } - } catch (e) { - this.syncMsg = 'Profiel laden mislukt: ' + e.message; - this.drawerOpen = false; - this.selected = null; + if (window.ExportIntelProfile && typeof window.ExportIntelProfile.open === 'function') { + window.ExportIntelProfile.open(id); + return; } - this.drawerLoading = false; + this.syncMsg = 'Profiel-module niet geladen — ververs de pagina'; }, closeDrawer() { + if (window.ExportIntelProfile && typeof window.ExportIntelProfile.close === 'function') { + window.ExportIntelProfile.close(); + return; + } this.drawerOpen = false; this.selected = null; this.drawerLoading = false; @@ -528,7 +785,7 @@ function exportIntelApp() { async syncKind(kind) { 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) { this.syncMsg = 'Selecteer eerst een regio in de filterbalk'; @@ -542,7 +799,12 @@ function exportIntelApp() { region_code: kind === 'region' ? this.region : (this.region || null), 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 r = await fetch('/api/export-intel/sync/' + endpoint, { method: 'POST', @@ -604,7 +866,128 @@ function exportIntelApp() { }, 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) { @@ -644,7 +1027,7 @@ function exportIntelApp() { } return; } - var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities; + var list = this.visibleEntities(); if (!list.length) return; var allSelected = list.every((e) => this.isSelected(e.id)); if (allSelected) { diff --git a/cockpit/templates/base.html b/cockpit/templates/base.html index 964c400..afc1524 100644 --- a/cockpit/templates/base.html +++ b/cockpit/templates/base.html @@ -24,7 +24,7 @@ {% block extra_head %}{% endblock %} - + diff --git a/cockpit/templates/export_intel.html b/cockpit/templates/export_intel.html index 6777022..ede709c 100644 --- a/cockpit/templates/export_intel.html +++ b/cockpit/templates/export_intel.html @@ -4,11 +4,13 @@ - - + + - + + + {% endblock %} {% block content %}
@@ -16,7 +18,7 @@

-
+
+ +
+ +
+
+ Sync voortgang + ● Live +
+ + +
+
Entiteiten
+
Landen met data
+
Lege landen
+
+
+
Laatste stappen
+ +
+
+
+

@@ -98,8 +142,8 @@
-
-