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 '
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 += 'Profiel laden…
' + esc(msg || 'Laden mislukt') + '
' + + '' + 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 →' : '') + '
' + : '') + + 'Geen contact — sync contacten om te verrijken.
' : '') + + 'Geen contactpersonen.
') + + '