SysOps: approved_backup — 2026-08-16 11:01 UTC
This commit is contained in:
@@ -5,17 +5,23 @@
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<link rel="stylesheet" href="/static/css/agents-mesh.css?v=8" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
|
||||
<script src="/static/js/agent-avatars.js?v=3"></script>
|
||||
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
|
||||
<script src="/static/js/agent-avatars.js?v=4"></script>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="hm-neo" x-data="agentsSoulHub()" x-init="init()">
|
||||
<header class="hm-neo-header page-header">
|
||||
<header class="hm-neo-header page-header" x-show="tab!=='warroom'" x-cloak>
|
||||
<h1>Agents · Soul Registry</h1>
|
||||
<p class="hm-sub">Animated team · duidelijke rollen · soul.md · live activiteit</p>
|
||||
</header>
|
||||
<header class="hm-neo-header page-header wr-page-header" x-show="tab==='warroom'" x-cloak>
|
||||
<h1>War Room</h1>
|
||||
<p class="hm-sub">Teamchat · @mentions · 1:1 DM · pop-out voor tweede scherm</p>
|
||||
</header>
|
||||
|
||||
<div id="viz-toolbar" class="viz-toolbar"></div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem">
|
||||
<div id="viz-toolbar" class="viz-toolbar" x-show="tab!=='warroom'" x-cloak></div>
|
||||
<div class="hm-chart-panel page-viz-panel viz-mode-chart agents-viz-compact" style="margin-bottom:1rem"
|
||||
x-show="tab!=='warroom'" x-cloak>
|
||||
<div class="hm-chart-head"><h4>Agent activiteit (events)</h4><small>5 visualisatie-modi</small></div>
|
||||
<div class="page-viz-body">
|
||||
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-agents-viz"></canvas></div>
|
||||
@@ -39,6 +45,9 @@
|
||||
<span class="vtab-icon">✓</span><span>Goedkeuringen</span>
|
||||
<span class="vtab-badge" x-show="pendingCount" x-text="pendingCount"></span>
|
||||
</button>
|
||||
<button class="vtab-btn" :class="{active: tab==='warroom'}" @click="setTab('warroom', $el)" data-tab="warroom">
|
||||
<span class="vtab-icon">⚔️</span><span>War Room</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="vtabs-content">
|
||||
@@ -262,6 +271,82 @@
|
||||
<p x-show="!items.length" class="muted">Geen approval requests.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="tab==='warroom'" x-cloak x-data="warRoomHub()" x-init="init()">
|
||||
<div class="panel" style="padding:0.75rem">
|
||||
<div class="wr-layout">
|
||||
<aside class="wr-roster">
|
||||
<h3>Kanalen</h3>
|
||||
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
|
||||
<span>⚔️</span><strong>#war-room</strong>
|
||||
</button>
|
||||
<h3 style="margin-top:0.85rem">Agents</h3>
|
||||
<template x-for="s in souls" :key="'wr-'+s.agent_key">
|
||||
<button type="button" class="wr-agent-btn"
|
||||
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
|
||||
@click="openDm(s.agent_key)"
|
||||
@dblclick="insertMention(s.agent_key)">
|
||||
<div class="agent-char-wrap"
|
||||
:data-agent-avatar="s.agent_key"
|
||||
:data-agent-color="s.avatar_color || '#1e3a5f'"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
|
||||
<div class="wr-agent-meta">
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<small x-text="'@'+s.agent_key"></small>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</aside>
|
||||
<div class="wr-main">
|
||||
<div class="wr-head">
|
||||
<div>
|
||||
<h2 x-text="room ? (room.title || ('#'+room.kind)) : 'War Room'"></h2>
|
||||
<small class="muted" x-show="status" x-text="status"></small>
|
||||
</div>
|
||||
<div class="wr-head-actions">
|
||||
<button type="button" class="btn btn-sm" @click="popOut()" title="Open op tweede scherm">Pop-out ↗</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wr-log" x-ref="wrLog">
|
||||
<p class="wr-empty" x-show="!messages.length">Nog geen berichten — tag agents met @marketing of open een DM.</p>
|
||||
<template x-for="m in messages" :key="m.id">
|
||||
<div class="wr-bubble"
|
||||
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
|
||||
<div class="wr-avatar" x-show="m.sender_type!=='system'">
|
||||
<template x-if="m.sender_type==='agent'">
|
||||
<div class="agent-char-wrap"
|
||||
:data-agent-avatar="m.agent_key"
|
||||
data-agent-color="#1e3a5f"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
|
||||
</template>
|
||||
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
|
||||
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
|
||||
</template>
|
||||
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
|
||||
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="wr-bubble-body">
|
||||
<div class="wr-bubble-meta">
|
||||
<span x-text="labelForMessage(m)"></span>
|
||||
<span x-show="m.agent_key" x-text="' · @'+m.agent_key"></span>
|
||||
<span x-show="m.metadata && m.metadata.in_reply_to" x-text="' · reageert op @'+m.metadata.in_reply_to"></span>
|
||||
</div>
|
||||
<div class="wr-bubble-text" x-text="m.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="wr-composer">
|
||||
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
|
||||
placeholder="Bericht… gebruik @agent om iemand te taggen" :disabled="busy || !room"></textarea>
|
||||
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,6 +389,7 @@
|
||||
<script src="/static/js/page-viz.js?v=1"></script>
|
||||
<script src="/static/js/agents-mesh.js?v=11"></script>
|
||||
<script src="/static/js/agents-terminals.js?v=6"></script>
|
||||
<script src="/static/js/agents-warroom.js?v=4"></script>
|
||||
<script>
|
||||
function agentsSoulHub() {
|
||||
return {
|
||||
@@ -384,7 +470,14 @@ function agentsSoulHub() {
|
||||
this.syncMeshMode();
|
||||
await this.load();
|
||||
await this.loadPendingCount();
|
||||
this._poll = setInterval(() => { this.load(true); this.loadPendingCount(); }, 20000);
|
||||
this._poll = setInterval(() => {
|
||||
if (this.tab === 'warroom') {
|
||||
this.loadPendingCount();
|
||||
return;
|
||||
}
|
||||
this.load(true);
|
||||
this.loadPendingCount();
|
||||
}, 20000);
|
||||
if (window.PageViz) {
|
||||
window.PageViz.init('agents', (mode) => this.renderViz(mode));
|
||||
}
|
||||
@@ -399,7 +492,7 @@ function agentsSoulHub() {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (window.AgentsTerminals) AgentsTerminals.startLiveStream();
|
||||
if (window.AgentsTerminals && this.tab !== 'warroom') AgentsTerminals.startLiveStream();
|
||||
if (this.tab === 'mesh') this.mountMesh();
|
||||
if (this.tab === 'terminals') this.startTerminals();
|
||||
});
|
||||
@@ -412,6 +505,7 @@ function agentsSoulHub() {
|
||||
this.tabIndicatorH = btnRect.height || 40;
|
||||
},
|
||||
setTab(next, el) {
|
||||
const prev = this.tab;
|
||||
this.tab = next;
|
||||
this.updateTabIndicator(el);
|
||||
const url = new URL(window.location.href);
|
||||
@@ -419,6 +513,15 @@ function agentsSoulHub() {
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
if (next === 'mesh') this.mountMesh();
|
||||
if (next === 'terminals') this.startTerminals();
|
||||
if (next === 'warroom') {
|
||||
if (window.AgentsTerminals && AgentsTerminals.stopLiveStream) AgentsTerminals.stopLiveStream();
|
||||
} else if (prev === 'warroom') {
|
||||
if (window.AgentsTerminals && AgentsTerminals.startLiveStream) AgentsTerminals.startLiveStream();
|
||||
this.$nextTick(() => {
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
window.AgentAvatars && AgentAvatars.paintAll(this.$root);
|
||||
});
|
||||
}
|
||||
if (next === 'approvals') this.loadPendingCount();
|
||||
},
|
||||
async loadPendingCount() {
|
||||
@@ -511,16 +614,23 @@ function agentsSoulHub() {
|
||||
},
|
||||
async load(quiet) {
|
||||
const r = await fetch('/api/agents/souls').then(x => x.json());
|
||||
this.souls = r.items || [];
|
||||
const items = r.items || [];
|
||||
// Avoid Alpine thrash: only replace souls when something meaningful changed
|
||||
const sig = items.map(s => [s.agent_key, s.event_count, s.events_6h, s.current_status, s.activity, s.current_task].join(':')).join('|');
|
||||
if (sig !== this._soulsSig) {
|
||||
this._soulsSig = sig;
|
||||
this.souls = items;
|
||||
}
|
||||
if (this.souls.length && !this.selected) this.select(this.souls[0].agent_key);
|
||||
if (this.tab === 'warroom') return; // no chart / global avatar repaint on War Room
|
||||
this.$nextTick(() => {
|
||||
(this.souls || []).forEach((s) => {
|
||||
const el = document.querySelector('[data-agent-avatar="' + s.agent_key + '"]');
|
||||
const el = document.querySelector('.agent-grid [data-agent-avatar="' + s.agent_key + '"], .hm-neo [data-agent-avatar="' + s.agent_key + '"]');
|
||||
if (el && window.AgentAvatars) AgentAvatars.paint(el, s.agent_key, s.avatar_color);
|
||||
});
|
||||
});
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
|
||||
if (!quiet) this.$nextTick(() => window.AgentAvatars && AgentAvatars.paintAll(this.$root));
|
||||
},
|
||||
renderViz(mode) {
|
||||
if (!window.VizEngine) return;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>War Room · Foodlinkk</title>
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/agent-characters.css?v=4" />
|
||||
<link rel="stylesheet" href="/static/css/agents-warroom.css?v=3" />
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
</head>
|
||||
<body class="wr-popup-body">
|
||||
<div class="wr-popup-shell" x-data="warRoomPopup()" x-init="init()">
|
||||
<div class="wr-layout">
|
||||
<aside class="wr-roster">
|
||||
<h3>Kanalen</h3>
|
||||
<button type="button" class="wr-room-btn" :class="{active: room && room.kind==='warroom'}" @click="openWarroom()">
|
||||
<span>⚔️</span><strong>#war-room</strong>
|
||||
</button>
|
||||
<h3 style="margin-top:0.85rem">Agents</h3>
|
||||
<template x-for="s in souls" :key="'p-'+s.agent_key">
|
||||
<button type="button" class="wr-agent-btn"
|
||||
:class="{active: room && room.kind==='dm' && room.agent_key===s.agent_key}"
|
||||
@click="openDm(s.agent_key)">
|
||||
<div class="agent-char-wrap"
|
||||
:data-agent-avatar="s.agent_key"
|
||||
:data-agent-color="s.avatar_color || '#1e3a5f'"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, s.agent_key, s.avatar_color))"></div>
|
||||
<div class="wr-agent-meta">
|
||||
<strong x-text="s.display_name"></strong>
|
||||
<small x-text="'@'+s.agent_key"></small>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</aside>
|
||||
<div class="wr-main">
|
||||
<div class="wr-head">
|
||||
<div>
|
||||
<h2 x-text="room ? (room.title || 'War Room') : 'War Room'"></h2>
|
||||
<small class="muted" x-show="status" x-text="status"></small>
|
||||
</div>
|
||||
<div class="wr-head-actions">
|
||||
<a class="btn btn-sm" href="/agents?tab=warroom" target="_blank">In cockpit</a>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="room && openRoom(room.id)" :disabled="busy">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wr-log" x-ref="wrLog">
|
||||
<p class="wr-empty" x-show="!messages.length">Nog geen berichten.</p>
|
||||
<template x-for="m in messages" :key="m.id">
|
||||
<div class="wr-bubble"
|
||||
:class="{'is-user': m.sender_type==='user', 'is-agent': m.sender_type==='agent', 'is-system': m.sender_type==='system'}">
|
||||
<div class="wr-avatar" x-show="m.sender_type!=='system'">
|
||||
<template x-if="m.sender_type==='agent'">
|
||||
<div class="agent-char-wrap"
|
||||
:data-agent-avatar="m.agent_key"
|
||||
x-init="$nextTick(() => window.AgentAvatars && AgentAvatars.paint($el, m.agent_key))"></div>
|
||||
</template>
|
||||
<template x-if="m.sender_type==='user' && (m.sender_avatar_url || me.avatar_url)">
|
||||
<img :src="m.sender_avatar_url || me.avatar_url" alt="" />
|
||||
</template>
|
||||
<template x-if="m.sender_type==='user' && !(m.sender_avatar_url || me.avatar_url)">
|
||||
<span class="wr-avatar-fallback" x-text="(m.username||me.username||'?').slice(0,2).toUpperCase()"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="wr-bubble-body">
|
||||
<div class="wr-bubble-meta"><span x-text="labelForMessage(m)"></span></div>
|
||||
<div class="wr-bubble-text" x-text="m.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="wr-composer">
|
||||
<textarea class="form-input" rows="2" x-model="draft" @keydown="onKey($event)"
|
||||
placeholder="Bericht… @agent" :disabled="busy || !room"></textarea>
|
||||
<button type="button" class="btn btn-pulse-green" @click="send()" :disabled="busy || !room || !draft.trim()">Stuur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/cockpit.js?v=3"></script>
|
||||
<script src="/static/js/agent-avatars.js?v=4"></script>
|
||||
<script src="/static/js/agents-warroom.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
+196
-48
@@ -12,22 +12,30 @@
|
||||
<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" />
|
||||
<link rel="manifest" href="/static/manifest.json?v=2" />
|
||||
<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" />
|
||||
<link rel="icon" href="/static/icons/app-icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/static/icons/app-icon.svg" />
|
||||
<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" />
|
||||
<link rel="stylesheet" href="/static/css/palantir-theme.css?v=4" />
|
||||
<link rel="stylesheet" href="/static/css/pulse-theme.css?v=2" />
|
||||
<link rel="stylesheet" href="/static/css/topnav-neo.css?v=6" />
|
||||
<link rel="stylesheet" href="/static/css/vertical-tabs.css?v=9" />
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=5" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
<script defer src="/static/js/herman-assistant.js?v=16"></script>
|
||||
<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>
|
||||
<script defer src="/static/js/alpine.min.js?v=2"></script>
|
||||
<script defer src="/static/js/htmx.min.js?v=2"></script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=11" />
|
||||
<link rel="stylesheet" href="/static/css/app-sidebar-neo.css?v=12" />
|
||||
<link rel="stylesheet" href="/static/css/cockpit-typography.css?v=1" />
|
||||
<link rel="stylesheet" href="/static/css/herman-assistant.css?v=16" />
|
||||
<!-- 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" />
|
||||
</head>
|
||||
<body x-data="appShell()" x-init="initApp()" :class="{ 'nav-open': navOpen, 'drawer-open': drawerOpen, 'brand-cucina': brand === 'cucina', 'brand-foodlinkk': brand === 'foodlinkk' }">
|
||||
<div class="app-shell">
|
||||
@@ -51,36 +59,36 @@
|
||||
<div class="persona-nav">
|
||||
<div class="app-sidebar-group" x-show="brand === 'foodlinkk'">
|
||||
<div class="app-sidebar-label">Foodlinkk B2B</div>
|
||||
<a href="/export-intel" 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" 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" 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" 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" 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" 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group" x-show="brand === 'cucina'">
|
||||
<div class="app-sidebar-label">Cucina B2C</div>
|
||||
<a href="/" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="app-sidebar-group">
|
||||
<div class="app-sidebar-label" data-i18n="nav.platform">Platform</div>
|
||||
<a href="/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" 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" 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" 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" 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" 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -100,15 +108,39 @@
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<div class="app-clock"><strong x-text="clockDate">—</strong> · <span x-text="clockTime">—</span></div>
|
||||
{% 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>
|
||||
</header>
|
||||
<main class="main-topnav">{% block content %}{% endblock %}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pwa-install-bar" x-show="showInstall" x-cloak>
|
||||
<p>📱 Installeer Foodlinkk als app op je telefoon</p>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="installPwa()">Installeren</button>
|
||||
<button type="button" class="btn btn-sm" @click="showInstall=false">Later</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Herman chat — altijd vast onderin zichtbaar -->
|
||||
@@ -136,6 +168,8 @@
|
||||
<div class="ha-head-actions">
|
||||
<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>
|
||||
<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>
|
||||
<a href="/herman">Volledig</a>
|
||||
<button type="button" @click="open = false" aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
@@ -224,7 +258,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/i18n.js?v=5"></script>
|
||||
<script src="/static/js/cockpit.js?v=2"></script>
|
||||
<script src="/static/js/cockpit.js?v=3"></script>
|
||||
<script src="/static/js/viz-engine.js?v=3"></script>
|
||||
<script src="/static/js/global-viz.js?v=1"></script>
|
||||
<script src="/static/js/live-pulse.js?v=3"></script>
|
||||
@@ -250,6 +284,47 @@
|
||||
/* legacy persona helper → brand */
|
||||
window.FoodlinkkPersona = window.FoodlinkkBrand;
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function appShell() {
|
||||
return {
|
||||
locale: 'nl',
|
||||
@@ -258,21 +333,24 @@
|
||||
navOpen: false,
|
||||
sidebarWidth: 280,
|
||||
showInstall: false,
|
||||
authUser: null,
|
||||
authPages: [],
|
||||
_deferredPrompt: null,
|
||||
clockDate: '—',
|
||||
clockTime: '—',
|
||||
_offset: 0,
|
||||
isIos: false,
|
||||
async initApp() {
|
||||
this.brand = window.FoodlinkkBrand.detect(location.pathname);
|
||||
if (!localStorage.getItem('flk-cache-v4')) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
}
|
||||
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')) {
|
||||
if (window.caches) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
localStorage.setItem('flk-pwa-v6', '1');
|
||||
localStorage.setItem('flk-cache-v4', '1');
|
||||
}
|
||||
if (window.I18n) await window.I18n.init();
|
||||
@@ -280,6 +358,59 @@
|
||||
this.initSidebarWidth();
|
||||
this.initClock();
|
||||
this.initPwa();
|
||||
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');
|
||||
}
|
||||
},
|
||||
initSidebarWidth() {
|
||||
var saved = parseInt(localStorage.getItem('flk-sidebar-width') || '280', 10);
|
||||
@@ -340,32 +471,49 @@
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
},
|
||||
isAndroid: false,
|
||||
initPwa() {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return;
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.showInstall = !localStorage.getItem('pwa-dismiss');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
this._deferredPrompt = e;
|
||||
this.showInstall = true;
|
||||
if (!localStorage.getItem('pwa-dismiss')) this.showInstall = true;
|
||||
});
|
||||
|
||||
if (!localStorage.getItem('pwa-dismiss') && (this.isIos || this.isAndroid)) {
|
||||
this.showInstall = true;
|
||||
}
|
||||
},
|
||||
dismissPwa() {
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
},
|
||||
async installPwa() {
|
||||
if (this._deferredPrompt) {
|
||||
this._deferredPrompt.prompt();
|
||||
await this._deferredPrompt.userChoice;
|
||||
this._deferredPrompt = null;
|
||||
this.showInstall = false;
|
||||
this.dismissPwa();
|
||||
return;
|
||||
}
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
alert('Tik op Deel ↗ en kies «Zet op beginscherm» om Foodlinkk als app te installeren.');
|
||||
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.');
|
||||
} else {
|
||||
alert('Tik op ⋮ menu → «Toevoegen aan startscherm» of «Installeren».');
|
||||
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.');
|
||||
}
|
||||
this.showInstall = false;
|
||||
localStorage.setItem('pwa-dismiss', '1');
|
||||
this.dismissPwa();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/hermes.css" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=33" />
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=34" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="herman-shell hm-neo">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0a0e14" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<title>Inloggen · Foodlinkk</title>
|
||||
<link rel="icon" href="/static/icons/icon-192.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/static/css/login.css?v=1" />
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<main class="login-shell">
|
||||
<section class="login-card">
|
||||
<img class="login-logo" src="/static/img/foodlinkk-logo.svg" alt="Foodlinkk" width="320" height="80" />
|
||||
<p class="login-sub">Meld je aan bij de Command Center cockpit</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="login-error" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="login-form" method="post" action="/login" autocomplete="on">
|
||||
<input type="hidden" name="next" value="{{ next or '/' }}" />
|
||||
<label class="login-label">Gebruikersnaam
|
||||
<input class="login-input" type="text" name="username" required autofocus
|
||||
value="{{ username or '' }}" autocomplete="username" />
|
||||
</label>
|
||||
<label class="login-label">Wachtwoord
|
||||
<input class="login-input" type="password" name="password" required
|
||||
autocomplete="current-password" />
|
||||
</label>
|
||||
<button class="login-btn" type="submit">Inloggen</button>
|
||||
</form>
|
||||
<p class="login-foot">Foodlinkk · Cucina · intern netwerk</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,48 @@
|
||||
{% block extra_head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
|
||||
<style>
|
||||
.reclame-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.reclame-kpi-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
button.reclame-kpi-btn.kpi-card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--bg-elevated, #0f172a);
|
||||
border: 1px solid rgba(56,189,248,0.55);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
button.reclame-kpi-btn.kpi-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 22px rgba(56,189,248,0.18);
|
||||
border-color: rgba(56,189,248,0.9);
|
||||
}
|
||||
button.reclame-kpi-btn.kpi-card:focus-visible {
|
||||
outline: 2px solid rgba(56,189,248,0.75);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button.reclame-kpi-btn.kpi-card.is-active {
|
||||
border-color: #38bdf8;
|
||||
background: rgba(56,189,248,0.08);
|
||||
box-shadow: 0 0 0 1px rgba(56,189,248,0.25);
|
||||
}
|
||||
button.reclame-kpi-btn.kpi-card span { color: var(--text-secondary, #94a3b8); font-size: 0.85rem; }
|
||||
button.reclame-kpi-btn.kpi-card strong { color: var(--text-primary, #f8fafc); font-size: 1.15rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div x-data="marketingHub()" x-init="init()">
|
||||
@@ -133,17 +175,23 @@
|
||||
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="reclameBusy" x-text="reclameBusy ? '⏳ Bezig met ophalen…' : '↻ Ophalen van Reclamefolder.nl'"></button>
|
||||
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
|
||||
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
|
||||
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
|
||||
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
|
||||
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem" role="group" aria-label="Reclame snelfilters">
|
||||
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'ketens' }" @click="onReclameKpi('ketens')" title="Open ketens-filter">
|
||||
<span>Ketens</span><strong x-text="reclameStats.chains||0"></strong>
|
||||
</button>
|
||||
<button type="button" class="kpi-card kpi-green clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'folders' }" @click="onReclameKpi('folders')" title="Toon folders">
|
||||
<span>Folders</span><strong x-text="filteredPromos.length"></strong>
|
||||
</button>
|
||||
<button type="button" class="kpi-card clickable-kpi reclame-kpi-btn" :class="{ 'is-active': reclameKpi === 'totaal' }" @click="onReclameKpi('totaal')" title="Toon alles / filters wissen">
|
||||
<span>Totaal geladen</span><strong x-text="promos.length"></strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
|
||||
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
|
||||
<select class="form-input" x-ref="chainFilter" x-model="reclameFilter.chain" @change="loadPromos()">
|
||||
<option value="">Alle ketens</option>
|
||||
<template x-for="c in reclameChains" :key="c">
|
||||
<option :value="c" x-text="c"></option>
|
||||
@@ -177,7 +225,7 @@
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
|
||||
<p x-show="!filteredPromos.length && !reclameBusy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
|
||||
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
|
||||
<form class="form-grid" @submit.prevent="addPromo">
|
||||
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
|
||||
@@ -397,10 +445,11 @@ function marketingHub() {
|
||||
return {
|
||||
tab: new URLSearchParams(location.search).get('tab') || 'live',
|
||||
tabIndicatorTop: 0, tabIndicatorH: 44,
|
||||
busy: false, dashboard: {}, socialAnalytics: '',
|
||||
busy: false, reclameBusy: false, dashboard: {}, socialAnalytics: '',
|
||||
regRows: { regelgeving: [], cbs: [], markt: [] },
|
||||
promos: [], bookmarks: [], bookmarkIds: {},
|
||||
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
|
||||
reclameKpi: 'folders',
|
||||
reclameStats: { chains: 0 },
|
||||
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
|
||||
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
|
||||
@@ -457,7 +506,30 @@ function marketingHub() {
|
||||
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
|
||||
this.tabIndicatorH = btn.offsetHeight;
|
||||
},
|
||||
onReclameKpi(which) {
|
||||
this.reclameKpi = which;
|
||||
if (which === 'ketens') {
|
||||
this.$nextTick(() => {
|
||||
const el = this.$refs.chainFilter;
|
||||
if (el) {
|
||||
el.focus();
|
||||
try { el.showPicker && el.showPicker(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (which === 'folders') {
|
||||
this.reclameFilter = { chain: '', q: '', folder_type: 'folder', valid_days: '' };
|
||||
this.loadPromos();
|
||||
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||
return;
|
||||
}
|
||||
this.reclameFilter = { chain: '', q: '', folder_type: '', valid_days: '' };
|
||||
this.loadPromos();
|
||||
this.$nextTick(() => document.querySelector('.promo-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||
},
|
||||
async loadReclameChains() {
|
||||
|
||||
try {
|
||||
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
|
||||
this.reclameChains = d.chains || [];
|
||||
@@ -471,10 +543,15 @@ function marketingHub() {
|
||||
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
|
||||
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
|
||||
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
|
||||
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
|
||||
const resp = await fetch('/api/retail/promo-campaigns?' + params);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
let items = (await resp.json()).items || [];
|
||||
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
|
||||
this.promos = items;
|
||||
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
|
||||
// Alleen auto-sync bij eerste lege load, niet tijdens handmatige sync (voorkomt nesting)
|
||||
if (!items.length && !this.reclameBusy && !this.reclameFilter.q && !this.reclameFilter.chain && !this.reclameFilter.folder_type && !this.reclameFilter.valid_days) {
|
||||
await this.refreshReclamefolders(false);
|
||||
}
|
||||
} catch (e) { this.promos = []; }
|
||||
},
|
||||
promoUrl(p) {
|
||||
@@ -489,15 +566,26 @@ function marketingHub() {
|
||||
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
|
||||
},
|
||||
async refreshReclamefolders(toast = true) {
|
||||
this.busy = true;
|
||||
if (this.reclameBusy) return;
|
||||
this.reclameBusy = true;
|
||||
try {
|
||||
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
|
||||
const resp = await fetch('/api/retail/reclamefolder/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
const d = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || d.ok === false) {
|
||||
throw new Error(d.detail || d.error || ('HTTP ' + resp.status));
|
||||
}
|
||||
await this.loadPromos();
|
||||
await this.loadReclameChains();
|
||||
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
|
||||
if (typeof d.synced === 'number') this.reclameStats.synced = d.synced;
|
||||
if (toast) Cockpit.toast((d.synced || this.promos.length || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
|
||||
} catch (e) {
|
||||
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
|
||||
} finally { this.busy = false; }
|
||||
console.error('reclamefolder refresh', e);
|
||||
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt: ' + (e.message || e), 'error');
|
||||
} finally { this.reclameBusy = false; }
|
||||
},
|
||||
async addPromo() {
|
||||
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
|
||||
@@ -545,13 +633,18 @@ function marketingHub() {
|
||||
},
|
||||
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
|
||||
async refreshAll(toast) {
|
||||
this.busy = true;
|
||||
const silent = toast === false;
|
||||
if (!silent) this.busy = true;
|
||||
try {
|
||||
await fetch('/api/retail/rss/refresh', { method: 'POST' });
|
||||
await this.loadDashboard();
|
||||
if (this.tab === 'reclame' && !this.reclameBusy) {
|
||||
await this.loadPromos();
|
||||
await this.loadReclameChains();
|
||||
}
|
||||
this.renderViz(window.__vizMode || 'neo-bars');
|
||||
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
||||
} finally { this.busy = false; }
|
||||
if (!silent) Cockpit.toast('Marketing data bijgewerkt', 'success');
|
||||
} finally { if (!silent) this.busy = false; }
|
||||
},
|
||||
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
|
||||
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=12" />
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=13" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
@@ -336,5 +336,5 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=12"></script>
|
||||
<script src="/static/js/revenue-cockpit.js?v=13"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<a href="/settings?tab=email" class="settings-tab {% if active_tab == 'email' %}active{% endif %}">Email</a>
|
||||
<a href="/settings?tab=social" class="settings-tab {% if active_tab == 'social' %}active{% endif %}">Social API's</a>
|
||||
<a href="/settings?tab=permissions" class="settings-tab {% if active_tab == 'permissions' %}active{% endif %}">Rechten Herman</a>
|
||||
{% if can_manage_users %}
|
||||
<a href="/settings?tab=users" class="settings-tab {% if active_tab == 'users' %}active{% endif %}">Gebruikers</a>
|
||||
{% endif %}
|
||||
<a href="/settings?tab=general" class="settings-tab {% if active_tab == 'general' %}active{% endif %}">General</a>
|
||||
</nav>
|
||||
|
||||
@@ -138,6 +141,111 @@
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% elif active_tab == 'users' and can_manage_users %}
|
||||
<section class="panel settings-panel hm-neo" x-data="usersAdminPage()" x-init="load()">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Gebruikers & rechten</h2>
|
||||
<p class="page-tip">Maak accounts aan en bepaal welke cockpit-pagina's zichtbaar zijn. Admins hebben altijd volledige toegang.</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" @click="openCreate()">+ Nieuwe gebruiker</button>
|
||||
</div>
|
||||
|
||||
<div class="email-account-list">
|
||||
<template x-for="u in users" :key="u.id">
|
||||
<article class="email-account-card" :class="{ 'is-active': u.is_active }">
|
||||
<div class="email-account-card-head">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;min-width:0">
|
||||
<img class="user-card-avatar" x-show="u.avatar_url" :src="u.avatar_url" alt="" />
|
||||
<span class="user-card-avatar fallback" x-show="!u.avatar_url" x-text="initials(u)"></span>
|
||||
<div style="min-width:0">
|
||||
<strong x-text="u.display_name"></strong>
|
||||
<span class="muted" x-text="'@' + u.username"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge" :class="u.role === 'admin' ? 'badge-positive' : 'badge-neutral'" x-text="u.role"></span>
|
||||
</div>
|
||||
<div class="email-account-meta">
|
||||
<span x-text="u.is_active ? 'Actief' : 'Uitgeschakeld'"></span>
|
||||
<span x-text="u.role === 'admin' ? 'Alle pagina\'s' : ((u.permissions.pages||[]).length + ' pagina\'s')"></span>
|
||||
<span x-show="u.last_login_at" x-text="'Laatste login: ' + (u.last_login_at || '')"></span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="edit(u)">Bewerken</button>
|
||||
<button type="button" class="btn btn-reject btn-sm" @click="remove(u)" x-show="u.id !== meId">Verwijder</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="empty-state" x-show="!users.length && !loading">Nog geen gebruikers.</p>
|
||||
</div>
|
||||
|
||||
<div class="modal" :class="{ open: formOpen }" @click.self="formOpen=false">
|
||||
<div class="modal-card settings-modal" style="max-width:560px">
|
||||
<h3 x-text="form.id ? 'Gebruiker bewerken' : 'Nieuwe gebruiker'"></h3>
|
||||
|
||||
<div class="user-form-avatar-row">
|
||||
<img class="user-form-avatar-preview" x-show="form.avatar_url || form.avatarPreview"
|
||||
:src="form.avatarPreview || form.avatar_url" alt="" />
|
||||
<span class="user-form-avatar-preview fallback" x-show="!form.avatar_url && !form.avatarPreview"
|
||||
x-text="formInitials()"></span>
|
||||
<div>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
@change="onAvatarPick($event)" />
|
||||
<p class="page-tip" style="margin:0.35rem 0 0">JPG/PNG/WEBP, max 3 MB. Bij nieuwe user: eerst opslaan, daarna foto.</p>
|
||||
<button type="button" class="btn btn-sm btn-secondary" style="margin-top:0.35rem"
|
||||
x-show="form.id && form.avatar_url" @click="removeAvatar()">Foto verwijderen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label">Gebruikersnaam
|
||||
<input class="form-input" x-model="form.username" :disabled="!!form.id" placeholder="bijv. sandra" autocomplete="off" />
|
||||
</label>
|
||||
<label class="form-label">Weergavenaam
|
||||
<input class="form-input" x-model="form.display_name" placeholder="Sandra" />
|
||||
</label>
|
||||
<label class="form-label">Wachtwoord <span class="muted" x-show="form.id">(leeg = ongewijzigd)</span>
|
||||
<input class="form-input" type="password" x-model="form.password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label class="form-label">Rol
|
||||
<select class="form-input" x-model="form.role">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-check" x-show="form.id">
|
||||
<input type="checkbox" x-model="form.is_active" /> Account actief
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input type="checkbox" x-model="form.manage_users" :disabled="form.role === 'admin'" /> Mag gebruikers beheren
|
||||
</label>
|
||||
|
||||
<div x-show="form.role !== 'admin'" style="margin-top:0.75rem">
|
||||
<strong style="font-size:0.85rem">Pagina-rechten</strong>
|
||||
<div class="perm-grid" style="margin-top:0.5rem;grid-template-columns:1fr 1fr">
|
||||
<template x-for="p in pages" :key="p.key">
|
||||
<label class="form-check">
|
||||
<input type="checkbox" :value="p.key" :checked="form.pages.includes(p.key)"
|
||||
@change="togglePage(p.key, $event.target.checked)" />
|
||||
<span x-text="p.label"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<div class="btn-group" style="margin-top:0.5rem">
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="selectAllPages()">Alles</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="form.pages=[]">Geen</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="page-tip" x-show="form.role === 'admin'">Admins hebben automatisch toegang tot alle pagina's en gebruikersbeheer.</p>
|
||||
|
||||
<div class="btn-group" style="margin-top:1rem">
|
||||
<button type="button" class="btn btn-primary" @click="save()" :disabled="saving" x-text="saving ? 'Bezig…' : 'Opslaan'"></button>
|
||||
<button type="button" class="btn btn-secondary" @click="formOpen=false">Annuleer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% elif active_tab == 'social' %}
|
||||
<section class="panel settings-panel" x-data="socialSettingsPage()" x-init="load()">
|
||||
<div class="settings-panel-head">
|
||||
@@ -271,6 +379,152 @@ function socialSettingsPage() {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function usersAdminPage() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
users: [],
|
||||
pages: [],
|
||||
meId: null,
|
||||
formOpen: false,
|
||||
form: { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: [] },
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const me = await Cockpit.api('/api/auth/me');
|
||||
this.meId = me.user && me.user.id;
|
||||
const r = await Cockpit.api('/api/settings/users');
|
||||
this.users = r.users || [];
|
||||
this.pages = r.pages || [];
|
||||
} catch (e) {
|
||||
Cockpit.toast((e && e.message) || 'Laden mislukt', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
openCreate() {
|
||||
this.form = { id: null, username: '', display_name: '', password: '', role: 'user', is_active: true, manage_users: false, pages: ['dashboard'], avatar_url: null, avatarPreview: null, avatarFile: null };
|
||||
this.formOpen = true;
|
||||
},
|
||||
edit(u) {
|
||||
this.form = {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name || '',
|
||||
password: '',
|
||||
role: u.role || 'user',
|
||||
is_active: !!u.is_active,
|
||||
manage_users: !!(u.permissions && u.permissions.manage_users),
|
||||
pages: (u.permissions && u.permissions.pages) ? [...u.permissions.pages] : [],
|
||||
avatar_url: u.avatar_url || null,
|
||||
avatarPreview: null,
|
||||
avatarFile: null,
|
||||
};
|
||||
this.formOpen = true;
|
||||
},
|
||||
initials(u) {
|
||||
const n = ((u && (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, 2).toUpperCase();
|
||||
},
|
||||
formInitials() {
|
||||
return this.initials({ display_name: this.form.display_name, username: this.form.username });
|
||||
},
|
||||
onAvatarPick(ev) {
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if (!file) return;
|
||||
this.form.avatarFile = file;
|
||||
this.form.avatarPreview = URL.createObjectURL(file);
|
||||
},
|
||||
async uploadAvatarFor(userId, file) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/settings/users/' + userId + '/avatar', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || 'Foto upload mislukt');
|
||||
return data.user;
|
||||
},
|
||||
async removeAvatar() {
|
||||
if (!this.form.id) return;
|
||||
if (!confirm('Profielfoto verwijderen?')) return;
|
||||
try {
|
||||
await Cockpit.api('/api/settings/users/' + this.form.id + '/avatar', { method: 'DELETE' });
|
||||
this.form.avatar_url = null;
|
||||
this.form.avatarPreview = null;
|
||||
this.form.avatarFile = null;
|
||||
Cockpit.toast('Foto verwijderd', 'success');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
Cockpit.toast((e && e.message) || 'Mislukt', 'error');
|
||||
}
|
||||
},
|
||||
togglePage(key, on) {
|
||||
if (on && !this.form.pages.includes(key)) this.form.pages.push(key);
|
||||
if (!on) this.form.pages = this.form.pages.filter(k => k !== key);
|
||||
},
|
||||
selectAllPages() { this.form.pages = this.pages.map(p => p.key); },
|
||||
async save() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const permissions = {
|
||||
pages: this.form.role === 'admin' ? this.pages.map(p => p.key) : this.form.pages,
|
||||
manage_users: this.form.role === 'admin' ? true : !!this.form.manage_users,
|
||||
};
|
||||
let saved = null;
|
||||
if (this.form.id) {
|
||||
const body = {
|
||||
display_name: this.form.display_name,
|
||||
role: this.form.role,
|
||||
is_active: this.form.is_active,
|
||||
permissions,
|
||||
};
|
||||
if (this.form.password) body.password = this.form.password;
|
||||
saved = await Cockpit.api('/api/settings/users/' + this.form.id, { method: 'PUT', body: JSON.stringify(body) });
|
||||
saved = saved.user || saved;
|
||||
} else {
|
||||
if (!this.form.username || !this.form.password) {
|
||||
Cockpit.toast('Gebruikersnaam en wachtwoord verplicht', 'error');
|
||||
return;
|
||||
}
|
||||
saved = await Cockpit.api('/api/settings/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name || this.form.username,
|
||||
role: this.form.role,
|
||||
permissions,
|
||||
}),
|
||||
});
|
||||
saved = saved.user || saved;
|
||||
}
|
||||
if (this.form.avatarFile && saved && saved.id) {
|
||||
await this.uploadAvatarFor(saved.id, this.form.avatarFile);
|
||||
}
|
||||
this.formOpen = false;
|
||||
Cockpit.toast('Gebruiker opgeslagen', 'success');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
Cockpit.toast((e && e.message) || 'Opslaan mislukt', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
async remove(u) {
|
||||
if (!confirm('Verwijder gebruiker ' + u.username + '?')) return;
|
||||
try {
|
||||
await Cockpit.api('/api/settings/users/' + u.id, { method: 'DELETE' });
|
||||
Cockpit.toast('Verwijderd', 'success');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
Cockpit.toast((e && e.message) || 'Verwijderen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function permissionsPage() {
|
||||
return {
|
||||
perms: [], grantedCount: 0, total: 0,
|
||||
|
||||
Reference in New Issue
Block a user