2026-06-09 00:41:27 +00:00
<!DOCTYPE html>
< html lang = "nl" >
< head >
< meta charset = "UTF-8" />
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=5" />
< meta name = "theme-color" content = "#0a0e14" />
< meta name = "mobile-web-app-capable" content = "yes" />
< meta name = "apple-mobile-web-app-capable" content = "yes" />
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" />
< meta name = "apple-mobile-web-app-title" content = "Foodlinkk" />
2026-06-23 10:04:23 +00:00
< title > {% block title %}{{ page_title }} · Cucina{% endblock %}</ title >
2026-07-19 18:49:52 +00:00
< link rel = "preconnect" href = "https://fonts.googleapis.com" />
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin />
< link rel = "stylesheet" href = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
2026-08-16 11:01:58 +00:00
< link rel = "manifest" href = "/manifest.webmanifest" />
< link rel = "manifest" href = "/static/manifest.json?v=3" />
< meta name = "application-name" content = "Foodlinkk" />
< meta name = "format-detection" content = "telephone=no" />
< link rel = "icon" href = "/static/icons/icon-192.png" type = "image/png" />
2026-06-09 00:41:27 +00:00
< link rel = "icon" href = "/static/icons/app-icon.svg" type = "image/svg+xml" />
2026-08-16 11:01:58 +00:00
< link rel = "apple-touch-icon" href = "/static/icons/apple-touch-icon.png" sizes = "180x180" />
< link rel = "apple-touch-icon" href = "/static/icons/icon-192.png" sizes = "192x192" />
2026-07-19 18:49:52 +00:00
< link rel = "stylesheet" href = "/static/css/palantir-theme.css?v=4" />
2026-06-23 10:04:23 +00:00
< link rel = "stylesheet" href = "/static/css/pulse-theme.css?v=2" />
< link rel = "stylesheet" href = "/static/css/topnav-neo.css?v=6" />
2026-07-19 18:49:52 +00:00
< link rel = "stylesheet" href = "/static/css/vertical-tabs.css?v=9" />
2026-08-16 11:01:58 +00:00
< link rel = "stylesheet" href = "/static/css/herman-dashboard.css?v=34" />
< script defer src = "/static/js/herman-assistant.js?v=17" ></ script >
< script defer src = "/static/js/agents-warroom.js?v=4" ></ script >
2026-06-23 10:04:23 +00:00
< script defer src = "/static/js/alpine.min.js?v=2" ></ script >
< script defer src = "/static/js/htmx.min.js?v=2" ></ script >
2026-06-09 00:41:27 +00:00
{% block extra_head %}{% endblock %}
2026-08-16 11:01:58 +00:00
< link rel = "stylesheet" href = "/static/css/app-sidebar-neo.css?v=12" />
2026-07-19 18:49:52 +00:00
< link rel = "stylesheet" href = "/static/css/cockpit-typography.css?v=1" />
2026-08-01 17:50:45 +00:00
< link rel = "stylesheet" href = "/static/css/herman-assistant.css?v=16" />
2026-08-16 11:01:58 +00:00
<!-- mobile last: wins over sidebar/dashboard cascade -->
< link rel = "stylesheet" href = "/static/css/mobile.css?v=7" />
< link rel = "stylesheet" href = "/static/css/user-avatar.css?v=3" />
2026-06-09 00:41:27 +00:00
</ head >
2026-06-23 10:04:23 +00:00
< body x-data = "appShell()" x-init = "initApp()" :class = "{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }" >
2026-06-09 00:41:27 +00:00
< div class = "app-shell" >
2026-07-30 11:53:43 +00:00
< aside class = "app-sidebar" :style = "'--sidebar-width:' + sidebarWidth + 'px'" @ click = "if($event.target.closest('a')) navOpen=false" >
< div class = "app-sidebar-resizer" title = "Sleep om breder te maken"
@ mousedown . prevent = "startSidebarResize($event)" ></ div >
2026-06-09 00:41:27 +00:00
< div class = "app-sidebar-brand" >
2026-06-23 10:04:23 +00:00
< p class = "brand-title" > Cucina · Foodlinkk</ p >
< p class = "brand-name" x-text = "brand === 'foodlinkk' ? 'Foodlinkk B2B' : 'Cucina B2C'" > Cucina B2C</ p >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-23 10:04:23 +00:00
< div class = "persona-switch brand-switch" role = "tablist" aria-label = "Brand" >
< button type = "button" role = "tab" class = "persona-btn brand-btn-cucina"
:class = "{ active: brand === 'cucina' }" @ click = "setBrand('cucina')"
:aria-selected = "brand === 'cucina'" > Cucina · B2C</ button >
< button type = "button" role = "tab" class = "persona-btn brand-btn-foodlinkk"
:class = "{ active: brand === 'foodlinkk' }" @ click = "setBrand('foodlinkk')"
:aria-selected = "brand === 'foodlinkk'" > Foodlinkk · B2B</ button >
2026-06-09 00:41:27 +00:00
</ div >
2026-06-23 10:04:23 +00:00
< div class = "persona-nav" >
< div class = "app-sidebar-group" x-show = "brand === 'foodlinkk'" >
< div class = "app-sidebar-label" > Foodlinkk B2B</ div >
2026-08-16 11:01:58 +00:00
< a href = "/export-intel" data-perm = "export" class = "nav-pill nav-pill-wereldexport {% if request.url.path.startswith('/export-intel') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🌍</ span >< span class = "sidebar-nav-label" > Wereldexport</ span ></ a >
< a href = "/clients" data-perm = "clients" class = "nav-pill nav-pill-clients {% if request.url.path.startswith('/clients') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 👥</ span >< span class = "sidebar-nav-label" data-i18n = "nav.clients" > Klanten</ span ></ a >
< a href = "/deals" data-perm = "deals" class = "nav-pill nav-pill-deals {% if request.url.path.startswith('/deals') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🤝</ span >< span class = "sidebar-nav-label" data-i18n = "nav.deals" > Deals</ span ></ a >
< a href = "/products" data-perm = "products" class = "nav-pill nav-pill-products {% if request.url.path.startswith('/products') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📦</ span >< span class = "sidebar-nav-label" data-i18n = "nav.products" > Producten</ span ></ a >
< a href = "/suppliers" data-perm = "suppliers" class = "nav-pill nav-pill-suppliers {% if request.url.path.startswith('/suppliers') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🏭</ span >< span class = "sidebar-nav-label" data-i18n = "nav.suppliers" > Leveranciers</ span ></ a >
< a href = "/reports" data-perm = "reports" class = "nav-pill nav-pill-reports {% if request.url.path.startswith('/reports') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📊</ span >< span class = "sidebar-nav-label" data-i18n = "nav.reports" > Rapporten</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
2026-06-23 10:04:23 +00:00
< div class = "app-sidebar-group" x-show = "brand === 'cucina'" >
< div class = "app-sidebar-label" > Cucina B2C</ div >
2026-08-16 11:01:58 +00:00
< a href = "/" data-perm = "dashboard" class = "nav-pill nav-pill-herman {% if request.url.path == '/' %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🏠</ span >< span class = "sidebar-nav-label" data-i18n = "nav.ceo_home" > Overzicht</ span ></ a >
< a href = "/revenue-cockpit" data-perm = "revenue" class = "nav-pill nav-pill-deals {% if request.url.path.startswith('/revenue-cockpit') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 💰</ span >< span class = "sidebar-nav-label" data-i18n = "nav.revenue_cockpit" > Revenue Cockpit</ span ></ a >
< a href = "/retail" data-perm = "retail" class = "nav-pill nav-pill-retail {% if request.url.path.startswith('/retail') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🛒</ span >< span class = "sidebar-nav-label" data-i18n = "nav.retail" > Retail NL</ span ></ a >
< a href = "/marketing" data-perm = "marketing" class = "nav-pill nav-pill-marketing {% if request.url.path.startswith('/marketing') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📣</ span >< span class = "sidebar-nav-label" data-i18n = "nav.marketing" > Marketing</ span ></ a >
< a href = "/packaging" data-perm = "packaging" class = "nav-pill nav-pill-studio {% if request.url.path.startswith('/packaging') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📦</ span >< span class = "sidebar-nav-label" data-i18n = "nav.packaging" > Packaging</ span ></ a >
< a href = "/beurs" data-perm = "beurs" class = "nav-pill nav-pill-beurs {% if request.url.path.startswith('/beurs') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🎪</ span >< span class = "sidebar-nav-label" data-i18n = "nav.beurs" > Beurs Live</ span ></ a >
< a href = "/documents" data-perm = "documents" class = "nav-pill nav-pill-documents {% if request.url.path.startswith('/documents') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📄</ span >< span class = "sidebar-nav-label" data-i18n = "nav.documents" > Documenten</ span ></ a >
< a href = "/projects" data-perm = "projects" class = "nav-pill nav-pill-documents {% if request.url.path.startswith('/projects') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📁</ span >< span class = "sidebar-nav-label" data-i18n = "nav.projects" > Projecten</ span ></ a >
< a href = "/hermes" data-perm = "hermes" class = "nav-pill nav-pill-telegram {% if request.url.path.startswith('/hermes') %}active{% endif %}" >< span class = "sidebar-nav-icon" > ✈️</ span >< span class = "sidebar-nav-label" data-i18n = "nav.telegram" > Telegram</ span ></ a >
< a href = "/analytics" data-perm = "analytics" class = "nav-pill nav-pill-analytics {% if request.url.path.startswith('/analytics') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 📈</ span >< span class = "sidebar-nav-label" data-i18n = "nav.analytics" > Analytics</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
2026-06-23 10:04:23 +00:00
< div class = "app-sidebar-group" >
< div class = "app-sidebar-label" data-i18n = "nav.platform" > Platform</ div >
2026-08-16 11:01:58 +00:00
< a href = "/ops" data-perm = "ops" class = "nav-pill nav-pill-ops {% if request.url.path.startswith('/ops') %}active{% endif %}" >< span class = "sidebar-nav-icon" > ⚙️</ span >< span class = "sidebar-nav-label" data-i18n = "nav.ops" > IT Ops</ span ></ a >
< a href = "/agents" data-perm = "agents" class = "nav-pill nav-pill-agents {% if request.url.path.startswith('/agents') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🤖</ span >< span class = "sidebar-nav-label" data-i18n = "nav.agents" > Agents</ span ></ a >
< a href = "/browser" data-perm = "browser" class = "nav-pill nav-pill-browser {% if request.url.path.startswith('/browser') or request.url.path.startswith('/monitor') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🌐</ span >< span class = "sidebar-nav-label" data-i18n = "nav.browser" > Browser</ span ></ a >
< a href = "/studio" data-perm = "studio" class = "nav-pill nav-pill-studio {% if request.url.path.startswith('/studio') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🎨</ span >< span class = "sidebar-nav-label" data-i18n = "nav.studio" > Studio</ span ></ a >
< a href = "/voice" data-perm = "voice" class = "nav-pill nav-pill-voice {% if request.url.path.startswith('/voice') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🎙️</ span >< span class = "sidebar-nav-label" data-i18n = "nav.voice" > Voice</ span ></ a >
< a href = "/settings" data-perm = "settings" class = "nav-pill nav-pill-settings {% if request.url.path.startswith('/settings') %}active{% endif %}" >< span class = "sidebar-nav-icon" > 🔧</ span >< span class = "sidebar-nav-label" data-i18n = "nav.settings" > Instellingen</ span ></ a >
2026-06-23 10:04:23 +00:00
</ div >
2026-06-09 00:41:27 +00:00
</ div >
</ aside >
< div class = "mobile-nav-overlay" x-show = "navOpen" @ click = "navOpen=false" x-cloak ></ div >
< div class = "app-main" >
< header class = "app-topbar" >
< button type = "button" class = "mobile-menu-btn" @ click = "navOpen = !navOpen" aria-label = "Menu openen" > ☰</ button >
2026-06-23 10:04:23 +00:00
< span class = "page-tip" >
< span class = "persona-badge" :class = "brand === 'foodlinkk' ? 'brand-badge-foodlinkk' : 'brand-badge-cucina'" x-text = "brand === 'foodlinkk' ? 'B2B' : 'B2C'" ></ span >
Cucina · Foodlinkk
</ span >
2026-06-09 10:41:13 +00:00
< select class = "form-input" style = "width:auto;min-width:110px;padding:0.2rem 0.5rem;font-size:0.72rem;margin:0 0.5rem"
x-model = "locale" @ change = "setLocale(locale)" :title = "$t ? $t('common.language') : 'Taal'" >
< option value = "nl" > NL</ option >
< option value = "en" > EN</ option >
</ select >
2026-06-09 00:41:27 +00:00
< div class = "app-clock" >< strong x-text = "clockDate" > —</ strong > · < span x-text = "clockTime" > —</ span ></ div >
2026-08-16 11:01:58 +00:00
{% set cu = request.state.user if request is defined else none %}
< div class = "app-user-chip" {% if not cu %} x-show = "authUser" x-cloak {% endif %}
x-data = "userChip()" x-init = "bootAvatar()" >
< button type = "button" class = "user-avatar-btn" title = "Profielfoto wijzigen" @ click = "pickAvatar()" >
{% if cu and cu.avatar_url %}
< img class = "user-avatar-img" src = "{{ cu.avatar_url }}"
:src = "photoUrl || '{{ cu.avatar_url }}'" alt = "" width = "36" height = "36" />
{% else %}
< img class = "user-avatar-img" x-show = "photoUrl" :src = "photoUrl" alt = "" width = "36" height = "36" />
< span class = "user-avatar-fallback" x-show = "!photoUrl" x-text = "initials" > ?</ span >
{% endif %}
</ button >
< input type = "file" x-ref = "avatarFile" accept = "image/png,image/jpeg,image/webp,image/gif"
class = "user-avatar-input" style = "display:none!important;position:absolute;width:0;height:0;opacity:0" tabindex = "-1" aria-hidden = "true" @ change = "onFile($event)" />
< div class = "user-avatar-meta" >
< span class = "user-avatar-name" x-text = "name" > {% if cu %}{{ cu.display_name or cu.username }}{% endif %}</ span >
< span class = "user-avatar-role" x-text = "role" > {% if cu %}{{ cu.role }}{% endif %}</ span >
</ div >
< a class = "btn btn-sm btn-secondary user-logout-btn" href = "/logout" > Uitloggen</ a >
</ div >
2026-06-09 00:41:27 +00:00
</ header >
< main class = "main-topnav" > {% block content %}{% endblock %}</ main >
</ div >
</ div >
2026-08-16 11:01:58 +00:00
< div class = "pwa-install-bar" x-show = "showInstall" x-cloak
:class = "{ 'is-ios': isIos, 'is-android': isAndroid }"
style = "display:none" >
< p > Installeer Foodlinkk op je beginscherm</ p >
< p class = "pwa-install-hint is-ios-hint" > iPhone/iPad (Safari): Deel ↗ → < strong > Zet op beginscherm</ strong > . Open via < strong > http://10.4.7.18:8610</ strong > — geen certificaatmelding.</ p >
< p class = "pwa-install-hint is-android-hint" > Android (Chrome): menu ⋮ → < strong > Toevoegen aan startscherm</ strong > . Open via < strong > http://10.4.7.18:8610</ strong > .</ p >
< button type = "button" class = "btn btn-sm btn-pulse-green" @ click = "installPwa()" > Uitleg</ button >
< button type = "button" class = "btn btn-sm" @ click = "dismissPwa()" > Later</ button >
2026-06-09 00:41:27 +00:00
</ div >
2026-08-01 17:50:45 +00:00
<!-- Herman chat — altijd vast onderin zichtbaar -->
< div id = "ha-dock" class = "ha-dock" x-data = "hermanBrowserWidget()" x-init = "initWidget()" :class = "{ 'ha-dock-hidden': hidden }" >
< button type = "button" class = "ha-fab"
:class = "{ 'is-open': open, 'is-busy': busy, 'is-dragging': fabDragging, 'has-custom-pos': !!fabPos }"
:style = "fabStyle()"
@ pointerdown . prevent = "onFabPointerDown($event)"
@ pointermove = "onFabPointerMove($event)"
@ pointerup = "onFabPointerUp($event)"
@ pointercancel = "onFabPointerUp($event)"
@ click = "onFabClick($event)"
@ dblclick = "onFabDblClick($event)"
title = "Slepen om te verplaatsen · klik om te openen · dubbelklik = reset"
aria-label = "Open Herman chat" >
< span class = "ha-fab-icon" aria-hidden = "true" > 💬</ span >
< span class = "ha-fab-label" x-text = "open ? 'Sluiten' : 'Herman'" ></ span >
</ button >
< div class = "ha-panel" :class = "{ 'has-custom-pos': !!fabPos }" :style = "panelStyle()" x-show = "open" x-cloak x-transition . opacity . duration . 150ms @ keydown . escape . window = "open = false" >
2026-06-23 10:04:23 +00:00
< header class = "ha-head" >
2026-08-01 17:50:45 +00:00
< div class = "ha-head-title" >
< h2 > Herman</ h2 >
< p class = "ha-conn" :class = "{ 'is-ok': connOk, 'is-err': connOk === false }" x-text = "connLabel" ></ p >
</ div >
2026-06-23 10:04:23 +00:00
< div class = "ha-head-actions" >
2026-08-01 17:50:45 +00:00
< a x-show = "!micSecureOk()" class = "ha-text-btn" :href = "httpsChatUrl()" target = "_blank" rel = "noopener" title = "Audio/microfoon in HTTPS-tab" > Audio</ a >
< button type = "button" class = "ha-text-btn" @ click = "clearChat()" title = "Nieuwe chat" :disabled = "busy || (!turns.length && !manualText)" > Wissen</ button >
2026-08-16 11:01:58 +00:00
< button type = "button" class = "ha-text-btn" @ click = "openWarRoomPopup()" title = "War Room op tweede scherm" > War Room</ button >
< a href = "/agents?tab=warroom" > Agents</ a >
2026-08-01 17:50:45 +00:00
< a href = "/herman" > Volledig</ a >
2026-06-23 10:04:23 +00:00
< button type = "button" @ click = "open = false" aria-label = "Sluiten" > × </ button >
</ div >
</ header >
< div class = "ha-confirm" x-show = "pendingAction" >
< span x-text = "pendingAction ? (pendingAction.type === 'webbuilder_build' ? ('Agy website: ' + (pendingAction.project || '')) : ('Zoeken: ' + (pendingAction.entity_label || '') + ' in ' + (pendingAction.location_label || ''))) : ''" ></ span >
< div class = "ha-confirm-actions" >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "confirmPending()" :disabled = "busy" > Ja</ button >
< button type = "button" class = "btn btn-sm" @ click = "cancelPending()" :disabled = "busy" > Nee</ button >
</ div >
</ div >
< div class = "ha-log" x-ref = "chatLog" >
< template x-for = "(t, i) in turns" :key = "i" >
< div class = "ha-bubble" :class = "t.role === 'user' ? 'ha-bubble-user' : 'ha-bubble-agent'" >
2026-08-01 17:50:45 +00:00
< div class = "ha-bubble-meta" x-text = "t.at + (t.role === 'user' ? ' · jij' : (' · ' + ((t.meta && t.meta.agent_label) || 'Herman')))" ></ div >
2026-06-23 10:04:23 +00:00
< p x-text = "t.text" ></ p >
</ div >
</ template >
2026-08-01 17:50:45 +00:00
< p x-show = "!turns.length" class = "page-tip" style = "font-size:0.75rem;margin:0" > Chat met Herman op de achtergrond — bv. “zoek distributeurs in Dubai”</ p >
2026-06-23 10:04:23 +00:00
</ div >
< div class = "ha-preview" :class = "{ 'is-interim': liveInterim }" x-show = "livePreview || recording" x-text = "livePreview || 'Luisteren…'" ></ div >
2026-08-01 17:50:45 +00:00
< div class = "ha-toggles" >
< label title = "Alleen voorlezen als jij dit aanzet" >
< input type = "checkbox" x-model = "speakReply" @ change = "persistPrefs()" />
< span > Voorlezen (Maarten)</ span >
</ label >
< label title = "Na antwoord meteen weer luisteren" >
< input type = "checkbox" x-model = "handsFreeMode" @ change = "onHandsFreeChange()" />
< span > Hands-free praten</ span >
</ label >
</ div >
< p class = "ha-status" :class = "{ 'is-error': status === 'error' }" x-show = "statusDetail || busy" x-text = "statusDetail || (busy ? 'Herman denkt…' : '')" ></ p >
< div class = "ha-https-prompt" x-show = "httpsAudioPrompt" x-cloak >
< span > Microfoon werkt via HTTPS</ span >
< button type = "button" class = "btn btn-sm btn-pulse" @ click = "goHttpsAudio()" > Doorgaan naar HTTPS →</ button >
< button type = "button" class = "ha-text-btn" @ click = "httpsAudioPrompt = false" title = "Sluiten" > × </ button >
</ div >
< form class = "ha-input-row" @ submit . prevent = "sendText()" >
2026-06-23 10:04:23 +00:00
< button type = "button" class = "ha-mic" :class = "micClass()"
@ mousedown . prevent = "onMicDown()" @ mouseup . prevent = "onMicUp()"
@ touchstart . prevent = "onMicDown()" @ touchend . prevent = "onMicUp()"
@ click . prevent = "onMicClick()" title = "Microfoon" > 🎙️</ button >
2026-08-01 17:50:45 +00:00
< input class = "form-input" type = "text" name = "herman_message" autocomplete = "off"
placeholder = "Vraag aan Herman…" x-model = "manualText"
2026-06-23 10:04:23 +00:00
@ keydown . enter . prevent = "sendText()" :disabled = "busy" />
2026-08-01 17:50:45 +00:00
< button type = "submit" class = "btn btn-sm btn-pulse" :disabled = "busy || !manualText.trim()" > →</ button >
</ form >
2026-06-23 10:04:23 +00:00
</ div >
< div class = "ei-modal-root ha-results-modal" x-show = "resultsOpen" @ click . self = "closeResultsModal()" >
< div class = "ei-modal-card ha-results-card" @ click . stop >
< h3 x-text = "resultsTitle" ></ h3 >
< p >< strong x-text = "resultsTotal" ></ strong > resultaten</ p >
< div class = "voice-results-table-wrap" style = "max-height:40vh;overflow:auto" >
< table class = "voice-results-table" style = "width:100%;font-size:0.78rem" >
< thead >< tr >< th > Naam</ th >< th > Type</ th >< th > Stad</ th ></ tr ></ thead >
< tbody >
< template x-for = "(e, i) in resultsEntities" :key = "i" >
< tr >
< td >< a :href = "'/export-intel?open=' + e.id" x-text = "e.name" ></ a ></ td >
< td x-text = "entityTypeLabel(e.entity_type)" ></ td >
< td x-text = "e.city || '—'" ></ td >
</ tr >
</ template >
</ tbody >
</ table >
</ div >
< div class = "ei-modal-actions" >
< a class = "btn btn-sm" :href = "resultsOpenUrl" > Wereldexport</ a >
< button type = "button" class = "btn btn-sm" @ click = "closeResultsModal()" > Sluiten</ button >
</ div >
</ div >
</ div >
< div class = "ei-modal-root ha-results-modal" x-show = "webbuilderOpen" @ click . self = "closeWebbuilderModal()" >
< div class = "ei-modal-card ha-results-card" @ click . stop >
< h3 x-text = "webbuilderTitle" ></ h3 >
< p >< strong x-text = "webbuilderProject" ></ strong > · Agy / Antigravity op Hermes</ p >
< p class = "page-tip" style = "font-size:0.78rem" > Preview na afloop: < a :href = "webbuilderPreview" target = "_blank" rel = "noopener" x-text = "webbuilderPreview" ></ a ></ p >
< p class = "page-tip" x-show = "webbuilderNas" style = "font-size:0.72rem" x-text = "'NAS: ' + webbuilderNas" ></ p >
< div class = "ei-modal-actions" >
< a class = "btn btn-sm btn-pulse" :href = "webbuilderAgentsUrl" > Agents terminal</ a >
< a class = "btn btn-sm" :href = "webbuilderPreview" target = "_blank" rel = "noopener" > Preview</ a >
< button type = "button" class = "btn btn-sm" @ click = "closeWebbuilderModal()" > Sluiten</ button >
</ div >
</ div >
</ div >
</ div >
< script src = "/static/js/i18n.js?v=5" ></ script >
2026-08-16 11:01:58 +00:00
< script src = "/static/js/cockpit.js?v=3" ></ script >
2026-06-09 10:41:13 +00:00
< script src = "/static/js/viz-engine.js?v=3" ></ script >
< script src = "/static/js/global-viz.js?v=1" ></ script >
2026-07-19 18:25:01 +00:00
< script src = "/static/js/live-pulse.js?v=3" ></ script >
2026-06-09 00:41:27 +00:00
< script >
2026-06-23 10:04:23 +00:00
window . FoodlinkkBrand = ( function () {
var FOODLINKK_PREFIXES = [ '/export-intel' , '/foodlinkk' , '/clients' , '/deals' , '/products' , '/suppliers' ];
var CUCINA_PREFIXES = [ '/retail' , '/marketing' , '/packaging' , '/revenue-cockpit' , '/beurs' ];
function isFoodlinkkPath ( path ) {
return FOODLINKK_PREFIXES . some ( function ( p ) { return path === p || path . indexOf ( p + '/' ) === 0 ; });
}
function isCucinaPath ( path ) {
return path === '/' || CUCINA_PREFIXES . some ( function ( p ) { return path === p || path . indexOf ( p + '/' ) === 0 ; });
}
function detect ( path ) {
var stored = localStorage . getItem ( 'foodlinkk-brand' );
if ( stored === 'cucina' || stored === 'foodlinkk' ) return stored ;
if ( isFoodlinkkPath ( path )) return 'foodlinkk' ;
return 'cucina' ;
}
return { isFoodlinkkPath : isFoodlinkkPath , isCucinaPath : isCucinaPath , detect : detect };
})();
/* legacy persona helper → brand */
window . FoodlinkkPersona = window . FoodlinkkBrand ;
2026-08-16 11:01:58 +00:00
function userChip () {
return {
photoUrl : { % if cu and cu . avatar_url % } '{{ cu.avatar_url }}' { % else % } null { % endif % },
name : { % if cu % } '{{ (cu.display_name or cu.username)|e }}' { % else % } '' { % endif % },
role : { % if cu % } '{{ cu.role }}' { % else % } '' { % endif % },
initials : { % if cu % } '{{ ((cu.display_name or cu.username or ' ? ')[0:1])|upper }}' { % else % } '?' { % endif % },
bootAvatar () {
// sync from parent shell when /api/auth/me returns
window . addEventListener ( 'flk-auth-ready' , ( ev ) => {
const u = ev . detail ;
if ( ! u ) return ;
this . photoUrl = u . avatar_url || null ;
this . name = u . display_name || u . username || '' ;
this . role = u . role || '' ;
const n = ( this . name || '?' ). trim ();
const parts = n . split ( /\s+/ ). filter ( Boolean );
this . initials = parts . length >= 2 ? ( parts [ 0 ][ 0 ] + parts [ 1 ][ 0 ]). toUpperCase () : n . slice ( 0 , 1 ). toUpperCase ();
});
},
pickAvatar () { this . $refs . avatarFile && this . $refs . avatarFile . click (); },
async onFile ( ev ) {
const file = ev . target . files && ev . target . files [ 0 ];
ev . target . value = '' ;
if ( ! file ) return ;
const fd = new FormData ();
fd . append ( 'file' , file );
try {
const res = await fetch ( '/api/auth/me/avatar' , { method : 'POST' , body : fd , credentials : 'same-origin' });
const data = await res . json (). catch (() => ({}));
if ( ! res . ok ) throw new Error ( data . detail || 'Upload mislukt' );
this . photoUrl = ( data . avatar_url || ( data . user && data . user . avatar_url )) + '?t=' + Date . now ();
if ( window . Cockpit && Cockpit . toast ) Cockpit . toast ( 'Profielfoto bijgewerkt' , 'success' );
window . dispatchEvent ( new CustomEvent ( 'flk-auth-ready' , { detail : data . user }));
} catch ( e ) {
if ( window . Cockpit && Cockpit . toast ) Cockpit . toast ( e . message || 'Upload mislukt' , 'error' );
else alert ( e . message || 'Upload mislukt' );
}
}
};
}
2026-06-09 00:41:27 +00:00
function appShell () {
return {
2026-06-09 10:41:13 +00:00
locale : 'nl' ,
2026-06-23 10:04:23 +00:00
brand : 'cucina' ,
2026-06-09 00:41:27 +00:00
drawerOpen : false ,
navOpen : false ,
2026-07-30 11:53:43 +00:00
sidebarWidth : 280 ,
2026-06-09 00:41:27 +00:00
showInstall : false ,
2026-08-16 11:01:58 +00:00
authUser : null ,
authPages : [],
2026-06-09 00:41:27 +00:00
_deferredPrompt : null ,
clockDate : '—' ,
clockTime : '—' ,
_offset : 0 ,
2026-08-16 11:01:58 +00:00
isIos : false ,
2026-06-09 10:41:13 +00:00
async initApp () {
2026-06-23 10:04:23 +00:00
this . brand = window . FoodlinkkBrand . detect ( location . pathname );
2026-08-16 11:01:58 +00:00
this . isIos = /iPhone|iPad|iPod/i . test ( navigator . userAgent ) ||
( navigator . platform === 'MacIntel' && navigator . maxTouchPoints > 1 );
// One-time migration off legacy no-op SW
if ( ! localStorage . getItem ( 'flk-pwa-v6' )) {
2026-06-23 10:04:23 +00:00
if ( window . caches ) {
const keys = await caches . keys ();
await Promise . all ( keys . map (( k ) => caches . delete ( k )));
}
2026-08-16 11:01:58 +00:00
localStorage . setItem ( 'flk-pwa-v6' , '1' );
2026-08-01 17:50:45 +00:00
localStorage . setItem ( 'flk-cache-v4' , '1' );
2026-06-23 10:04:23 +00:00
}
2026-06-09 10:41:13 +00:00
if ( window . I18n ) await window . I18n . init ();
this . locale = window . I18n ? window . I18n . locale () : 'nl' ;
2026-07-30 11:53:43 +00:00
this . initSidebarWidth ();
2026-06-09 00:41:27 +00:00
this . initClock ();
this . initPwa ();
2026-08-16 11:01:58 +00:00
await this . initAuth ();
},
async initAuth () {
try {
const r = await fetch ( '/api/auth/me' , { credentials : 'same-origin' });
if ( r . status === 401 ) {
location . href = '/login?next=' + encodeURIComponent ( location . pathname + location . search );
return ;
}
if ( ! r . ok ) return ;
const data = await r . json ();
this . authUser = Object . assign ({}, data . user || null , { _avTick : Date . now () });
this . authPages = ( data . user && data . user . permissions && data . user . permissions . pages ) || [];
this . applyNavPermissions ();
window . dispatchEvent ( new CustomEvent ( 'flk-auth-ready' , { detail : this . authUser }));
} catch ( e ) { /* ignore */ }
},
canPage ( key ) {
if ( ! this . authUser ) return true ;
if ( this . authUser . role === 'admin' ) return true ;
return this . authPages . indexOf ( key ) !== - 1 ;
},
applyNavPermissions () {
if ( ! this . authUser || this . authUser . role === 'admin' ) return ;
document . querySelectorAll ( '.app-sidebar a.nav-pill[data-perm]' ). forEach (( a ) => {
const key = a . getAttribute ( 'data-perm' );
a . style . display = this . canPage ( key ) ? '' : 'none' ;
});
},
avatarInitials () {
const u = this . authUser ;
if ( ! u ) return '?' ;
const n = ( u . display_name || u . username || '?' ). trim ();
const parts = n . split ( /\s+/ ). filter ( Boolean );
if ( parts . length >= 2 ) return ( parts [ 0 ][ 0 ] + parts [ 1 ][ 0 ]). toUpperCase ();
return n . slice ( 0 , 1 ). toUpperCase ();
},
async uploadMyAvatar ( ev ) {
const file = ev . target . files && ev . target . files [ 0 ];
ev . target . value = '' ;
if ( ! file ) return ;
const fd = new FormData ();
fd . append ( 'file' , file );
try {
const res = await fetch ( '/api/auth/me/avatar' , { method : 'POST' , body : fd , credentials : 'same-origin' });
const data = await res . json (). catch (() => ({}));
if ( ! res . ok ) throw new Error ( data . detail || 'Upload mislukt' );
this . authUser = Object . assign ({}, data . user , { _avTick : Date . now () });
if ( window . Cockpit && Cockpit . toast ) Cockpit . toast ( 'Profielfoto bijgewerkt' , 'success' );
} catch ( e ) {
if ( window . Cockpit && Cockpit . toast ) Cockpit . toast ( e . message || 'Upload mislukt' , 'error' );
else alert ( e . message || 'Upload mislukt' );
}
2026-06-09 00:41:27 +00:00
},
2026-07-30 11:53:43 +00:00
initSidebarWidth () {
var saved = parseInt ( localStorage . getItem ( 'flk-sidebar-width' ) || '280' , 10 );
if ( ! isFinite ( saved )) saved = 280 ;
this . sidebarWidth = Math . min ( 420 , Math . max ( 220 , saved ));
document . documentElement . style . setProperty ( '--sidebar-width' , this . sidebarWidth + 'px' );
},
startSidebarResize ( e ) {
var self = this ;
var startX = e . clientX ;
var startW = this . sidebarWidth ;
var handle = e . currentTarget ;
handle . classList . add ( 'is-dragging' );
document . body . classList . add ( 'sidebar-resizing' );
function onMove ( ev ) {
var next = Math . min ( 420 , Math . max ( 220 , startW + ( ev . clientX - startX )));
self . sidebarWidth = next ;
document . documentElement . style . setProperty ( '--sidebar-width' , next + 'px' );
}
function onUp () {
handle . classList . remove ( 'is-dragging' );
document . body . classList . remove ( 'sidebar-resizing' );
localStorage . setItem ( 'flk-sidebar-width' , String ( self . sidebarWidth ));
window . removeEventListener ( 'mousemove' , onMove );
window . removeEventListener ( 'mouseup' , onUp );
}
window . addEventListener ( 'mousemove' , onMove );
window . addEventListener ( 'mouseup' , onUp );
},
2026-06-23 10:04:23 +00:00
setBrand ( b ) {
if ( b === this . brand ) return ;
this . brand = b ;
localStorage . setItem ( 'foodlinkk-brand' , b );
var path = location . pathname ;
if ( b === 'foodlinkk' && window . FoodlinkkBrand . isCucinaPath ( path ) && ! window . FoodlinkkBrand . isFoodlinkkPath ( path )) {
location . href = '/export-intel' ;
return ;
}
if ( b === 'cucina' && window . FoodlinkkBrand . isFoodlinkkPath ( path )) {
location . href = '/' ;
return ;
}
},
2026-06-09 10:41:13 +00:00
async setLocale ( loc ) {
if ( window . I18n ) await window . I18n . setLocale ( loc );
this . locale = loc ;
},
2026-06-09 00:41:27 +00:00
async initClock () {
try {
const r = await fetch ( '/api/server-time' ). then ( x => x . json ());
this . _offset = new Date ( r . utc ). getTime () - Date . now ();
} catch ( e ) { this . _offset = 0 ; }
const tick = () => {
const d = new Date ( Date . now () + this . _offset );
this . clockDate = d . toLocaleDateString ( 'nl-NL' , { timeZone : 'Europe/Amsterdam' , weekday : 'short' , day : 'numeric' , month : 'short' });
this . clockTime = d . toLocaleTimeString ( 'nl-NL' , { timeZone : 'Europe/Amsterdam' , hour : '2-digit' , minute : '2-digit' , second : '2-digit' });
};
tick ();
setInterval ( tick , 1000 );
},
2026-08-16 11:01:58 +00:00
isAndroid : false ,
2026-06-09 00:41:27 +00:00
initPwa () {
2026-08-16 11:01:58 +00:00
const standalone = window . matchMedia ( '(display-mode: standalone)' ). matches
|| window . navigator . standalone === true ;
if ( standalone ) return ;
this . isAndroid = /Android/i . test ( navigator . userAgent );
// SW alleen proberen op secure context (https/localhost). Op http in LAN faalt dit stil — app blijft gewoon werken.
if ( window . isSecureContext && 'serviceWorker' in navigator ) {
navigator . serviceWorker . register ( '/sw.js' , { scope : '/' }). catch ( function ( err ) {
console . warn ( 'SW register failed' , err );
});
2026-06-09 00:41:27 +00:00
}
2026-08-16 11:01:58 +00:00
2026-06-09 00:41:27 +00:00
window . addEventListener ( 'beforeinstallprompt' , ( e ) => {
e . preventDefault ();
this . _deferredPrompt = e ;
2026-08-16 11:01:58 +00:00
if ( ! localStorage . getItem ( 'pwa-dismiss' )) this . showInstall = true ;
2026-06-09 00:41:27 +00:00
});
2026-08-16 11:01:58 +00:00
if ( ! localStorage . getItem ( 'pwa-dismiss' ) && ( this . isIos || this . isAndroid )) {
this . showInstall = true ;
}
},
dismissPwa () {
this . showInstall = false ;
localStorage . setItem ( 'pwa-dismiss' , '1' );
2026-06-09 00:41:27 +00:00
},
async installPwa () {
if ( this . _deferredPrompt ) {
this . _deferredPrompt . prompt ();
await this . _deferredPrompt . userChoice ;
this . _deferredPrompt = null ;
2026-08-16 11:01:58 +00:00
this . dismissPwa ();
2026-06-09 00:41:27 +00:00
return ;
}
2026-08-16 11:01:58 +00:00
if ( this . isIos ) {
alert ( 'Open http://10.4.7.18:8610 in Safari\n\n1. Tik op Deel (↗)\n2. Kies «Zet op beginscherm»\n3. Bevestig met «Zet erop»\n\nGeen HTTPS nodig — geen certificaatmelding.' );
2026-06-09 00:41:27 +00:00
} else {
2026-08-16 11:01:58 +00:00
alert ( 'Open http://10.4.7.18:8610 in Chrome\n\n1. Tik op ⋮ (rechtsboven)\n2. «Toevoegen aan startscherm» of «App installeren»\n3. Bevestig\n\nGeen HTTPS nodig — geen certificaatmelding.' );
2026-06-09 00:41:27 +00:00
}
2026-08-16 11:01:58 +00:00
this . dismissPwa ();
2026-06-09 00:41:27 +00:00
}
};
}
</ script >
2026-06-23 10:04:23 +00:00
< script >
window . addEventListener ( 'load' , function () {
setTimeout ( function () {
if ( window . Alpine ) return ;
document . querySelectorAll ( '[x-cloak]' ). forEach ( function ( el ) { el . removeAttribute ( 'x-cloak' ); });
document . body . classList . add ( 'alpine-missing' );
console . error ( 'Alpine.js niet geladen — interactieve UI beperkt. Hard refresh (Ctrl+Shift+R).' );
}, 2500 );
});
</ script >
2026-06-09 00:41:27 +00:00
{% block scripts %}{% endblock %}
</ body >
</ html >