SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -1,103 +1,517 @@
|
||||
.mesh-wrap {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: radial-gradient(circle at 50% 40%, rgba(255, 215, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(180deg, rgba(7, 11, 18, 0.95) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.mesh-wrap-mode-mesh { border-color: rgba(168, 85, 247, 0.25); }
|
||||
.mesh-wrap-mode-org { border-color: rgba(255, 215, 0, 0.2); }
|
||||
.mesh-wrap-mode-pulse { border-color: rgba(34, 197, 94, 0.25); }
|
||||
.mesh-wrap-mode-clusters { border-color: rgba(255, 159, 67, 0.25); }
|
||||
.mesh-wrap-mode-pyramid { border-color: rgba(0, 229, 255, 0.2); }
|
||||
|
||||
.mesh-svg {
|
||||
width: 100%;
|
||||
min-height: 520px;
|
||||
min-height: 680px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mesh-tier-band {
|
||||
fill: rgba(148, 163, 184, 0.03);
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.mesh-tier-0 { fill: rgba(0, 229, 255, 0.03); }
|
||||
.mesh-tier-1 { fill: rgba(168, 85, 247, 0.025); }
|
||||
.mesh-tier-2 { fill: rgba(34, 197, 94, 0.025); }
|
||||
|
||||
.mesh-edge {
|
||||
stroke: rgba(0, 229, 255, 0.42);
|
||||
stroke-width: 1.6;
|
||||
fill: none;
|
||||
stroke: rgba(100, 116, 139, 0.2);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-edge-report {
|
||||
stroke: rgba(0, 229, 255, 0.45);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.mesh-edge-delegate {
|
||||
stroke: rgba(255, 215, 0, 0.4);
|
||||
stroke-width: 1.3;
|
||||
stroke-dasharray: 5 6;
|
||||
}
|
||||
|
||||
.mesh-edge-executive {
|
||||
stroke: rgba(255, 159, 67, 0.28);
|
||||
stroke-width: 1.1;
|
||||
}
|
||||
|
||||
.mesh-edge-peer {
|
||||
stroke: rgba(168, 85, 247, 0.12);
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
|
||||
.mesh-edge-peer-live {
|
||||
stroke: rgba(192, 132, 252, 0.55);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.mesh-pulse {
|
||||
stroke: rgba(0, 229, 255, 0.9);
|
||||
stroke-width: 3;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 3 140;
|
||||
animation: mesh-pulse 2.4s linear infinite;
|
||||
stroke-dasharray: 4 100;
|
||||
animation: mesh-pulse 2.6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-pulse-active.mesh-edge-report { stroke: rgba(0, 229, 255, 0.85); }
|
||||
.mesh-pulse-active.mesh-edge-delegate { stroke: rgba(255, 215, 0, 0.75); }
|
||||
.mesh-pulse-active.mesh-edge-peer-live { stroke: rgba(192, 132, 252, 0.9); }
|
||||
|
||||
@keyframes mesh-pulse {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -286; }
|
||||
to { stroke-dashoffset: -208; }
|
||||
}
|
||||
|
||||
.mesh-node { cursor: pointer; }
|
||||
.mesh-node text {
|
||||
font-size: 12px;
|
||||
fill: #e2e8f0;
|
||||
font-weight: 600;
|
||||
.mesh-node text.mesh-label {
|
||||
font-size: 13px;
|
||||
fill: #f1f5f9;
|
||||
font-weight: 700;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-label-agent { font-size: 13px; }
|
||||
.mesh-label-herman { font-size: 14px; fill: #ffd700; }
|
||||
.mesh-label-exec { font-size: 13px; }
|
||||
.mesh-node text.mesh-sublabel {
|
||||
font-size: 10px;
|
||||
fill: #94a3b8;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-sublabel-herman { fill: #cbd5e1; font-size: 10px; }
|
||||
.mesh-node text.mesh-context {
|
||||
font-size: 9.5px;
|
||||
fill: #64748b;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-node text.mesh-domain {
|
||||
font-size: 8.5px;
|
||||
fill: #475569;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mesh-node text.mesh-emoji {
|
||||
font-size: 14px;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mesh-herman-crown { font-size: 18px; pointer-events: none; }
|
||||
|
||||
.mesh-node circle {
|
||||
fill: #0f172a;
|
||||
stroke: rgba(148, 163, 184, 0.65);
|
||||
.mesh-node circle.main {
|
||||
fill: #0b1220;
|
||||
stroke: rgba(148, 163, 184, 0.55);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mesh-node.healthy circle { stroke: #22c55e; }
|
||||
.mesh-node.warn circle { stroke: #f59e0b; }
|
||||
.mesh-node.idle circle { stroke: #38bdf8; }
|
||||
.mesh-node.offline circle { stroke: #64748b; }
|
||||
.mesh-node.healthy circle.main { stroke: #22c55e; }
|
||||
.mesh-node.warn circle.main { stroke: #f59e0b; }
|
||||
.mesh-node.idle circle.main { stroke: #475569; }
|
||||
.mesh-node.offline circle.main { stroke: #334155; opacity: 0.65; }
|
||||
|
||||
.mesh-herman circle.main {
|
||||
fill: #111827;
|
||||
stroke: #ffd700;
|
||||
stroke-width: 4;
|
||||
filter: drop-shadow(0 0 12px rgba(255, 215, 0, 0.6));
|
||||
}
|
||||
|
||||
.mesh-exec circle.main {
|
||||
fill: #0f172a;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.mesh-ceo circle.main { stroke: #ff9f43; filter: drop-shadow(0 0 10px rgba(255, 159, 67, 0.5)); }
|
||||
.mesh-cto circle.main { stroke: #a855f7; filter: drop-shadow(0 0 10px rgba(168, 85, 247, 0.5)); }
|
||||
|
||||
.mesh-exec circle.ring {
|
||||
.mesh-agent-live .mesh-pulse-wrap circle.pulse-ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.15);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.mesh-agent .mesh-status { fill: #64748b; }
|
||||
|
||||
.mesh-herman circle.ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 215, 0, 0.35);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 8 4;
|
||||
animation: mesh-herman-ring 7s linear infinite;
|
||||
stroke: rgba(34, 197, 94, 0.45);
|
||||
stroke-width: 1.8;
|
||||
animation: mesh-agent-pulse 2.8s ease-in-out infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes mesh-herman-ring {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@keyframes mesh-agent-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||
50% { opacity: 0.75; transform: scale(1.12); }
|
||||
}
|
||||
|
||||
.mesh-herman circle.main {
|
||||
fill: #0f172a;
|
||||
stroke: #ffd700;
|
||||
stroke-width: 3;
|
||||
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.35));
|
||||
}
|
||||
|
||||
.mesh-herman.active .mesh-herman-ring circle.ring {
|
||||
fill: none;
|
||||
stroke: rgba(255, 215, 0, 0.4);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 10 8;
|
||||
}
|
||||
|
||||
.mesh-status-live circle.main { stroke-width: 2.5; }
|
||||
.mesh-status-ready circle.main { stroke: #64748b; }
|
||||
.mesh-status-warn circle.main { stroke: #f59e0b; }
|
||||
.mesh-status-idle circle.main { stroke: #334155; opacity: 0.7; }
|
||||
|
||||
.mesh-exec circle.main { stroke-width: 2.5; }
|
||||
.mesh-ceo circle.main { stroke: #ff9f43; }
|
||||
.mesh-cto circle.main { stroke: #a855f7; }
|
||||
|
||||
/* ——— Communicatie-modus kiezer ——— */
|
||||
.mesh-mode-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mesh-mode-section > label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mesh-mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mesh-mode-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.mesh-mode-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.mesh-mode-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
padding: 0.7rem 0.75rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(145deg, rgba(15, 23, 42, 0.7) 0%, rgba(7, 11, 18, 0.9) 100%);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
min-height: 5.5rem;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mesh-mode-card:hover {
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mesh-mode-card.is-active {
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.15), 0 4px 20px rgba(0, 229, 255, 0.08);
|
||||
background: linear-gradient(145deg, rgba(0, 229, 255, 0.06) 0%, rgba(7, 11, 18, 0.95) 100%);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="mesh"].is-active {
|
||||
border-color: rgba(168, 85, 247, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.15), 0 4px 20px rgba(168, 85, 247, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="org"].is-active {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.12), 0 4px 20px rgba(255, 215, 0, 0.06);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="pulse"].is-active {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 4px 20px rgba(34, 197, 94, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-card[data-mode="clusters"].is-active {
|
||||
border-color: rgba(255, 159, 67, 0.5);
|
||||
box-shadow: 0 0 0 1px rgba(255, 159, 67, 0.12), 0 4px 20px rgba(255, 159, 67, 0.08);
|
||||
}
|
||||
|
||||
.mesh-mode-icon {
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.mesh-mode-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mesh-mode-sub {
|
||||
font-size: 0.68rem;
|
||||
color: #64748b;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.mesh-mode-desc {
|
||||
font-size: 0.65rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.15rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mesh-mode-check {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(148, 163, 184, 0.35);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.mesh-mode-card.is-active .mesh-mode-check {
|
||||
opacity: 1;
|
||||
background: #00e5ff;
|
||||
border-color: #00e5ff;
|
||||
box-shadow: 0 0 8px rgba(0, 229, 255, 0.5);
|
||||
}
|
||||
|
||||
.mesh-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.mesh-mode-badge {
|
||||
margin-right: auto;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #00e5ff;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.mesh-framework-head { margin-bottom: 0.5rem; }
|
||||
.mesh-framework-head h2 { margin: 0; }
|
||||
|
||||
.mesh-legend {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
gap: 0.65rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.75rem;
|
||||
margin-top: 0.65rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mesh-legend span::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mesh-legend .leg-report::before { background: #00e5ff; }
|
||||
.mesh-legend .leg-delegate::before { background: #ffd700; }
|
||||
.mesh-legend .leg-peer::before { background: #a855f7; }
|
||||
.mesh-legend .leg-exec::before { background: #ff9f43; }
|
||||
|
||||
.mesh-cluster-label {
|
||||
font-size: 11px;
|
||||
fill: #94a3b8;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-agent-cluster-growth circle.main { stroke: rgba(0, 229, 255, 0.7); }
|
||||
.mesh-agent-cluster-product circle.main { stroke: rgba(168, 85, 247, 0.7); }
|
||||
.mesh-agent-cluster-ops circle.main { stroke: rgba(34, 197, 94, 0.65); }
|
||||
|
||||
.mesh-edge-straight { stroke-dasharray: none; }
|
||||
|
||||
/* ——— Terminals grid ——— */
|
||||
.agents-terminals-intro {
|
||||
margin: 0 0 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.agents-terminals-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.agent-term-window {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 12px;
|
||||
background: #060a10;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.agent-term-window.is-live {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.agent-term-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.agent-term-head .term-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-term-window.is-live .term-dot {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.7);
|
||||
animation: term-dot-blink 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes term-dot-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.agent-term-head strong { color: #f1f5f9; flex: 1; }
|
||||
.agent-term-head small { color: #64748b; font-size: 0.72rem; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.agent-term-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 180px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-body {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.term-line {
|
||||
padding: 0.2rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.term-ts { color: #64748b; flex-shrink: 0; min-width: 2.5rem; }
|
||||
.term-msg { color: #cbd5e1; word-break: break-word; }
|
||||
.term-line.line-handoff .term-msg { color: #c4b5fd; }
|
||||
.term-line.line-action .term-msg { color: #86efac; }
|
||||
.term-line.line-command .term-msg { color: #86efac; font-weight: 600; }
|
||||
.term-line.line-output .term-msg { color: #e2e8f0; }
|
||||
.term-line.line-error .term-msg { color: #fca5a5; }
|
||||
.term-line.term-idle .term-msg { color: #64748b; font-style: italic; }
|
||||
.term-line.term-idle code { color: #94a3b8; font-size: 0.95em; }
|
||||
|
||||
.agent-term-foot {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(10, 14, 20, 0.95);
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.agent-term-cmd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
color: #22c55e;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.agent-term-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #f1f5f9;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
outline: none;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.agent-term-input::placeholder { color: #475569; }
|
||||
.agent-term-input.is-busy { opacity: 0.55; }
|
||||
|
||||
.agent-term-window.is-running .term-dot {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-foot {
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.agent-term-window.is-expanded .agent-term-input {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mesh-terminal-focus {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
border-radius: 12px;
|
||||
background: #060a10;
|
||||
}
|
||||
|
||||
.mesh-terminal-focus .agent-term-body {
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.vtab-badge {
|
||||
display: inline-flex;
|
||||
min-width: 1.1rem;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Sidebar — clean, uniform nav (loads last, wins over topnav-neo) */
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.6rem;
|
||||
background: #080c12;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.app-sidebar-brand {
|
||||
padding: 0.25rem 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.app-sidebar-brand .brand-title {
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.14em;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.app-sidebar-brand .brand-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.persona-switch.brand-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.35rem 0.65rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.persona-btn {
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: #94a3b8;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.25rem;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.brand-btn-cucina.active { color: #fdba74; border-color: rgba(251, 146, 60, 0.4); background: rgba(251, 146, 60, 0.1); }
|
||||
.brand-btn-foodlinkk.active { color: #7dd3fc; border-color: rgba(14, 165, 233, 0.4); background: rgba(14, 165, 233, 0.1); }
|
||||
|
||||
.persona-nav { display: contents; }
|
||||
|
||||
.app-sidebar-group {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.app-sidebar-label {
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
padding: 0.2rem 0.5rem 0.35rem;
|
||||
}
|
||||
|
||||
/* Base pill — accent via modifier class */
|
||||
.app-sidebar a.nav-pill {
|
||||
--nav-accent: #64748b;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin: 0 0 0.2rem;
|
||||
padding: 0.5rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
color: #cbd5e1 !important;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transform: none !important;
|
||||
animation: none !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
overflow: visible;
|
||||
isolation: auto;
|
||||
}
|
||||
.app-sidebar a.nav-pill::before,
|
||||
.app-sidebar a.nav-pill::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
.app-sidebar a.nav-pill:hover {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
color: #f1f5f9 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-sidebar a.nav-pill.active {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border-color: var(--nav-accent);
|
||||
border-left: 3px solid var(--nav-accent);
|
||||
padding-left: calc(0.55rem - 2px);
|
||||
color: #fff !important;
|
||||
box-shadow: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.sidebar-nav-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(8, 12, 18, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.app-sidebar a.nav-pill.active .sidebar-nav-icon {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border-color: var(--nav-accent);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.sidebar-nav-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Per-item accent colors */
|
||||
.app-sidebar a.nav-pill-wereldexport { --nav-accent: #0ea5e9; }
|
||||
.app-sidebar a.nav-pill-clients { --nav-accent: #38bdf8; }
|
||||
.app-sidebar a.nav-pill-deals { --nav-accent: #4ade80; }
|
||||
.app-sidebar a.nav-pill-products { --nav-accent: #a855f7; }
|
||||
.app-sidebar a.nav-pill-suppliers { --nav-accent: #fb923c; }
|
||||
.app-sidebar a.nav-pill-chat { --nav-accent: #fcd34d; }
|
||||
.app-sidebar a.nav-pill-reports { --nav-accent: #6366f1; }
|
||||
.app-sidebar a.nav-pill-herman { --nav-accent: #fbbf24; }
|
||||
.app-sidebar a.nav-pill-retail { --nav-accent: #0066cc; }
|
||||
.app-sidebar a.nav-pill-marketing { --nav-accent: #ff6b9d; }
|
||||
.app-sidebar a.nav-pill-beurs { --nav-accent: #b8ff3c; }
|
||||
.app-sidebar a.nav-pill-documents { --nav-accent: #94a3b8; }
|
||||
.app-sidebar a.nav-pill-telegram { --nav-accent: #2aabee; }
|
||||
.app-sidebar a.nav-pill-analytics { --nav-accent: #8b5cf6; }
|
||||
.app-sidebar a.nav-pill-agents { --nav-accent: #00e5ff; }
|
||||
.app-sidebar a.nav-pill-browser { --nav-accent: #14b8a6; }
|
||||
.app-sidebar a.nav-pill-studio { --nav-accent: #ec4899; }
|
||||
.app-sidebar a.nav-pill-voice { --nav-accent: #f97316; }
|
||||
.app-sidebar a.nav-pill-ops { --nav-accent: #22d3ee; }
|
||||
.app-sidebar a.nav-pill-settings { --nav-accent: #e2e8f0; }
|
||||
|
||||
.persona-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
margin-right: 0.45rem;
|
||||
}
|
||||
.brand-badge-foodlinkk { color: #7dd3fc; background: rgba(14, 165, 233, 0.12); border: 1px solid rgba(14, 165, 233, 0.25); }
|
||||
.brand-badge-cucina { color: #fdba74; background: rgba(251, 146, 60, 0.12); border: 1px solid rgba(251, 146, 60, 0.25); }
|
||||
@@ -11,15 +11,60 @@
|
||||
.clients-kpi strong { font-size: 1.35rem; color: #f1f5f9; }
|
||||
|
||||
.clients-tabs {
|
||||
display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
|
||||
margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.clients-tab-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.88);
|
||||
border: 1px solid rgba(56, 189, 248, 0.14);
|
||||
border-radius: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.clients-tab {
|
||||
border: 1px solid rgba(148,163,184,0.2); background: rgba(0,0,0,0.25);
|
||||
color: #cbd5e1; border-radius: 999px; padding: 0.35rem 0.85rem; font-size: 0.78rem; cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
transition: color 0.18s, background 0.18s, border-color 0.18s, box-shadow 0.18s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.clients-tab-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.clients-tab:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(56, 189, 248, 0.07);
|
||||
}
|
||||
.clients-tab.active {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(168, 85, 247, 0.1));
|
||||
border-color: rgba(56, 189, 248, 0.38);
|
||||
box-shadow: 0 0 18px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
.clients-tab.active .clients-tab-icon { opacity: 1; }
|
||||
.clients-tab-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-left: auto; }
|
||||
@media (max-width: 720px) {
|
||||
.clients-tabs { flex-direction: column; align-items: stretch; }
|
||||
.clients-tab-bar { width: 100%; }
|
||||
.clients-tab-actions { margin-left: 0; width: 100%; }
|
||||
}
|
||||
.clients-tab.active { border-color: #38bdf8; color: #38bdf8; box-shadow: 0 0 12px rgba(56,189,248,0.15); }
|
||||
.clients-tab-actions { margin-left: auto; display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.clients-kanban { display: grid; grid-template-columns: repeat(5, minmax(200px, 1fr)); gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem; }
|
||||
@media (max-width: 1100px) { .clients-kanban { grid-template-columns: repeat(5, minmax(180px, 1fr)); } }
|
||||
@@ -39,7 +84,30 @@
|
||||
.clients-card:hover { border-color: rgba(56,189,248,0.4); box-shadow: 0 0 16px rgba(56,189,248,0.08); }
|
||||
.clients-card strong { display: block; color: #f1f5f9; font-size: 0.88rem; margin-bottom: 0.2rem; }
|
||||
.clients-card-meta { font-size: 0.72rem; color: #94a3b8; line-height: 1.45; }
|
||||
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; }
|
||||
.clients-card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.4rem; margin-bottom: 0.45rem; }
|
||||
.clients-card-stage {
|
||||
width: 100%;
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.28rem 0.45rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.clients-card-stage:focus { outline: none; border-color: #38bdf8; box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.15); }
|
||||
.clients-inline-stage {
|
||||
min-width: 7.5rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.clients-inline-stage:focus { outline: none; border-color: #38bdf8; }
|
||||
.clients-tag { font-size: 0.62rem; padding: 0.12rem 0.4rem; border-radius: 999px; background: rgba(56,189,248,0.12); color: #7dd3fc; }
|
||||
.clients-tag.mrr { background: rgba(74,222,128,0.12); color: #4ade80; }
|
||||
.stage-intake { border-left: 3px solid #94a3b8; }
|
||||
@@ -76,6 +144,103 @@
|
||||
display: grid; grid-template-columns: 1fr 1.2fr; gap: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) { .store-search-panel { grid-template-columns: 1fr; } }
|
||||
|
||||
.store-filters-card {
|
||||
background: rgba(13, 18, 25, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.store-filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .store-filters-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 640px) { .store-filters-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.store-filter-field { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.store-filter-field .filter-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-filter-search { grid-column: span 2; }
|
||||
@media (max-width: 640px) { .store-filter-search { grid-column: span 2; } }
|
||||
.store-filters-extra {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.store-filters-extra summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: #7dd3fc;
|
||||
user-select: none;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.store-filter-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.store-filter-checks label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.store-filters-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.store-results-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-results-col { min-width: 0; }
|
||||
.store-table-wrap {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.store-results-table { font-size: 0.78rem; margin: 0; }
|
||||
.store-results-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #0d1219;
|
||||
z-index: 1;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.store-table-row { cursor: pointer; }
|
||||
.store-table-row:hover td { background: rgba(56, 189, 248, 0.06); }
|
||||
.store-table-row.selected td {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
box-shadow: inset 3px 0 0 #38bdf8;
|
||||
}
|
||||
.store-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.store-list { max-height: 520px; overflow-y: auto; border: 1px solid rgba(148,163,184,0.12); border-radius: 10px; }
|
||||
.store-list-item {
|
||||
padding: 0.65rem 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.08); cursor: pointer;
|
||||
@@ -91,3 +256,51 @@
|
||||
.store-checks label { display: flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #cbd5e1; }
|
||||
|
||||
.clients-empty { text-align: center; padding: 2rem; color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
/* ── 360° Klant (CRM) ── */
|
||||
.crm-360-shell {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
|
||||
overflow: hidden;
|
||||
min-height: 560px;
|
||||
}
|
||||
.crm-360-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: linear-gradient(90deg, rgba(56, 189, 248, 0.06), transparent 70%);
|
||||
}
|
||||
.crm-360-head h2 { margin: 0; font-size: 1.05rem; color: #f1f5f9; }
|
||||
.crm-360-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
|
||||
.crm-360-section {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.crm-360-stat {
|
||||
padding: 0.65rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
}
|
||||
.crm-360-stat .n { font-size: 1.2rem; font-weight: 800; color: #f1f5f9; }
|
||||
.crm-360-stat .l { font-size: 0.6rem; color: #64748b; text-transform: uppercase; }
|
||||
.a360-toolbar { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 0.5rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 800px) { .a360-toolbar { grid-template-columns: 1fr; } }
|
||||
.a360-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; padding: 1rem; }
|
||||
.a360-kpis { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||
@media (max-width: 700px) { .a360-kpis { grid-template-columns: 1fr 1fr; } }
|
||||
.a360-panel { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(148,163,184,0.12); background: rgba(0,0,0,0.25); min-height: 120px; }
|
||||
.a360-links { max-height: 200px; overflow-y: auto; }
|
||||
.a360-link-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.72rem; padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.06); color: #cbd5e1; }
|
||||
.a360-pill { display: inline-block; margin: 0.2rem; padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.68rem; background: rgba(56,189,248,0.12); color: #7dd3fc; }
|
||||
.a360-trend-row { display: flex; justify-content: space-between; font-size: 0.68rem; padding: 0.25rem 0; color: #94a3b8; }
|
||||
.a360-word { display: inline-block; margin: 0.15rem; padding: 0.15rem 0.4rem; font-size: 0.65rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #cbd5e1; }
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
/* Documents & NAS Workbench v2 */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.doc-hub {
|
||||
--doc-accent: #00e5ff;
|
||||
--doc-gold: #ffd700;
|
||||
--doc-purple: #a855f7;
|
||||
--doc-border: rgba(0, 229, 255, 0.14);
|
||||
--doc-text: #f1f5f9;
|
||||
--doc-muted: #94a3b8;
|
||||
--doc-dim: #64748b;
|
||||
}
|
||||
|
||||
.doc-hub .page-header h1 {
|
||||
font-size: 1.65rem;
|
||||
background: linear-gradient(90deg, #f8fafc 0%, #00e5ff 55%, #a855f7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.85);
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.doc-nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
transition: all 0.18s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.doc-nav-btn:hover { color: var(--doc-text); background: rgba(0, 229, 255, 0.06); }
|
||||
|
||||
.doc-nav-btn.active,
|
||||
#doctab-overview:checked ~ #doc-nav label[for="doctab-overview"],
|
||||
#doctab-labels:checked ~ #doc-nav label[for="doctab-labels"],
|
||||
#doctab-docling:checked ~ #doc-nav label[for="doctab-docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-nav label[for="doctab-powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-nav label[for="doctab-chat"] {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.12));
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Workbench shell */
|
||||
.doc-workbench {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--doc-border);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98), rgba(12, 18, 32, 0.95));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* Tab panels — CSS radio + #doc-tab-stage fallback (doc-tabs.js) */
|
||||
.doc-tab-input {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#doc-tab-stage {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
#doc-tab-stage [data-tab-panel] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#doctab-overview:checked ~ #doc-tab-stage [data-tab-panel="overview"],
|
||||
#doctab-labels:checked ~ #doc-tab-stage [data-tab-panel="labels"],
|
||||
#doctab-docling:checked ~ #doc-tab-stage [data-tab-panel="docling"],
|
||||
#doctab-powerpoint:checked ~ #doc-tab-stage [data-tab-panel="powerpoint"],
|
||||
#doctab-chat:checked ~ #doc-tab-stage [data-tab-panel="chat"],
|
||||
#doc-tab-stage [data-tab-panel].tab-active {
|
||||
display: block !important;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.doc-ts-cell, .dl-file-ts, .dl-ws-ts {
|
||||
font-size: 0.65rem;
|
||||
color: var(--doc-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-version-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.doc-row-fallback { opacity: 0.85; }
|
||||
|
||||
.share-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.share-crumb {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.share-crumb:hover { background: rgba(0, 229, 255, 0.1); }
|
||||
.share-crumb-sep { color: #475569; }
|
||||
.share-folder-item { border-left: 2px solid rgba(168, 85, 247, 0.35); }
|
||||
.share-browser-list { max-height: 52vh; overflow-y: auto; }
|
||||
|
||||
.doc-wb-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.05), transparent 70%);
|
||||
}
|
||||
|
||||
.doc-wb-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.doc-wb-head p { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--doc-dim); }
|
||||
|
||||
.doc-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.doc-kpi {
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.doc-kpi:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.doc-kpi:last-child { border-right: none; }
|
||||
.doc-kpi .lbl { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--doc-dim); }
|
||||
.doc-kpi .val { font-size: 1.5rem; font-weight: 800; color: var(--doc-text); line-height: 1.15; margin-top: 0.15rem; }
|
||||
|
||||
.doc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-toolbar .form-input { min-width: 140px; font-size: 0.8rem; }
|
||||
|
||||
/* Split layout */
|
||||
.doc-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .doc-split { grid-template-columns: 1fr; } }
|
||||
|
||||
.doc-split-col {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.doc-split-col:last-child { border-right: none; }
|
||||
|
||||
.doc-chart-wrap { height: 140px; position: relative; }
|
||||
|
||||
/* Document table */
|
||||
.doc-table-wrap { overflow-x: auto; max-height: 420px; overflow-y: auto; }
|
||||
|
||||
.doc-table-wrap .data-table { font-size: 0.78rem; }
|
||||
.doc-table-wrap .data-table th { position: sticky; top: 0; background: #0a0f18; z-index: 2; }
|
||||
.doc-table-wrap .data-table td { color: var(--doc-muted); vertical-align: top; }
|
||||
.doc-table-wrap .data-table td strong { color: var(--doc-text); }
|
||||
.doc-table-wrap .path-cell { font-size: 0.68rem; color: var(--doc-dim); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
|
||||
.doc-row-selected { background: rgba(0, 229, 255, 0.08) !important; }
|
||||
|
||||
/* Label chips */
|
||||
.doc-chip {
|
||||
display: inline-block;
|
||||
padding: 0.18rem 0.55rem;
|
||||
margin: 0.12rem 0.12rem 0 0;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-chip.muted { background: rgba(100, 116, 139, 0.15); color: var(--doc-dim); border-color: rgba(100, 116, 139, 0.25); }
|
||||
|
||||
/* Photo grid */
|
||||
.photo-masonry {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.photo-tile {
|
||||
border: 1px solid var(--doc-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(10, 15, 26, 0.95);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.photo-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 8px 28px rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.photo-tile img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
background: #060a10;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-tile-body {
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.photo-tile-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.photo-tile-id { font-size: 0.72rem; font-weight: 700; color: var(--doc-text); }
|
||||
.photo-tile-src { font-size: 0.62rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.photo-tile-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
min-height: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.photo-tile-ocr {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.4;
|
||||
color: var(--doc-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.8em;
|
||||
}
|
||||
|
||||
.photo-tile-meta { font-size: 0.62rem; color: var(--doc-dim); }
|
||||
|
||||
/* Label filter chips row */
|
||||
.label-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.label-filter-chip {
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.label-filter-chip.active, .label-filter-chip:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
color: #e0f2fe;
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
/* ── Label modal (fixed overlap + dark text) ── */
|
||||
.label-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(6px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.label-modal-backdrop.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label-modal {
|
||||
width: min(780px, 100%);
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #0f1520 0%, #080c14 100%);
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 1.35rem;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
color: var(--doc-text);
|
||||
}
|
||||
|
||||
.label-modal h3 { margin: 0; font-size: 1.15rem; color: #f8fafc; }
|
||||
.label-modal .modal-sub { margin: 0.3rem 0 0; font-size: 0.78rem; color: var(--doc-muted); }
|
||||
|
||||
.label-modal-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
padding: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) { .label-modal-preview { grid-template-columns: 1fr; } }
|
||||
|
||||
.label-modal-preview img {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
max-height: 160px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.modal-ocr-box {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1 !important;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.modal-ocr-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.label-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
min-height: 92px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.35);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.label-pick:hover {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick.selected {
|
||||
border-color: var(--pick-color, #00e5ff);
|
||||
background: rgba(0, 229, 255, 0.14);
|
||||
box-shadow: 0 0 0 1px var(--pick-color, #00e5ff), inset 0 0 24px rgba(0, 229, 255, 0.06);
|
||||
}
|
||||
|
||||
.label-pick-emoji { font-size: 1.5rem; line-height: 1; flex-shrink: 0; }
|
||||
|
||||
.label-pick-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.label-pick.selected .label-pick-name { color: #f8fafc; }
|
||||
|
||||
.label-modal-extra {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label-modal-extra label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--doc-muted);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.label-modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
/* Document label drawer */
|
||||
.doc-label-drawer {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.doc-label-drawer h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #f8fafc; }
|
||||
|
||||
/* ── Docling workbench ── */
|
||||
.dl-workbench { min-height: 620px; }
|
||||
|
||||
.dl-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 300px 1fr;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
/* v3 split workbench: files | editor+preview */
|
||||
.dl-body-v3 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) 1fr;
|
||||
min-height: 580px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.dl-head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
|
||||
.dl-auto-toggle { font-size: 0.72rem; padding: 0.3rem 0.55rem; white-space: nowrap; }
|
||||
|
||||
.dl-pipeline-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dl-pipeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) { .dl-pipeline-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 580px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dl-workspace.empty { justify-content: center; align-items: center; }
|
||||
|
||||
.dl-workspace-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-ws-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-ws-title { display: flex; align-items: center; gap: 0.65rem; min-width: 0; }
|
||||
.dl-ws-icon { font-size: 1.4rem; flex-shrink: 0; }
|
||||
.dl-ws-title strong { font-size: 0.88rem; color: var(--doc-text); display: block; }
|
||||
.dl-ws-path { font-size: 0.62rem; color: var(--doc-dim); display: block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-ws-kind { font-size: 0.62rem; padding: 0.15rem 0.45rem; border-radius: 999px; background: rgba(0,229,255,0.12); color: #7dd3fc; white-space: nowrap; }
|
||||
|
||||
.dl-loading-bar { height: 2px; overflow: hidden; background: rgba(148,163,184,0.1); }
|
||||
|
||||
.dl-workspace-panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) { .dl-workspace-panes { grid-template-columns: 1fr; } }
|
||||
|
||||
.dl-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.06);
|
||||
}
|
||||
|
||||
.dl-pane:last-child { border-right: none; }
|
||||
|
||||
.dl-pane-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--doc-dim);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dl-export-tabs-inline { margin: 0; }
|
||||
|
||||
.dl-editor-full {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.dl-preview-viewport {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-viewport .dl-preview-box {
|
||||
min-height: 200px;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dl-preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 480px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dl-preview-iframe {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dl-file-row { display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.dl-file-icon { font-size: 1rem; flex-shrink: 0; line-height: 1.2; }
|
||||
.dl-file-meta { flex: 1; min-width: 0; }
|
||||
.dl-file-ext { font-size: 0.58rem; color: #64748b; padding: 0.1rem 0.35rem; border-radius: 4px; background: rgba(100,116,139,0.15); flex-shrink: 0; }
|
||||
.dl-file-tags { display: flex; gap: 0.25rem; margin-top: 0.2rem; padding-left: 1.5rem; }
|
||||
.dl-file-badge.convert { background: rgba(0,229,255,0.15); color: #7dd3fc; }
|
||||
|
||||
.dl-body-v3 .dl-sidebar {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body-v3 { grid-template-columns: 1fr; }
|
||||
.dl-body-v3 .dl-sidebar { max-height: 280px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dl-body { grid-template-columns: 240px 1fr; }
|
||||
.dl-inspector { grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.1); }
|
||||
}
|
||||
|
||||
.dl-sidebar, .dl-options, .dl-preview, .dl-inspector {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
overflow-y: auto;
|
||||
max-height: 680px;
|
||||
}
|
||||
|
||||
.dl-sidebar { background: rgba(0, 0, 0, 0.22); }
|
||||
.dl-options { background: rgba(0, 0, 0, 0.12); }
|
||||
|
||||
.dl-section-title {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--doc-dim);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.dl-ext-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-ext-chip {
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-ext-chip.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-file-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.dl-file-item {
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.dl-file-item:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.dl-file-item.selected {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
}
|
||||
|
||||
.dl-file-name { font-size: 0.76rem; font-weight: 600; color: var(--doc-text); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dl-file-path { font-size: 0.62rem; color: var(--doc-dim); display: block; margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.dl-file-badge {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.dl-file-badge.pending { background: rgba(251, 191, 36, 0.15); color: #fbbf24; }
|
||||
.dl-file-badge.failed { background: rgba(251, 113, 133, 0.15); color: #fb7185; }
|
||||
|
||||
.dl-toggle-grid { display: grid; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(100, 116, 139, 0.2);
|
||||
font-size: 0.76rem;
|
||||
color: var(--doc-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.dl-toggle:has(input:checked) {
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
color: #e0f2fe;
|
||||
}
|
||||
|
||||
.dl-format-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; }
|
||||
|
||||
.dl-preview-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.dl-preview-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
|
||||
.dl-export-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.65rem; }
|
||||
|
||||
.dl-tab {
|
||||
padding: 0.32rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
background: transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-tab.active { border-color: #00e5ff; color: #e0f2fe; background: rgba(0, 229, 255, 0.12); }
|
||||
|
||||
.dl-preview-box {
|
||||
min-height: 320px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dl-preview-box.rendered { white-space: normal; }
|
||||
.dl-preview-box.rendered h1, .dl-preview-box.rendered h2, .dl-preview-box.rendered h3 { color: #f1f5f9; margin-top: 0.75rem; }
|
||||
.dl-preview-box.rendered p, .dl-preview-box.rendered li { color: #cbd5e1; }
|
||||
.dl-preview-box.rendered table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
||||
.dl-preview-box.rendered th, .dl-preview-box.rendered td { border: 1px solid rgba(148, 163, 184, 0.25); padding: 0.35rem; font-size: 0.72rem; }
|
||||
|
||||
.dl-search { width: 100%; margin-bottom: 0.5rem; font-size: 0.78rem; }
|
||||
|
||||
.dl-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.45rem; margin-bottom: 1rem; }
|
||||
|
||||
.dl-stat {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.dl-stat .n { font-size: 1.2rem; font-weight: 800; color: var(--doc-text); }
|
||||
.dl-stat .l { font-size: 0.6rem; color: var(--doc-dim); text-transform: uppercase; }
|
||||
|
||||
.dl-history { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.dl-history-item {
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
.dl-history-item:hover { background: rgba(0, 229, 255, 0.05); border-color: rgba(0, 229, 255, 0.15); }
|
||||
|
||||
.dl-progress {
|
||||
height: 3px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dl-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00e5ff, #a855f7);
|
||||
animation: dl-pulse 1.2s ease-in-out infinite;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.dl-editor {
|
||||
width: 100%;
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
background: #050810;
|
||||
border: 1px solid rgba(0, 229, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.ppt-workbench { min-height: 560px; }
|
||||
.ppt-grid { display: grid; grid-template-columns: 280px 1fr; min-height: 500px; }
|
||||
@media (max-width: 900px) { .ppt-grid { grid-template-columns: 1fr; } }
|
||||
.ppt-sidebar { padding: 1rem; border-right: 1px solid rgba(148, 163, 184, 0.08); background: rgba(0,0,0,0.22); max-height: 640px; overflow-y: auto; }
|
||||
.ppt-editor { padding: 1rem; }
|
||||
.ppt-meta-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
@media (max-width: 800px) { .ppt-meta-row { grid-template-columns: 1fr; } }
|
||||
.ppt-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.75rem; max-height: 480px; overflow-y: auto; }
|
||||
.ppt-slide-card { padding: 0.75rem; border-radius: 12px; border: 1px solid rgba(0,229,255,0.15); background: rgba(8,12,20,0.9); }
|
||||
|
||||
/* ── Document chat (RAG) ── */
|
||||
.doc-chat-workbench { min-height: 560px; display: flex; flex-direction: column; }
|
||||
.doc-chat-layout { display: grid; grid-template-columns: 1fr 280px; flex: 1; min-height: 420px; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
@media (max-width: 900px) { .doc-chat-layout { grid-template-columns: 1fr; } }
|
||||
.doc-chat-messages { padding: 1rem; overflow-y: auto; max-height: 480px; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.doc-chat-msg { padding: 0.75rem 0.9rem; border-radius: 12px; max-width: 92%; }
|
||||
.doc-chat-msg.user { align-self: flex-end; background: rgba(0,229,255,0.12); border: 1px solid rgba(0,229,255,0.25); }
|
||||
.doc-chat-msg.assistant { align-self: flex-start; background: rgba(8,12,20,0.95); border: 1px solid rgba(148,163,184,0.15); }
|
||||
.doc-chat-role { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; display: block; margin-bottom: 0.25rem; }
|
||||
.doc-chat-msg p { margin: 0; font-size: 0.84rem; line-height: 1.55; white-space: pre-wrap; }
|
||||
.doc-chat-sources { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
|
||||
.doc-chat-src { font-size: 0.68rem; padding: 0.15rem 0.45rem; border-radius: 6px; background: rgba(100,116,139,0.2); color: #7dd3fc; text-decoration: none; }
|
||||
.doc-chat-src:hover { background: rgba(0,229,255,0.15); }
|
||||
.doc-chat-side { padding: 1rem; border-left: 1px solid rgba(148,163,184,0.08); background: rgba(0,0,0,0.2); overflow-y: auto; max-height: 480px; }
|
||||
.doc-chat-hits { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.doc-chat-hit { padding: 0.5rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; font-size: 0.72rem; }
|
||||
.doc-chat-hit:hover { border-color: rgba(0,229,255,0.2); background: rgba(0,229,255,0.05); }
|
||||
.doc-chat-hit strong { display: block; font-size: 0.74rem; color: #e2e8f0; margin-bottom: 0.2rem; }
|
||||
.doc-chat-hit p { margin: 0; color: #64748b; font-size: 0.68rem; }
|
||||
.doc-chat-input { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid rgba(148,163,184,0.08); }
|
||||
.doc-chat-input .form-input { flex: 1; }
|
||||
|
||||
@keyframes dl-pulse {
|
||||
0%, 100% { transform: translateX(-100%); width: 30%; }
|
||||
50% { transform: translateX(200%); width: 50%; }
|
||||
}
|
||||
|
||||
.badge-positive { background: rgba(62, 207, 142, 0.2); color: #3ecf8e; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-neutral { background: rgba(107, 114, 128, 0.25); color: #cbd5e1; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
.badge-negative { background: rgba(239, 68, 68, 0.2); color: #ef4444; padding: 0.2rem 0.55rem; border-radius: 999px; font-size: 0.72rem; }
|
||||
@@ -0,0 +1,51 @@
|
||||
.doc-tabs { display:flex; gap:0.35rem; flex-wrap:wrap; margin-bottom:1rem; }
|
||||
.doc-tab {
|
||||
padding:0.45rem 0.9rem; border-radius:999px; font-size:0.78rem; cursor:pointer;
|
||||
border:1px solid rgba(0,229,255,0.15); background:rgba(13,18,25,0.8); color:#94a3b8;
|
||||
}
|
||||
.doc-tab.active { background:rgba(0,229,255,0.15); color:#e0f2fe; border-color:rgba(0,229,255,0.45); }
|
||||
|
||||
.label-modal-backdrop {
|
||||
position:fixed; inset:0; z-index:1200; background:rgba(0,0,0,0.72);
|
||||
display:flex; align-items:center; justify-content:center; padding:1rem;
|
||||
}
|
||||
.label-modal {
|
||||
width:min(720px, 100%); max-height:90vh; overflow:auto;
|
||||
background:linear-gradient(160deg,#0d1219,#080c12); border:1px solid rgba(0,229,255,0.2);
|
||||
border-radius:16px; padding:1.1rem; box-shadow:0 20px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.label-modal-head { display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.85rem; }
|
||||
.label-modal-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:0.45rem; }
|
||||
.label-pick {
|
||||
display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.65rem 0.4rem;
|
||||
border-radius:12px; border:1px solid rgba(100,116,139,0.35); background:rgba(15,23,42,0.6);
|
||||
cursor:pointer; font-size:0.72rem; text-align:center; transition:all .15s;
|
||||
}
|
||||
.label-pick:hover { border-color:rgba(0,229,255,0.35); }
|
||||
.label-pick.selected { border-color:var(--pick-color, #00e5ff); background:rgba(0,229,255,0.12); box-shadow:0 0 0 1px var(--pick-color, #00e5ff); }
|
||||
.label-pick-emoji { font-size:1.35rem; }
|
||||
|
||||
.docling-grid { display:grid; grid-template-columns:280px 1fr 1fr; gap:1rem; }
|
||||
@media (max-width:1100px) { .docling-grid { grid-template-columns:1fr; } }
|
||||
.docling-panel { border:1px solid rgba(0,229,255,0.12); border-radius:12px; padding:0.85rem; background:rgba(13,18,25,0.85); }
|
||||
.docling-file-list { max-height:420px; overflow:auto; }
|
||||
.docling-file-row {
|
||||
padding:0.45rem 0.55rem; border-radius:8px; cursor:pointer; font-size:0.75rem;
|
||||
border:1px solid transparent; margin-bottom:0.25rem;
|
||||
}
|
||||
.docling-file-row:hover { background:rgba(0,229,255,0.06); }
|
||||
.docling-file-row.selected { border-color:rgba(0,229,255,0.35); background:rgba(0,229,255,0.1); }
|
||||
.docling-opt-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.35rem 0.75rem; font-size:0.78rem; }
|
||||
.docling-export-tabs { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.65rem 0; }
|
||||
.docling-export-tab {
|
||||
padding:0.3rem 0.65rem; border-radius:999px; font-size:0.72rem; cursor:pointer;
|
||||
border:1px solid rgba(100,116,139,0.35); background:transparent; color:#94a3b8;
|
||||
}
|
||||
.docling-export-tab.active { border-color:#00e5ff; color:#e0f2fe; background:rgba(0,229,255,0.12); }
|
||||
.docling-preview {
|
||||
max-height:360px; overflow:auto; font-size:0.74rem; line-height:1.45;
|
||||
background:#060a10; border:1px solid rgba(0,229,255,0.1); border-radius:8px; padding:0.65rem;
|
||||
white-space:pre-wrap; word-break:break-word;
|
||||
}
|
||||
.docling-preview.html-preview { white-space:normal; }
|
||||
.docling-status { font-size:0.72rem; color:#94a3b8; margin-top:0.35rem; }
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Wereldexport — KPIs + layout lock */
|
||||
|
||||
.wereldexport-workspace.vtabs-layout {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
.wereldexport-workspace > .vtabs-nav {
|
||||
flex: 0 0 220px !important;
|
||||
width: 220px !important;
|
||||
max-width: 220px !important;
|
||||
}
|
||||
.wereldexport-workspace > .vtabs-content {
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.ei-neo-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 1100px) { .ei-neo-kpi-row { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 600px) { .ei-neo-kpi-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
.ei-neo-kpi {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 14, 20, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.ei-neo-kpi-top { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.ei-neo-ring {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
background: conic-gradient(var(--ring-color, #0ea5e9) calc(var(--ring-pct, 70) * 1%), rgba(30, 41, 59, 0.8) 0);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ei-neo-ring-inner {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: #0a0e14;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ei-neo-kpi-label { font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
|
||||
.ei-neo-kpi-value { font-size: 1.35rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; }
|
||||
|
||||
.ei-sync-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ei-sync-bar .btn { font-size: 0.72rem; }
|
||||
@@ -0,0 +1,368 @@
|
||||
/* Export Intel — page layout */
|
||||
.ei-page { --ei-accent: #0ea5e9; }
|
||||
.ei-header { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.ei-header h1 { font-size: 1.5rem; margin: 0; }
|
||||
.ei-subtitle { color: #94a3b8; font-size: 0.85rem; margin: 0.25rem 0 0; }
|
||||
.ei-phase-badge { font-size: 0.65rem; padding: 0.15rem 0.5rem; border-radius: 999px; background: rgba(14,165,233,0.15); color: #7dd3fc; border: 1px solid rgba(14,165,233,0.3); }
|
||||
.ei-kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.65rem; margin-bottom: 1rem; }
|
||||
.ei-kpi { padding: 0.65rem 0.75rem; border-radius: 12px; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-kpi span { display: block; font-size: 0.7rem; color: #94a3b8; }
|
||||
.ei-kpi strong { font-size: 1.25rem; color: #e2e8f0; }
|
||||
.ei-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.ei-filters select, .ei-filters input { font-size: 0.8rem; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1px solid rgba(148,163,184,0.2); background: #0f1419; color: #e2e8f0; }
|
||||
|
||||
.ei-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.65rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, rgba(14,165,233,0.08), rgba(15,20,25,0.95));
|
||||
border: 1px solid rgba(148,163,184,0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ei-filter-group { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
|
||||
.ei-filter-group-search { flex: 1 1 200px; min-width: 180px; }
|
||||
.ei-filter-label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; font-weight: 600; }
|
||||
.ei-filter-label select,
|
||||
.ei-filter-label input {
|
||||
font-size: 0.82rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148,163,184,0.22);
|
||||
background: #0a0e12;
|
||||
color: #e2e8f0;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.ei-filter-search-wrap input { width: 100%; min-width: 0; }
|
||||
.ei-filter-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
.ei-filter-meta { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
||||
.ei-filter-count { font-size: 0.75rem; color: #64748b; white-space: nowrap; }
|
||||
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
|
||||
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
|
||||
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
|
||||
.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); min-height: 400px; background: #0a0e14; }
|
||||
.ei-map-wrap #ei-map { height: 100%; min-height: 400px; }
|
||||
|
||||
/* Leaflet popups — rich card */
|
||||
.ei-map-wrap .leaflet-popup-content-wrapper,
|
||||
.ei-map-wrap .leaflet-popup-tip {
|
||||
background: #0f1724;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
|
||||
}
|
||||
.ei-map-wrap .leaflet-popup-content-wrapper { border-radius: 12px; padding: 0; }
|
||||
.ei-map-wrap .leaflet-popup-content { margin: 0; min-width: 260px; line-height: 1.35; }
|
||||
.ei-map-wrap .leaflet-popup-close-button {
|
||||
color: #94a3b8 !important; font-size: 1.2rem; padding: 6px 8px 0 0;
|
||||
}
|
||||
.ei-map-wrap .leaflet-popup-close-button:hover { color: #fff !important; background: transparent !important; }
|
||||
|
||||
.ei-popup-card { padding: 0.85rem 1rem 1rem; }
|
||||
.ei-popup-top { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; }
|
||||
.ei-popup-badge {
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--badge-color, #38bdf8);
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
padding: 0.2rem 0.5rem; border-radius: 6px;
|
||||
}
|
||||
.ei-popup-flag { font-size: 0.72rem; font-weight: 800; color: #64748b; }
|
||||
.ei-map-wrap .leaflet-popup-content .ei-popup-name {
|
||||
display: block; margin: 0 0 0.6rem; font-size: 1rem; font-weight: 700;
|
||||
color: #f8fafc !important; line-height: 1.3;
|
||||
}
|
||||
.ei-popup-grid { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; }
|
||||
.ei-popup-row { display: grid; grid-template-columns: 72px 1fr; gap: 0.35rem; font-size: 0.78rem; }
|
||||
.ei-popup-k { color: #64748b; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.ei-popup-v { color: #cbd5e1; word-break: break-word; }
|
||||
.ei-popup-v a { color: #38bdf8; text-decoration: none; }
|
||||
.ei-popup-v a:hover { text-decoration: underline; }
|
||||
.ei-popup-actions { border-top: 1px solid rgba(148,163,184,0.15); padding-top: 0.65rem; }
|
||||
.ei-popup-btn {
|
||||
width: 100%; border: none; border-radius: 8px; padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||||
background: linear-gradient(135deg, #0ea5e9, #0284c7); color: #fff;
|
||||
}
|
||||
.ei-popup-btn:hover { filter: brightness(1.08); }
|
||||
|
||||
.ei-seg-toggle {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ei-seg-btn {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: rgba(15,20,25,0.85);
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.ei-seg-btn:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.45); }
|
||||
.ei-seg-btn.active {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(14,165,233,0.65);
|
||||
background: rgba(14,165,233,0.18);
|
||||
box-shadow: 0 0 0 1px rgba(14,165,233,0.2);
|
||||
}
|
||||
|
||||
.ei-filter-group-maptypes { flex: 1 1 100%; max-width: 100%; }
|
||||
.ei-type-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.ei-type-chip {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148,163,184,0.22);
|
||||
background: rgba(15,20,25,0.9);
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ei-type-chip:hover { color: #e2e8f0; border-color: rgba(14,165,233,0.4); }
|
||||
.ei-type-chip.active {
|
||||
color: #f0fdf4;
|
||||
border-color: rgba(34,197,94,0.55);
|
||||
background: rgba(34,197,94,0.12);
|
||||
}
|
||||
|
||||
.ei-filter-group-halal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ei-filter-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ei-halal-min {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ei-halal-min input[type=range] { width: 90px; }
|
||||
|
||||
.ei-legend-row { display: flex; flex-wrap: wrap; gap: 0.65rem 1rem; }
|
||||
.ei-pin-hot .ei-pin-ring { box-shadow: 0 0 0 3px rgba(34,197,94,0.45); }
|
||||
|
||||
.ei-halal-markets { margin-bottom: 0.85rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-halal-markets h4 { margin: 0 0 0.45rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
|
||||
.ei-halal-market-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.ei-halal-market-row:hover { color: #38bdf8; }
|
||||
.ei-halal-score { color: #22c55e; font-size: 0.82rem; }
|
||||
.ei-halal-pill {
|
||||
display: inline-block;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ei-halal-pill.ei-halal-hot { background: rgba(34,197,94,0.2); color: #4ade80; }
|
||||
.ei-halal-pill.ei-halal-warm { background: rgba(234,179,8,0.2); color: #facc15; }
|
||||
.ei-halal-pill.ei-halal-mild { background: rgba(249,115,22,0.2); color: #fb923c; }
|
||||
|
||||
.ei-list-focus {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(34,197,94,0.35);
|
||||
background: rgba(34,197,94,0.06);
|
||||
}
|
||||
.ei-list-focus-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.ei-list-focus-title { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f8fafc; line-height: 1.3; }
|
||||
.ei-list-focus-meta { margin: 0 0 0.65rem; font-size: 0.75rem; color: #94a3b8; }
|
||||
.ei-list-focus-kv { display: grid; grid-template-columns: 72px 1fr; gap: 0.3rem; font-size: 0.78rem; margin-bottom: 0.3rem; }
|
||||
.ei-list-focus-kv span:first-child { color: #64748b; }
|
||||
.ei-list-focus-kv a { color: #38bdf8; word-break: break-all; }
|
||||
.ei-list-focus-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; margin-top: 0.65rem; }
|
||||
.ei-halal-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.5rem 0; }
|
||||
.ei-halal-tag {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(14,165,233,0.12);
|
||||
color: #7dd3fc;
|
||||
border: 1px solid rgba(14,165,233,0.2);
|
||||
}
|
||||
.ei-row-active { background: rgba(14,165,233,0.12) !important; }
|
||||
.ei-row-name { font-weight: 600; color: #e2e8f0; }
|
||||
.ei-contact-cell { font-size: 0.72rem; color: #94a3b8; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ei-type-pill-sm { font-size: 0.65rem; padding: 0.1rem 0.35rem; }
|
||||
.ei-contact-row-compact { margin-bottom: 0.45rem; font-size: 0.78rem; }
|
||||
.ei-contact-source { display: block; font-size: 0.68rem; color: #64748b; }
|
||||
.ei-drawer-coords { font-size: 0.78rem; color: #94a3b8; }
|
||||
.ei-drawer-coords a { margin-left: 0.5rem; color: #38bdf8; }
|
||||
|
||||
.ei-selection-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(14,165,233,0.25);
|
||||
background: rgba(14,165,233,0.06);
|
||||
}
|
||||
.ei-selection-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.ei-selection-count { font-size: 0.78rem; font-weight: 700; color: #38bdf8; }
|
||||
.ei-selection-hint { font-size: 0.72rem; color: #64748b; }
|
||||
|
||||
.ei-th-check, .ei-td-check { width: 28px; text-align: center; }
|
||||
.ei-th-fav, .ei-td-fav { width: 32px; text-align: center; }
|
||||
.ei-fav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #64748b;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.ei-fav-btn.active, .ei-fav-btn:hover { color: #facc15; }
|
||||
.ei-row-selected { background: rgba(14,165,233,0.08) !important; }
|
||||
.ei-row-crm .ei-row-name { padding-right: 0.25rem; }
|
||||
.ei-crm-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 800;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(34,197,94,0.2);
|
||||
color: #4ade80;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ei-filter-fav { color: #facc15 !important; margin-left: 0.35rem; }
|
||||
.ei-pipeline-hint { font-size: 0.82rem; color: #94a3b8; margin: 0 0 1rem; }
|
||||
|
||||
.ei-modal-root { position: fixed; inset: 0; z-index: 1300; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.ei-modal-card {
|
||||
position: relative;
|
||||
z-index: 1302;
|
||||
width: min(420px, 96vw);
|
||||
padding: 1.25rem;
|
||||
border-radius: 14px;
|
||||
background: #0f1419;
|
||||
border: 1px solid rgba(14,165,233,0.35);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
}
|
||||
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
|
||||
.ei-modal-card p { font-size: 0.85rem; color: #94a3b8; margin: 0 0 0.75rem; }
|
||||
.ei-modal-note { font-size: 0.75rem !important; color: #64748b !important; }
|
||||
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
/* Map pins */
|
||||
.ei-pin-wrap { background: transparent !important; border: none !important; }
|
||||
.ei-pin {
|
||||
width: 30px; height: 30px; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ei-pin-ring {
|
||||
position: absolute; inset: 0; border-radius: 50%;
|
||||
background: var(--pin-color);
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.45);
|
||||
}
|
||||
.ei-pin-icon { position: relative; z-index: 1; font-size: 14px; line-height: 1; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.3)); }
|
||||
|
||||
/* Clusters */
|
||||
.ei-cluster-wrap { background: transparent !important; border: none !important; }
|
||||
.ei-cluster {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, #0ea5e9, #0369a1);
|
||||
border: 2px solid rgba(255,255,255,0.55);
|
||||
box-shadow: 0 4px 16px rgba(14,165,233,0.45);
|
||||
color: #fff; font-weight: 800; font-size: 0.85rem;
|
||||
}
|
||||
.ei-cluster-md { width: 50px; height: 50px; font-size: 0.9rem; background: linear-gradient(135deg, #8b5cf6, #6d28d9); box-shadow: 0 4px 16px rgba(139,92,246,0.45); }
|
||||
.ei-cluster-lg { width: 56px; height: 56px; font-size: 0.95rem; background: linear-gradient(135deg, #f59e0b, #d97706); box-shadow: 0 4px 16px rgba(245,158,11,0.45); }
|
||||
.ei-map-wrap .marker-cluster-small,
|
||||
.ei-map-wrap .marker-cluster-medium,
|
||||
.ei-map-wrap .marker-cluster-large { background: transparent !important; }
|
||||
.ei-map-wrap .marker-cluster-small div,
|
||||
.ei-map-wrap .marker-cluster-medium div,
|
||||
.ei-map-wrap .marker-cluster-large div { background: transparent !important; color: transparent !important; }
|
||||
|
||||
.ei-map-legend {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.65rem; font-size: 0.72rem; color: #94a3b8;
|
||||
}
|
||||
.ei-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.ei-leg-pin { font-style: normal; }
|
||||
|
||||
.ei-map-empty { margin-bottom: 0.65rem; }
|
||||
.ei-map-empty-inner {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
font-size: 0.82rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.ei-map-empty-inner strong { display: block; color: #fbbf24; margin-bottom: 0.35rem; }
|
||||
.ei-map-empty-inner p { margin: 0.25rem 0; }
|
||||
.ei-map-empty-actions { display: flex; gap: 0.5rem; margin-top: 0.65rem; flex-wrap: wrap; }
|
||||
|
||||
.ei-panel { border-radius: 14px; padding: 0.75rem; background: rgba(10,14,20,0.85); border: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-table-wrap { overflow-x: auto; max-height: 480px; }
|
||||
.ei-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||||
.ei-table th, .ei-table td { padding: 0.5rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.08); }
|
||||
.ei-table th { color: #94a3b8; font-weight: 600; position: sticky; top: 0; background: #0f1419; }
|
||||
.ei-table tr:hover td { background: rgba(14,165,233,0.05); }
|
||||
.ei-empty { text-align: center; padding: 2rem; color: #64748b; }
|
||||
.ei-empty-cta { margin-top: 0.75rem; }
|
||||
.ei-type-pill { font-size: 0.65rem; padding: 0.1rem 0.45rem; border-radius: 6px; background: rgba(148,163,184,0.15); }
|
||||
.ei-type-distributor { color: #fbbf24; }
|
||||
.ei-type-wholesaler { color: #eab308; }
|
||||
.ei-type-contract_caterer { color: #a78bfa; }
|
||||
.ei-confidence { font-size: 0.65rem; color: #64748b; }
|
||||
.ei-drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 1200; }
|
||||
.ei-drawer-root { position: relative; z-index: 1201; }
|
||||
.ei-drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: #0f1419; border-left: 1px solid rgba(14,165,233,0.2); z-index: 1202; overflow-y: auto; box-shadow: -8px 0 32px rgba(0,0,0,0.45); }
|
||||
.ei-drawer-rich { width: min(480px, 100vw); padding: 0; display: flex; flex-direction: column; }
|
||||
.ei-drawer-head { padding: 0.75rem 1rem; border-bottom: 1px solid rgba(148,163,184,0.12); }
|
||||
.ei-drawer-body { padding: 1rem; overflow-y: auto; flex: 1; }
|
||||
.ei-drawer-hero { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.35rem; }
|
||||
.ei-drawer-badge { font-size: 0.68rem; padding: 0.2rem 0.55rem; border-radius: 6px; background: rgba(148,163,184,0.15); text-transform: capitalize; }
|
||||
.ei-drawer-flag { font-size: 0.75rem; font-weight: 800; color: #64748b; }
|
||||
.ei-drawer-title { margin: 0 0 0.35rem; font-size: 1.25rem; line-height: 1.25; color: #f8fafc; }
|
||||
.ei-drawer-meta { font-size: 0.78rem; color: #94a3b8; margin: 0 0 1rem; }
|
||||
.ei-drawer-meta strong { color: #e2e8f0; }
|
||||
.ei-drawer-section { margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(148,163,184,0.1); }
|
||||
.ei-drawer-section h4 { margin: 0 0 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
|
||||
.ei-drawer-kv { display: grid; grid-template-columns: 88px 1fr; gap: 0.35rem; font-size: 0.82rem; margin-bottom: 0.35rem; }
|
||||
.ei-drawer-kv span:first-child { color: #64748b; }
|
||||
.ei-drawer-kv a { color: #38bdf8; word-break: break-all; }
|
||||
.ei-drawer-empty { font-size: 0.78rem; color: #64748b; margin: 0; }
|
||||
.ei-contact-row { padding: 0.5rem 0; border-bottom: 1px solid rgba(148,163,184,0.08); font-size: 0.82rem; }
|
||||
.ei-contact-row a { color: #38bdf8; }
|
||||
.ei-contact-role { font-size: 0.72rem; color: #64748b; margin-left: 0.35rem; }
|
||||
.ei-drawer-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||
.ei-sync-msg { font-size: 0.75rem; color: #7dd3fc; margin-top: 0.5rem; }
|
||||
@@ -0,0 +1,148 @@
|
||||
/* Herman browser assistant — floating widget */
|
||||
.ha-fab {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
z-index: 1500;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(167, 139, 250, 0.55);
|
||||
background: linear-gradient(145deg, #1e293b, #0f1419);
|
||||
color: #f8fafc;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.ha-fab:hover { transform: scale(1.06); box-shadow: 0 10px 32px rgba(167, 139, 250, 0.25); }
|
||||
.ha-fab.is-open { background: linear-gradient(145deg, #4c1d95, #1e1b4b); }
|
||||
|
||||
.ha-panel {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 5rem;
|
||||
z-index: 1499;
|
||||
width: min(400px, calc(100vw - 2rem));
|
||||
max-height: min(560px, calc(100vh - 7rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(167, 139, 250, 0.35);
|
||||
background: rgba(10, 14, 20, 0.97);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ha-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
}
|
||||
.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; }
|
||||
.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; }
|
||||
.ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; }
|
||||
.ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; }
|
||||
|
||||
.ha-confirm {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border-bottom: 1px solid rgba(251, 191, 36, 0.25);
|
||||
font-size: 0.75rem;
|
||||
color: #fde68a;
|
||||
}
|
||||
.ha-confirm-actions { display: flex; gap: 0.35rem; margin-top: 0.35rem; }
|
||||
|
||||
.ha-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 200px;
|
||||
max-height: 320px;
|
||||
}
|
||||
.ha-bubble {
|
||||
max-width: 92%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ha-bubble-user {
|
||||
align-self: flex-end;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border: 1px solid rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
.ha-bubble-agent {
|
||||
align-self: flex-start;
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
.ha-bubble-meta { font-size: 0.62rem; color: #64748b; margin-bottom: 0.15rem; }
|
||||
.ha-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.ha-preview {
|
||||
margin: 0 0.65rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
color: #94a3b8;
|
||||
border: 1px dashed rgba(167, 139, 250, 0.3);
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
.ha-preview.is-interim { font-style: italic; }
|
||||
|
||||
.ha-input-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
align-items: center;
|
||||
}
|
||||
.ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; }
|
||||
.ha-mic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(167, 139, 250, 0.45);
|
||||
background: rgba(15, 20, 25, 0.95);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ha-mic.is-listening { border-color: #f87171; animation: ha-pulse 1s infinite; }
|
||||
.ha-mic.is-busy { border-color: #38bdf8; }
|
||||
@keyframes ha-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.15); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); }
|
||||
}
|
||||
|
||||
.ha-status { font-size: 0.65rem; color: #64748b; padding: 0 0.65rem 0.35rem; }
|
||||
|
||||
.ha-results-modal { z-index: 1600; }
|
||||
.ha-results-card { width: min(640px, 96vw); max-height: 80vh; }
|
||||
|
||||
.ei-modal-root {
|
||||
position: fixed; inset: 0; z-index: 1600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem; background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ei-modal-card {
|
||||
background: #0f1419; border-radius: 14px; padding: 1.25rem;
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
max-width: 96vw; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ei-modal-card h3 { margin: 0 0 0.5rem; color: #f8fafc; }
|
||||
.ei-modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
|
||||
.voice-results-table th, .voice-results-table td { padding: 0.4rem 0.55rem; border-top: 1px solid rgba(148,163,184,0.1); font-size: 0.78rem; }
|
||||
.voice-results-table a { color: #38bdf8; }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ha-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; }
|
||||
.ha-fab { right: 1rem; bottom: 1rem; }
|
||||
}
|
||||
@@ -558,3 +558,97 @@
|
||||
display: flex; gap: 1rem; align-items: center; margin-bottom: 1rem;
|
||||
}
|
||||
.agent-soul-editor .agent-editor-header h2 { margin: 0; }
|
||||
|
||||
/* Herman dashboard tabs */
|
||||
.herman-dash-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.35rem;
|
||||
background: rgba(6, 10, 18, 0.88);
|
||||
border: 1px solid rgba(0, 229, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.herman-dash-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
.herman-dash-tab:hover { color: #e2e8f0; background: rgba(0, 229, 255, 0.06); }
|
||||
.herman-dash-tab.active {
|
||||
color: #e0f2fe;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 255, 0.18), rgba(168, 85, 247, 0.1));
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
box-shadow: 0 0 18px rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
.herman-dash-tab-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.hm-rss-teaser {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
.hm-rss-teaser a { color: #38bdf8; }
|
||||
|
||||
/* RSS hub tab */
|
||||
.herman-rss-hub { margin-top: 0.25rem; }
|
||||
.herman-rss-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.herman-rss-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; }
|
||||
.herman-rss-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.herman-rss-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@media (max-width: 720px) { .herman-rss-filters { grid-template-columns: 1fr; } }
|
||||
.herman-rss-count { font-size: 0.72rem; color: #94a3b8; white-space: nowrap; }
|
||||
.herman-rss-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 0.9fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1000px) { .herman-rss-grid { grid-template-columns: 1fr; } }
|
||||
.herman-rss-list {
|
||||
max-height: 68vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.herman-rss-card { margin: 0; }
|
||||
.herman-rss-side-item {
|
||||
padding: 0.45rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.herman-rss-side-item a { color: #e2e8f0; text-decoration: none; display: block; margin-bottom: 0.15rem; }
|
||||
.herman-rss-side-item a:hover { color: #7dd3fc; }
|
||||
.herman-rss-side-item small { color: #64748b; font-size: 0.65rem; }
|
||||
.herman-rss-col-side .hm-chart-panel { margin-bottom: 0.75rem; }
|
||||
.herman-rss-col-side h4 { margin: 0 0 0.5rem; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
@@ -68,16 +68,6 @@ body { overflow-x: hidden; padding-bottom: var(--safe-bottom); }
|
||||
}
|
||||
.hm-exec-grid { grid-template-columns: 1fr !important; }
|
||||
.hm-neo-charts, .analytics-charts, .beurs-two-col { grid-template-columns: 1fr !important; }
|
||||
.vtabs-layout { grid-template-columns: 1fr !important; }
|
||||
.vtabs-nav {
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: relative !important;
|
||||
}
|
||||
.vtabs-nav::before { display: none; }
|
||||
.vtab-btn { flex: 0 0 auto; white-space: nowrap; }
|
||||
.retail-workspace, .retail-workspace.with-filters {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/* Parse Intelligence Workbench */
|
||||
.pi-workbench {
|
||||
--pi-accent: #00e5ff;
|
||||
--pi-gold: #ffd700;
|
||||
--pi-purple: #a855f7;
|
||||
--pi-bg: rgba(6, 10, 18, 0.92);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.12);
|
||||
background: linear-gradient(165deg, rgba(8, 12, 22, 0.98) 0%, rgba(12, 18, 32, 0.95) 100%);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pi-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: linear-gradient(90deg, rgba(0, 229, 255, 0.06) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.pi-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
background: linear-gradient(90deg, #f1f5f9, #00e5ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.pi-header-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
|
||||
.pi-kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pi-kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.pi-kpi-cell {
|
||||
padding: 0.85rem 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.pi-kpi-cell:hover { background: rgba(0, 229, 255, 0.04); }
|
||||
.pi-kpi-cell:last-child { border-right: none; }
|
||||
.pi-kpi-cell .lbl { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; }
|
||||
.pi-kpi-cell .val { font-size: 1.4rem; font-weight: 800; color: #f8fafc; line-height: 1.2; }
|
||||
.pi-kpi-cell .sub { font-size: 0.68rem; color: #475569; margin-top: 0.1rem; }
|
||||
|
||||
.pi-body {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr 380px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.pi-body { grid-template-columns: 240px 1fr; }
|
||||
.pi-detail { display: none; }
|
||||
.pi-body.detail-open .pi-detail { display: flex; grid-column: 1 / -1; border-top: 1px solid rgba(148, 163, 184, 0.12); }
|
||||
}
|
||||
|
||||
.pi-filters {
|
||||
padding: 1rem;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
overflow-y: auto;
|
||||
max-height: 720px;
|
||||
}
|
||||
|
||||
.pi-filters label {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
margin: 0.85rem 0 0.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-filters label:first-child { margin-top: 0; }
|
||||
|
||||
.pi-search {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-search:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 229, 255, 0.55);
|
||||
box-shadow: 0 0 0 2px rgba(0, 229, 255, 0.12);
|
||||
}
|
||||
|
||||
.pi-select, .pi-range {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
color: #e2e8f0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pi-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
margin: 0.45rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-toggle-row input { accent-color: #00e5ff; }
|
||||
|
||||
.pi-filter-chips { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.65rem; }
|
||||
|
||||
.pi-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 229, 255, 0.12);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
color: #7dd3fc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-filter-chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pi-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pi-trends-bar {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(168, 85, 247, 0.04);
|
||||
}
|
||||
|
||||
.pi-trends-bar .lbl {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pi-hype-cloud { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
|
||||
.pi-hype-chip {
|
||||
font-size: 0.76rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 85, 247, 0.35);
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #e9d5ff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pi-hype-chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(168, 85, 247, 0.2); }
|
||||
.pi-hype-chip.active {
|
||||
border-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
color: #a5f3fc;
|
||||
box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
|
||||
}
|
||||
|
||||
.pi-hype-chip.cross {
|
||||
border-color: rgba(255, 215, 0, 0.45);
|
||||
background: rgba(255, 215, 0, 0.08);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.pi-hype-chip .sc { opacity: 0.6; font-size: 0.62rem; margin-left: 0.2rem; }
|
||||
|
||||
.pi-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.pi-toolbar .count {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.pi-toolbar .count strong { color: #00e5ff; }
|
||||
|
||||
.pi-sort-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-sort-btn.active {
|
||||
border-color: rgba(0, 229, 255, 0.45);
|
||||
color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
.pi-view-toggle { display: flex; border-radius: 8px; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); }
|
||||
|
||||
.pi-view-toggle button {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pi-view-toggle button.active { background: rgba(0, 229, 255, 0.12); color: #00e5ff; }
|
||||
|
||||
.pi-table-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pi-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: rgba(10, 15, 25, 0.98);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
color: #64748b;
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pi-table th:hover { color: #00e5ff; }
|
||||
|
||||
.pi-table th .arrow { margin-left: 0.25rem; opacity: 0.7; }
|
||||
|
||||
.pi-table td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
vertical-align: middle;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-table tr { cursor: pointer; transition: background 0.12s; }
|
||||
|
||||
.pi-table tbody tr:hover { background: rgba(0, 229, 255, 0.05); }
|
||||
|
||||
.pi-table tbody tr.selected {
|
||||
background: rgba(0, 229, 255, 0.1);
|
||||
box-shadow: inset 3px 0 0 #00e5ff;
|
||||
}
|
||||
|
||||
.pi-table .site-cell { color: #7dd3fc; font-weight: 600; font-size: 0.75rem; }
|
||||
.pi-table .title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pi-table .num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
|
||||
.pi-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.62rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pi-badge.changed { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
|
||||
.pi-badge.hype-high { background: rgba(168, 85, 247, 0.25); color: #e9d5ff; }
|
||||
|
||||
.pi-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-y: auto;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.pi-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(7, 11, 18, 0.8);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s;
|
||||
}
|
||||
|
||||
.pi-card:hover { border-color: rgba(0, 229, 255, 0.35); transform: translateY(-1px); }
|
||||
.pi-card.selected { border-color: #00e5ff; box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.2); }
|
||||
|
||||
.pi-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pi-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pi-detail-head {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-head h3 { margin: 0 0 0.35rem; font-size: 0.95rem; color: #f1f5f9; line-height: 1.35; }
|
||||
|
||||
.pi-detail-meta { font-size: 0.72rem; color: #64748b; }
|
||||
|
||||
.pi-detail-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.pi-detail-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.pi-detail-tabs button.active {
|
||||
color: #00e5ff;
|
||||
border-bottom-color: #00e5ff;
|
||||
background: rgba(0, 229, 255, 0.05);
|
||||
}
|
||||
|
||||
.pi-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.85rem 1rem;
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.pi-read-text {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.pi-read-text mark {
|
||||
background: rgba(255, 215, 0, 0.35);
|
||||
color: #fef9c3;
|
||||
padding: 0 0.15rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.pi-link-list { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.pi-link-list li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.06);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pi-link-list a { color: #86efac; word-break: break-all; }
|
||||
|
||||
.pi-heading-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
|
||||
.pi-heading-item {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 229, 255, 0.06);
|
||||
border-left: 3px solid rgba(0, 229, 255, 0.4);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.pi-loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pi-empty strong { display: block; color: #94a3b8; margin-bottom: 0.35rem; }
|
||||
@@ -0,0 +1,493 @@
|
||||
/* Revenue Cockpit — Excel layout (licht) */
|
||||
.rc-excel-page {
|
||||
--rc-green: #92d050;
|
||||
--rc-red: #ff0000;
|
||||
--rc-orange: #ffc000;
|
||||
--rc-yellow: #ffff00;
|
||||
--rc-blue: #bdd7ee;
|
||||
--rc-border: #b4b4b4;
|
||||
--rc-text: #000;
|
||||
background: #e8e8e8;
|
||||
color: var(--rc-text);
|
||||
margin: -1rem -1.25rem 0;
|
||||
padding: 0.75rem 1rem 2rem;
|
||||
min-height: calc(100vh - 4rem);
|
||||
font-family: Calibri, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.persona-ceo .main-topnav:has(.rc-excel-page),
|
||||
.main-topnav:has(.rc-excel-page) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.rc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rc-toolbar-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
}
|
||||
.rc-toolbar-actions { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.rc-save-hint { font-size: 0.72rem; color: #666; }
|
||||
.rc-saved { color: #217346; }
|
||||
.rc-btn {
|
||||
border: 1px solid #aaa;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
color: #222;
|
||||
}
|
||||
.rc-btn:hover { background: #f3f3f3; }
|
||||
.rc-btn-primary { background: #217346; color: #fff; border-color: #217346; }
|
||||
.rc-btn-import { font-size: 0.72rem; }
|
||||
|
||||
.rc-sheet-wrap {
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 2px 3px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
.rc-sheet-wrap.rc-loading { opacity: 0.65; pointer-events: none; }
|
||||
|
||||
.rc-sheet {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rc-col-logo { width: 72px; }
|
||||
.rc-col-deal { width: 16%; }
|
||||
.rc-col-num { width: 10%; }
|
||||
.rc-col-steps { width: auto; }
|
||||
|
||||
/* Alleen bewerk-modus (echte Excel) — niet lekken naar cockpit */
|
||||
.rc-sheet .rc-row td {
|
||||
border: 1px solid var(--rc-border);
|
||||
vertical-align: top;
|
||||
padding: 4px 6px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.rc-sheet .rc-row-goals td {
|
||||
background: var(--rc-yellow);
|
||||
font-weight: 600;
|
||||
min-height: 48px;
|
||||
}
|
||||
.rc-sheet .rc-cell-tagline {
|
||||
font-size: 14pt;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rc-sheet .rc-row-head td {
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #666;
|
||||
}
|
||||
.rc-sheet .rc-th { text-align: left; }
|
||||
.rc-sheet .rc-num { text-align: right; }
|
||||
|
||||
.rc-sheet .rc-style-green td:not(.rc-logo-cell) { background: var(--rc-green); }
|
||||
.rc-sheet .rc-style-red td:not(.rc-logo-cell) { background: var(--rc-red); color: #000; }
|
||||
.rc-sheet .rc-style-orange td:not(.rc-logo-cell) { background: var(--rc-orange); }
|
||||
.rc-sheet .rc-style-yellow td:not(.rc-logo-cell) { background: var(--rc-yellow); font-weight: 600; }
|
||||
.rc-sheet .rc-style-blue td:not(.rc-logo-cell) { background: var(--rc-blue); }
|
||||
.rc-sheet .rc-style-white td:not(.rc-logo-cell) { background: #fff; }
|
||||
|
||||
.rc-sheet .rc-data-row:hover td:not(.rc-logo-cell) { outline: 1px solid #217346; outline-offset: -1px; }
|
||||
.rc-sheet .rc-selected td:not(.rc-logo-cell) { box-shadow: inset 0 0 0 2px #217346; }
|
||||
|
||||
.rc-sheet .rc-cell-edit:focus {
|
||||
outline: 2px solid #217346;
|
||||
outline-offset: -2px;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
.rc-sheet .rc-cell-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.rc-sheet .rc-cell-deal { font-weight: 600; }
|
||||
.rc-sheet .rc-cell-steps { font-size: 10pt; min-height: 2.5em; }
|
||||
|
||||
.rc-sheet .rc-logo-cell {
|
||||
background: #fff !important;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
padding: 6px 4px;
|
||||
width: 72px;
|
||||
}
|
||||
.rc-logo-cucina .rc-logo-ring {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 6.5pt;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
}
|
||||
.rc-logo-foodlinkk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.rc-fl-brand {
|
||||
font-size: 11pt;
|
||||
font-weight: 900;
|
||||
color: #111;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.rc-fl-brand::first-letter { color: #f5c400; }
|
||||
.rc-fl-sub {
|
||||
font-size: 5.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #333;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Rij panel */
|
||||
.rc-row-panel {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border: 1px solid #888;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.rc-row-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.rc-row-panel-body { padding: 0.65rem 0.75rem; }
|
||||
.rc-row-panel-body label { display: block; font-size: 0.72rem; color: #555; }
|
||||
.rc-inline { display: flex; gap: 0.35rem; margin-top: 0.25rem; }
|
||||
.rc-inline select, .rc-inline input {
|
||||
flex: 1;
|
||||
border: 1px solid #bbb;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.rc-style-select {
|
||||
border: 1px solid #bbb;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
}
|
||||
|
||||
.rc-footnote {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-excel-page { margin: -0.5rem; padding: 0.5rem; }
|
||||
.rc-row-panel { left: 0.5rem; right: 0.5rem; width: auto; }
|
||||
}
|
||||
|
||||
/* ── Cockpit: Excel-layout + charts (dark theme) ── */
|
||||
.rc-page {
|
||||
--rc-green: #4ade80;
|
||||
--rc-red: #f87171;
|
||||
--rc-orange: #fb923c;
|
||||
--rc-yellow: #facc15;
|
||||
--rc-blue: #60a5fa;
|
||||
--rc-green-fill: #22c55e;
|
||||
--rc-red-fill: #ef4444;
|
||||
--rc-orange-fill: #f59e0b;
|
||||
--rc-yellow-fill: #eab308;
|
||||
--rc-blue-fill: #3b82f6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.rc-page.rc-mode-sheet { background: transparent; }
|
||||
|
||||
.rc-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.rc-header h1 { margin: 0; font-size: 1.25rem; }
|
||||
.rc-subtitle { margin: 0.25rem 0 0; font-size: 0.72rem; color: #64748b; }
|
||||
.rc-header-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
|
||||
|
||||
.rc-view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid rgba(148,163,184,0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-toggle-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rc-toggle-btn.active { background: rgba(34,197,94,0.15); color: #4ade80; }
|
||||
|
||||
.rc-cockpit.rc-loading { opacity: 0.55; pointer-events: none; }
|
||||
|
||||
.rc-summary-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: rgba(15,20,25,0.6);
|
||||
border: 1px solid rgba(148,163,184,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.rc-sum-item { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.rc-sum-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: #94a3b8; }
|
||||
.rc-sum-item strong { font-size: 1.05rem; color: #4ade80; font-variant-numeric: tabular-nums; }
|
||||
.rc-filter-inline {
|
||||
flex: 1 1 140px;
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148,163,184,0.18);
|
||||
background: #0a0e12;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.rc-style-dots { display: flex; gap: 0.3rem; align-items: center; }
|
||||
.rc-dot-btn {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148,163,184,0.3);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.rc-dot-btn.active { border-color: #fff; box-shadow: 0 0 0 1px #38bdf8; }
|
||||
.rc-dot-green { background: var(--rc-green); }
|
||||
.rc-dot-red { background: var(--rc-red); }
|
||||
.rc-dot-orange { background: var(--rc-orange); }
|
||||
.rc-dot-yellow { background: var(--rc-yellow); }
|
||||
.rc-dot-blue { background: var(--rc-blue); }
|
||||
.rc-dot-white { background: #e2e8f0; }
|
||||
|
||||
.rc-visual-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||
background: #0a0e12;
|
||||
}
|
||||
|
||||
.rc-visual-table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
background: #111820;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.rc-col-deal { width: 14%; }
|
||||
.rc-col-chart-num { width: 12%; }
|
||||
.rc-col-donut { width: 72px; }
|
||||
.rc-col-steps { width: auto; }
|
||||
|
||||
.rc-visual-goals td {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #fef3c7;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(234, 179, 8, 0.25);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-vtagline { font-style: italic; text-align: center; color: #fde047; font-size: 0.95rem; }
|
||||
|
||||
.rc-visual-head td {
|
||||
background: #1a2332;
|
||||
color: #cbd5e1;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid rgba(148,163,184,0.2);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rc-visual-row { cursor: pointer; }
|
||||
.rc-visual-row:hover td { background: #1e2a3a !important; }
|
||||
.rc-visual-row.rc-row-open td { background: #1a3050 !important; }
|
||||
|
||||
.rc-visual-row td,
|
||||
.rc-visual-goals td,
|
||||
.rc-visual-head td,
|
||||
.rc-vcell {
|
||||
border-bottom: 1px solid rgba(148,163,184,0.1);
|
||||
padding: 10px 12px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.45;
|
||||
background: #151c28;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Status = gekleurde linkerrand, neutrale donkere rij */
|
||||
.rc-visual-row.rc-style-green td { box-shadow: inset 4px 0 0 #4ade80; }
|
||||
.rc-visual-row.rc-style-red td { box-shadow: inset 4px 0 0 #f87171; }
|
||||
.rc-visual-row.rc-style-orange td { box-shadow: inset 4px 0 0 #fb923c; }
|
||||
.rc-visual-row.rc-style-yellow td { box-shadow: inset 4px 0 0 #facc15; color: #fef9c3; }
|
||||
.rc-visual-row.rc-style-blue td { box-shadow: inset 4px 0 0 #60a5fa; }
|
||||
.rc-visual-row.rc-style-white td { box-shadow: inset 4px 0 0 #94a3b8; }
|
||||
|
||||
.rc-vdeal { font-weight: 700; }
|
||||
.rc-deal-name { display: block; font-size: 0.95rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rc-brand-tag {
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(148,163,184,0.15);
|
||||
}
|
||||
.rc-brand-tag-fl { color: #fbbf24; background: rgba(251,191,36,0.12); }
|
||||
|
||||
.rc-vnum { text-align: right; }
|
||||
.rc-vamount { display: block; font-variant-numeric: tabular-nums; font-size: 0.92rem; font-weight: 700; color: #4ade80; }
|
||||
|
||||
.rc-mini-bar {
|
||||
height: 5px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 3px;
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rc-mini-bar-fill { height: 100%; border-radius: 3px; min-width: 3px; opacity: 0.95; }
|
||||
.rc-mini-bar-year { background: #38bdf8 !important; }
|
||||
|
||||
.rc-vdonut { text-align: center; }
|
||||
.rc-donut {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.rc-donut-label { font-size: 0.62rem; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
||||
.rc-donut-empty { color: #64748b; font-size: 0.85rem; }
|
||||
|
||||
.rc-vsteps {
|
||||
font-size: 0.84rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rc-expand-hint { color: #38bdf8; font-weight: 700; margin-left: 6px; }
|
||||
|
||||
.rc-expand-row td { border: 1px solid rgba(148,163,184,0.1); padding: 0; background: #0a0e12; }
|
||||
.rc-expand-cell { padding: 0 !important; }
|
||||
.rc-expand-inner {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: rgba(10,14,18,0.98);
|
||||
color: #e2e8f0;
|
||||
border-top: 1px solid rgba(56,189,248,0.15);
|
||||
}
|
||||
.rc-expand-chart { flex: 0 0 140px; }
|
||||
.rc-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 72px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.rc-bar-col { display: flex; flex-direction: column; align-items: center; gap: 2px; height: 100%; justify-content: flex-end; }
|
||||
.rc-bar-col span { font-size: 0.65rem; color: #64748b; }
|
||||
.rc-bar-stack {
|
||||
width: 28px;
|
||||
min-height: 2px;
|
||||
background: var(--rc-green-fill);
|
||||
border-radius: 3px 3px 0 0;
|
||||
transition: height 0.2s;
|
||||
}
|
||||
.rc-bar-year { background: #38bdf8; }
|
||||
.rc-expand-stats { font-size: 0.75rem; }
|
||||
.rc-expand-stats div { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; }
|
||||
.rc-expand-stats span { color: #64748b; }
|
||||
.rc-expand-stats strong { color: #34d399; }
|
||||
.rc-expand-body { flex: 1; min-width: 0; }
|
||||
.rc-expand-body h4 { margin: 0 0 0.35rem; font-size: 0.65rem; text-transform: uppercase; color: #64748b; letter-spacing: 0.04em; }
|
||||
.rc-expand-body p { margin: 0; font-size: 0.9rem; line-height: 1.55; white-space: pre-wrap; color: #e2e8f0; }
|
||||
.rc-expand-task { margin-top: 0.65rem; }
|
||||
.rc-expand-task .rc-inline select,
|
||||
.rc-expand-task .rc-inline input {
|
||||
border: 1px solid rgba(148,163,184,0.25);
|
||||
background: #0f1419;
|
||||
color: #e2e8f0;
|
||||
padding: 0.3rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rc-page .rc-excel-page {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: auto;
|
||||
background: #e8e8e8;
|
||||
color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rc-summary-strip { gap: 0.5rem; }
|
||||
.rc-filter-inline { margin-left: 0; max-width: none; flex: 1 1 100%; }
|
||||
.rc-expand-inner { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Revenue Cockpit · Cucina{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/revenue-cockpit.css?v=6" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="rc-page" :class="{ 'rc-mode-sheet': viewMode === 'sheet' }" x-data="revenueCockpit()" x-init="init()">
|
||||
|
||||
<header class="rc-header">
|
||||
<div>
|
||||
<h1>Revenue Cockpit <span class="live-badge">Excel Live</span></h1>
|
||||
<p class="rc-subtitle" x-show="meta.file_mtime">
|
||||
<span x-text="meta.source_file || 'Succes Sheet .xlsx'"></span>
|
||||
· <span x-text="fmtDate(meta.file_mtime)"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rc-header-actions">
|
||||
<div class="rc-view-toggle">
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'cockpit' }" @click="viewMode = 'cockpit'">Cockpit</button>
|
||||
<button type="button" class="rc-toggle-btn" :class="{ active: viewMode === 'sheet' }" @click="switchToSheet()">Bewerken</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click="loadLive()" :disabled="loading">↻</button>
|
||||
<button type="button" class="btn btn-sm" @click="takeSnapshot()" title="Snapshot">📸</button>
|
||||
<button type="button" class="btn btn-sm btn-pulse-green" @click="importExcel()" title="Sync DB">📥</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Cockpit: Excel-layout met charts -->
|
||||
<div class="rc-cockpit" x-show="viewMode === 'cockpit'" :class="{ 'rc-loading': loading }">
|
||||
|
||||
<div class="rc-summary-strip">
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / mnd</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_month)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Marge / jr</span>
|
||||
<strong x-text="fmtEuro(aggregates.total_margin_year)"></strong>
|
||||
</div>
|
||||
<div class="rc-sum-item">
|
||||
<span class="rc-sum-label">Deals</span>
|
||||
<strong x-text="filteredProjects().length + '/' + projects.length"></strong>
|
||||
</div>
|
||||
<input type="search" class="rc-filter-search rc-filter-inline" placeholder="Zoek deal…" x-model="filterQ" />
|
||||
<div class="rc-style-dots">
|
||||
<button type="button" class="rc-dot-btn" :class="{ active: !filterStyle }" @click="filterStyle = ''" title="Alle">○</button>
|
||||
<template x-for="s in styleFilters" :key="'d-' + s.id">
|
||||
<button type="button" class="rc-dot-btn" :class="['rc-dot-' + s.id, { active: filterStyle === s.id }]"
|
||||
@click="filterStyle = filterStyle === s.id ? '' : s.id" :title="s.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rc-visual-wrap">
|
||||
<table class="rc-visual-table" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-chart-num" />
|
||||
<col class="rc-col-donut" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
|
||||
<!-- Excel rij 1: goals -->
|
||||
<tr class="rc-visual-goals">
|
||||
<td class="rc-vcell" x-text="goals.vision_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.horizon_text || '—'"></td>
|
||||
<td class="rc-vcell" x-text="goals.mid_text || '—'"></td>
|
||||
<td class="rc-vcell rc-vtagline" colspan="2" x-text="goals.tagline || '—'"></td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-visual-head">
|
||||
<td class="rc-vcell">Deal</td>
|
||||
<td class="rc-vcell rc-num">Marge mnd</td>
|
||||
<td class="rc-vcell rc-num">Marge jr</td>
|
||||
<td class="rc-vcell rc-num">Aandeel</td>
|
||||
<td class="rc-vcell">Next steps</td>
|
||||
</tr>
|
||||
|
||||
<template x-for="row in visualRows()" :key="row.source_row + '-' + row.name">
|
||||
<tr class="rc-visual-row"
|
||||
:class="['rc-style-' + (row.row_style || 'white'), 'rc-brand-' + (row.brand || 'none'), { 'rc-row-open': expandedKey === projectKey(row) }]"
|
||||
@click="toggleExpand(row)">
|
||||
|
||||
<td class="rc-vcell rc-vdeal">
|
||||
<span class="rc-deal-name" x-text="row.name"></span>
|
||||
<span class="rc-brand-tag" x-show="row.logoCell === 'cucina'">Cucina</span>
|
||||
<span class="rc-brand-tag rc-brand-tag-fl" x-show="row.logoCell === 'foodlinkk'">Foodlinkk</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_month != null ? fmtEuroCompact(row.margin_month) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_month">
|
||||
<div class="rc-mini-bar-fill" :style="'width:' + marginBarPct(row, 'margin_month') + '%;background:' + styleColor(row.row_style)"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vnum">
|
||||
<span class="rc-vamount" x-text="row.margin_year != null ? fmtEuroCompact(row.margin_year) : '—'"></span>
|
||||
<div class="rc-mini-bar" x-show="row.margin_year">
|
||||
<div class="rc-mini-bar-fill rc-mini-bar-year" :style="'width:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vdonut">
|
||||
<div class="rc-donut" x-show="row.margin_month" :style="donutStyle(row)">
|
||||
<span class="rc-donut-label" x-text="sharePct(row) + '%'"></span>
|
||||
</div>
|
||||
<span class="rc-donut-empty" x-show="!row.margin_month">—</span>
|
||||
</td>
|
||||
|
||||
<td class="rc-vcell rc-vsteps">
|
||||
<span x-text="truncate(row.next_steps, 72) || '—'"></span>
|
||||
<span class="rc-expand-hint" x-show="row.next_steps && row.next_steps.length > 72">↵</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr class="rc-expand-row" x-show="expandedKey === projectKey(row)" x-cloak>
|
||||
<td class="rc-expand-cell" colspan="5">
|
||||
<div class="rc-expand-inner">
|
||||
<div class="rc-expand-chart">
|
||||
<div class="rc-bar-chart">
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack" :style="'height:' + marginBarPct(row, 'margin_month') + '%'"></div>
|
||||
<span>Mnd</span>
|
||||
</div>
|
||||
<div class="rc-bar-col">
|
||||
<div class="rc-bar-stack rc-bar-year" :style="'height:' + marginBarPct(row, 'margin_year') + '%'"></div>
|
||||
<span>Jr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-stats">
|
||||
<div><span>Marge mnd</span><strong x-text="fmtEuro(row.margin_month)"></strong></div>
|
||||
<div><span>Marge jr</span><strong x-text="fmtEuro(row.margin_year)"></strong></div>
|
||||
<div><span>Portfolio</span><strong x-text="sharePct(row) + '%'"></strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-expand-body">
|
||||
<h4>Next steps</h4>
|
||||
<p x-text="row.next_steps || 'Geen next steps in Excel'"></p>
|
||||
<div class="rc-expand-task" x-show="row.db_id">
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak…" @click.stop />
|
||||
<button type="button" class="btn btn-sm btn-pulse" @click.stop="assignTask()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerken: originele Excel -->
|
||||
<div class="rc-excel-page" x-show="viewMode === 'sheet'" x-cloak>
|
||||
<div class="rc-toolbar">
|
||||
<span class="rc-toolbar-title">Bewerken · DB</span>
|
||||
<div class="rc-toolbar-actions">
|
||||
<span class="rc-save-hint" x-show="saving" x-cloak>Opslaan…</span>
|
||||
<span class="rc-save-hint rc-saved" x-show="savedAt && !saving" x-cloak x-text="'Opgeslagen ' + savedAt"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rc-sheet-wrap" :class="{ 'rc-loading': loading }">
|
||||
<table class="rc-sheet" cellspacing="0" cellpadding="0">
|
||||
<colgroup>
|
||||
<col class="rc-col-logo" />
|
||||
<col class="rc-col-deal" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-num" />
|
||||
<col class="rc-col-steps" />
|
||||
</colgroup>
|
||||
<tr class="rc-row rc-row-goals">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.vision_text || ''"
|
||||
@blur="saveGoalsField('vision_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.horizon_text || ''"
|
||||
@blur="saveGoalsField('horizon_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = goals.mid_text || ''"
|
||||
@blur="saveGoalsField('mid_text', $event.target)" @focus="editStart($event)"></td>
|
||||
<td class="rc-cell rc-cell-edit rc-cell-tagline" contenteditable="true"
|
||||
x-init="$el.textContent = goals.tagline || ''"
|
||||
@blur="saveGoalsField('tagline', $event.target)" @focus="editStart($event)"></td>
|
||||
</tr>
|
||||
<tr class="rc-row rc-row-head">
|
||||
<td class="rc-logo-cell"></td>
|
||||
<td class="rc-cell rc-th">Deal</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Month</td>
|
||||
<td class="rc-cell rc-th rc-num">Marge Year</td>
|
||||
<td class="rc-cell rc-th">Next steps</td>
|
||||
</tr>
|
||||
<template x-for="row in sheetRows" :key="row.id || row.source_row">
|
||||
<tr class="rc-row rc-data-row"
|
||||
:class="['rc-style-' + row.row_style, { 'rc-selected': selectedId === row.id }]"
|
||||
@click="selectRow(row)">
|
||||
<template x-if="row.logoCell === 'cucina'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-cucina"><span class="rc-logo-ring">CUCINA<br>HALAL<br>FOOD</span></div>
|
||||
</td>
|
||||
</template>
|
||||
<template x-if="row.logoCell === 'foodlinkk'">
|
||||
<td class="rc-logo-cell" :rowspan="row.logoSpan">
|
||||
<div class="rc-logo rc-logo-foodlinkk">
|
||||
<span class="rc-fl-brand">Foodlinkk</span>
|
||||
<span class="rc-fl-sub">FOOD MARKETING AGENCY</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
<td class="rc-cell rc-cell-deal rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.name || ''"
|
||||
@blur="saveProjectField(row, 'name', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_month)"
|
||||
@blur="saveProjectField(row, 'margin_month', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-num rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = fmtNum(row.margin_year)"
|
||||
@blur="saveProjectField(row, 'margin_year', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
<td class="rc-cell rc-cell-steps rc-cell-edit" contenteditable="true"
|
||||
x-init="$el.textContent = row.next_steps || ''"
|
||||
@blur="saveProjectField(row, 'next_steps', $event.target)" @focus="editStart($event)" @click.stop></td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
<div class="rc-row-panel" x-show="selectedRow" x-cloak @click.outside="selectedId = null">
|
||||
<div class="rc-row-panel-head">
|
||||
<strong x-text="selectedRow?.name"></strong>
|
||||
<select class="rc-style-select" :value="selectedRow?.row_style" @change="setRowStyle($event.target.value)">
|
||||
<option value="green">Groen (live)</option>
|
||||
<option value="red">Rood (inactief)</option>
|
||||
<option value="orange">Oranje</option>
|
||||
<option value="yellow">Geel (sectie)</option>
|
||||
<option value="blue">Blauw</option>
|
||||
<option value="white">Wit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="rc-row-panel-body">
|
||||
<label>Agent taak
|
||||
<div class="rc-inline">
|
||||
<select x-model="taskForm.agent_name">
|
||||
<template x-for="a in agents" :key="a"><option :value="a" x-text="a"></option></template>
|
||||
</select>
|
||||
<input type="text" x-model="taskForm.title" placeholder="Taak titel…" />
|
||||
<button type="button" class="rc-btn rc-btn-primary" @click="assignTask()">→</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rc-footnote">Klik in een cel om te bewerken · bron: Excel op NAS</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="/static/js/revenue-cockpit.js?v=5"></script>
|
||||
{% endblock %}
|
||||
@@ -194,3 +194,16 @@
|
||||
.nav-pill.active { animation: none; }
|
||||
.nav-pill:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* Sidebar: app-sidebar-neo.css only — no topnav glow/pulse */
|
||||
.app-sidebar .nav-pill,
|
||||
.app-sidebar .nav-pill:hover,
|
||||
.app-sidebar .nav-pill.active {
|
||||
animation: none !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
.app-sidebar .nav-pill::before,
|
||||
.app-sidebar .nav-pill::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -1,117 +1,278 @@
|
||||
/* Vertical tab navigation with animated active indicator */
|
||||
/* Vertical tabs — clean module nav + page tabs */
|
||||
|
||||
.vtabs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
min-height: 60vh;
|
||||
width: 100%;
|
||||
min-height: 40vh;
|
||||
--vt-color: #0ea5e9;
|
||||
--vt-panel-bg: rgba(10, 14, 20, 0.85);
|
||||
}
|
||||
|
||||
.vtabs-layout > .vtabs-nav {
|
||||
flex: 0 0 220px;
|
||||
width: 220px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.vtabs-layout > .vtabs-content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.vtabs-layout { flex-direction: column !important; }
|
||||
.vtabs-layout > .vtabs-nav { flex: 1 1 auto; width: 100%; max-width: none; }
|
||||
}
|
||||
|
||||
.vtabs-nav {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 14, 20, 0.85);
|
||||
gap: 0.1rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtabs-nav::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: var(--vt-indicator-top, 0);
|
||||
width: 3px;
|
||||
height: var(--vt-indicator-h, 40px);
|
||||
background: linear-gradient(180deg, var(--vt-color, #00e5ff), transparent);
|
||||
border-radius: 0 4px 4px 0;
|
||||
transition: top 0.35s cubic-bezier(0.34, 1.4, 0.64, 1), height 0.25s ease;
|
||||
box-shadow: 0 0 12px var(--vt-color, #00e5ff);
|
||||
height: var(--vt-indicator-h, 36px);
|
||||
background: var(--vt-color);
|
||||
border-radius: 0 3px 3px 0;
|
||||
transition: top 0.25s ease, height 0.2s ease;
|
||||
}
|
||||
|
||||
.vtabs-nav-header {
|
||||
padding: 0 0.35rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.vtabs-nav-header strong {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.vtab-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.25s, transform 0.2s;
|
||||
text-decoration: none;
|
||||
--tab-accent: var(--vt-color, #0ea5e9);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.vtab-btn:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.vtab-btn.active {
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--vt-color, #00e5ff) 12%, transparent);
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--vt-color, #00e5ff) 50%, transparent);
|
||||
}
|
||||
.vtab-icon { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.vtabs-content { min-width: 0; animation: vtabFadeIn 0.35s ease; }
|
||||
@keyframes vtabFadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
.vtab-btn::before { display: none; }
|
||||
|
||||
.vtab-icon,
|
||||
.ei-vtab-icon-wrap {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
/* App sidebar layout */
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
min-height: 100vh;
|
||||
.vtab-btn:hover {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
color: #e2e8f0;
|
||||
transform: none;
|
||||
}
|
||||
.app-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 0.65rem;
|
||||
background: linear-gradient(180deg, rgba(8, 12, 18, 0.98), rgba(12, 18, 28, 0.95));
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.vtab-btn.active {
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
border-color: rgba(14, 165, 233, 0.2);
|
||||
color: #f1f5f9;
|
||||
text-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.app-sidebar-brand {
|
||||
padding: 0.5rem 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
margin-bottom: 0.5rem;
|
||||
.vtab-btn.active .vtab-icon,
|
||||
.vtab-btn.active .ei-vtab-icon-wrap {
|
||||
border-color: var(--tab-accent);
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.app-sidebar-brand .brand-title { font-size: 0.65rem; letter-spacing: 0.12em; color: #64748b; margin: 0; }
|
||||
.app-sidebar-brand .brand-name { font-size: 1rem; font-weight: 700; color: #e2e8f0; margin: 0.15rem 0 0; }
|
||||
.app-sidebar-group { margin-bottom: 0.75rem; }
|
||||
.app-sidebar-label {
|
||||
font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
color: #64748b; padding: 0.25rem 0.75rem; margin-bottom: 0.25rem;
|
||||
|
||||
.vtab-badge,
|
||||
.ei-vtab-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: #cbd5e1;
|
||||
border: none;
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.app-sidebar .nav-pill {
|
||||
display: flex; width: 100%; justify-content: flex-start;
|
||||
margin-bottom: 0.2rem; border-radius: 10px;
|
||||
.vtab-btn.active .ei-vtab-badge,
|
||||
.vtab-btn.active .vtab-badge {
|
||||
background: rgba(14, 165, 233, 0.2);
|
||||
color: #7dd3fc;
|
||||
animation: none;
|
||||
}
|
||||
.app-sidebar .nav-pill.active { animation: nav-pill-pulse 2.5s ease-in-out infinite; }
|
||||
|
||||
/* Rich tabs (Wereldexport) */
|
||||
.vtab-btn.vtab-btn-rich {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
}
|
||||
.ei-vtab-text { min-width: 0; }
|
||||
.ei-vtab-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.ei-vtab-sub {
|
||||
display: block;
|
||||
font-size: 0.62rem;
|
||||
color: #64748b;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.ei-vtab-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.ei-vtab-key {
|
||||
font-size: 0.55rem;
|
||||
color: #475569;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.vtabs-content { min-width: 0; }
|
||||
|
||||
.vt-tab-panel,
|
||||
.ei-tab-panel {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: var(--vt-panel-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.vt-tab-panel-head,
|
||||
.ei-tab-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
.vt-tab-panel-head h2,
|
||||
.ei-tab-panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.vt-tab-panel-head p,
|
||||
.ei-tab-panel-head p {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
.vt-tab-panel-body,
|
||||
.ei-tab-panel-body { padding: 1rem; }
|
||||
|
||||
/* Retail side-nav */
|
||||
.side-nav {
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
.side-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.side-nav-btn:hover { background: rgba(30, 41, 59, 0.5); color: #e2e8f0; }
|
||||
.side-nav-btn.active {
|
||||
background: rgba(0, 102, 204, 0.1);
|
||||
border-color: rgba(0, 102, 204, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-main { min-width: 0; }
|
||||
.app-topbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0.65rem 1.25rem; border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.65rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(8, 12, 18, 0.6);
|
||||
}
|
||||
.app-clock {
|
||||
font-family: ui-monospace, monospace; font-size: 0.85rem; color: #94a3b8;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.app-clock strong { color: #e2e8f0; }
|
||||
.main-topnav { padding: 1rem 1.25rem; }
|
||||
|
||||
/* Mobile layout → see mobile.css */
|
||||
@media (max-width: 639px) {
|
||||
.vtabs-nav {
|
||||
position: relative;
|
||||
top: 0;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
.vtabs-nav::before { display: none; }
|
||||
.vtabs-nav-header { display: none; }
|
||||
.vtab-btn { flex: 0 0 auto; min-width: 64px; }
|
||||
.vtab-btn.vtab-btn-rich .ei-vtab-text,
|
||||
.vtab-btn.vtab-btn-rich .ei-vtab-key { display: none; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
.voice-live-page { --vl-accent: #a78bfa; max-width: 1400px; }
|
||||
|
||||
.voice-live-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr 320px;
|
||||
gap: 1rem;
|
||||
min-height: 520px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.voice-live-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.voice-panel {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(10, 14, 20, 0.9);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.voice-mic-zone { align-items: center; text-align: center; }
|
||||
.voice-mic-btn {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(167, 139, 250, 0.45);
|
||||
background: radial-gradient(circle at 30% 30%, rgba(167, 139, 250, 0.25), rgba(15, 20, 25, 0.95));
|
||||
color: #f8fafc;
|
||||
font-size: 2.5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
margin: 0.5rem auto 1rem;
|
||||
}
|
||||
.voice-mic-btn:hover { border-color: rgba(167, 139, 250, 0.8); transform: scale(1.03); }
|
||||
.voice-mic-btn.is-listening {
|
||||
border-color: #f87171;
|
||||
box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.2);
|
||||
animation: vl-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.voice-mic-btn.is-busy {
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
.voice-mic-btn.is-handsfree {
|
||||
border-color: #34d399;
|
||||
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
|
||||
animation: vl-pulse-hf 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes vl-pulse-hf {
|
||||
0%, 100% { box-shadow: 0 0 0 6px rgba(52, 211, 153, 0.12); }
|
||||
50% { box-shadow: 0 0 0 12px rgba(52, 211, 153, 0.22); }
|
||||
}
|
||||
@keyframes vl-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0.15); }
|
||||
50% { box-shadow: 0 0 0 14px rgba(248, 113, 113, 0.28); }
|
||||
}
|
||||
|
||||
.voice-status {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.voice-status-sub { font-size: 0.72rem; color: #64748b; margin-bottom: 0.75rem; }
|
||||
|
||||
.voice-live-preview {
|
||||
width: 100%;
|
||||
min-height: 3.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 20, 25, 0.85);
|
||||
border: 1px dashed rgba(167, 139, 250, 0.35);
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.voice-live-preview.is-interim { color: #94a3b8; font-style: italic; }
|
||||
.voice-live-preview strong { color: #a78bfa; font-size: 0.68rem; text-transform: uppercase; display: block; margin-bottom: 0.25rem; }
|
||||
|
||||
.voice-toggles { display: flex; flex-direction: column; gap: 0.45rem; margin-top: 1rem; text-align: left; }
|
||||
.voice-toggles label { font-size: 0.78rem; color: #94a3b8; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
|
||||
|
||||
.voice-chat-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.voice-chat-head h2 { margin: 0; font-size: 0.95rem; color: #f8fafc; }
|
||||
.voice-chat-log {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.voice-bubble {
|
||||
max-width: 92%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.voice-bubble-user {
|
||||
align-self: flex-end;
|
||||
background: rgba(14, 165, 233, 0.15);
|
||||
border: 1px solid rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
.voice-bubble-agent {
|
||||
align-self: flex-start;
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
border: 1px solid rgba(167, 139, 250, 0.28);
|
||||
}
|
||||
.voice-bubble-meta { font-size: 0.68rem; color: #64748b; margin-bottom: 0.25rem; }
|
||||
.voice-bubble-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(167, 139, 250, 0.2);
|
||||
color: #c4b5fd;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.voice-bubble p { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.voice-side-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; margin: 0 0 0.5rem; }
|
||||
.voice-pipeline { list-style: none; margin: 0 0 1rem; padding: 0; max-height: 220px; overflow-y: auto; }
|
||||
.voice-pipeline li {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
.voice-pipeline .vl-phase {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.voice-pipeline .vl-label { color: #e2e8f0; font-weight: 600; }
|
||||
.voice-pipeline .vl-detail { grid-column: 2; color: #94a3b8; font-size: 0.72rem; }
|
||||
.voice-pipeline li.is-running .vl-label { color: #38bdf8; }
|
||||
.voice-pipeline li.is-error .vl-label { color: #f87171; }
|
||||
|
||||
.voice-feed { max-height: 200px; overflow-y: auto; font-size: 0.72rem; }
|
||||
.voice-feed-item { padding: 0.35rem 0; border-bottom: 1px solid rgba(148, 163, 184, 0.08); color: #94a3b8; }
|
||||
.voice-feed-item strong { color: #c4b5fd; }
|
||||
.voice-feed-item .vl-ts { color: #64748b; margin-right: 0.35rem; }
|
||||
|
||||
.voice-manual-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.voice-manual-row .form-input { flex: 1; font-size: 0.82rem; }
|
||||
|
||||
.voice-confirm-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
.voice-confirm-text { font-size: 0.82rem; color: #fde68a; }
|
||||
.voice-confirm-text strong { display: block; color: #fbbf24; font-size: 0.72rem; text-transform: uppercase; margin-bottom: 0.2rem; }
|
||||
.voice-confirm-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.voice-results-modal { z-index: 1400; }
|
||||
.voice-results-card { width: min(720px, 96vw); max-height: 85vh; display: flex; flex-direction: column; }
|
||||
.voice-results-table-wrap { overflow: auto; max-height: 50vh; margin: 0.5rem 0 1rem; border: 1px solid rgba(148,163,184,0.12); border-radius: 8px; }
|
||||
.voice-results-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||
.voice-results-table th { text-align: left; padding: 0.5rem 0.65rem; background: rgba(15,20,25,0.9); color: #64748b; font-size: 0.65rem; text-transform: uppercase; position: sticky; top: 0; }
|
||||
.voice-results-table td { padding: 0.45rem 0.65rem; border-top: 1px solid rgba(148,163,184,0.08); color: #cbd5e1; }
|
||||
.voice-results-table a { color: #38bdf8; text-decoration: none; }
|
||||
.voice-results-table a:hover { text-decoration: underline; }
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overview",
|
||||
"revenue_cockpit": "Revenue Cockpit",
|
||||
"cto_home": "CTO Overview",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Clients",
|
||||
"deals": "Deals",
|
||||
@@ -55,7 +65,8 @@
|
||||
"new": "New client",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "List",
|
||||
"tab_stores": "Supermarkets"
|
||||
"tab_stores": "Supermarkets",
|
||||
"tab_360": "360° Client"
|
||||
},
|
||||
"products": {
|
||||
"title": "Product catalog",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cucina · Foodlinkk Command Center",
|
||||
"short_name": "Foodlinkk",
|
||||
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0e14",
|
||||
"theme_color": "#0a0e14",
|
||||
"lang": "nl",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/app-icon.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
{
|
||||
"nav": {
|
||||
"herman": "Herman",
|
||||
"persona_ceo": "CEO · Business",
|
||||
"persona_cto": "CTO · Tech",
|
||||
"ceo_home": "CEO Overzicht",
|
||||
"revenue_cockpit": "Revenue Cockpit",
|
||||
"cto_home": "CTO Overzicht",
|
||||
"business": "Business",
|
||||
"platform": "Platform",
|
||||
"devops": "DevOps",
|
||||
"tools_api": "Tools API",
|
||||
"grafana": "Grafana",
|
||||
"crm": "CRM",
|
||||
"clients": "Klanten",
|
||||
"deals": "Deals",
|
||||
@@ -55,7 +65,8 @@
|
||||
"new": "Nieuwe klant",
|
||||
"tab_kanban": "Kanban",
|
||||
"tab_list": "Lijst",
|
||||
"tab_stores": "Supermarkten"
|
||||
"tab_stores": "Supermarkten",
|
||||
"tab_360": "360° Klant"
|
||||
},
|
||||
"products": {
|
||||
"title": "Productcatalogus",
|
||||
|
||||
@@ -17,6 +17,7 @@ window.AgentAvatars = (function () {
|
||||
research: { hoodie: '#115e59', hair: '#1a1a1a', skin: '#f5c9a8', style: 'glasses', accent: '#14b8a6' },
|
||||
sysops: { hoodie: '#1e3a5f', hair: '#4a3728', skin: '#e8b896', style: 'short', accent: '#38bdf8', hat: 'cap' },
|
||||
packaging: { hoodie: '#7c2d12', hair: '#2d1810', skin: '#c9a07a', style: 'short', accent: '#fb923c' },
|
||||
webbuilder: { hoodie: '#0c4a6e', hair: '#1f2937', skin: '#ddb896', style: 'glasses', accent: '#0ea5e9', hat: 'cap' },
|
||||
};
|
||||
|
||||
function cfg(key, fallbackColor) {
|
||||
|
||||
@@ -1,9 +1,149 @@
|
||||
(function () {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const TIER_ROWS = [4, 5, 7];
|
||||
const MIN_NODE_GAP = 148;
|
||||
const VIEW_W = 1200;
|
||||
const VIEW_H = 860;
|
||||
|
||||
function make(tag, attrs = {}) {
|
||||
/** Korte rol-context per agent (NL) */
|
||||
const AGENT_HINTS = {
|
||||
marketing: { short: 'Campagnes', domain: 'Groei' },
|
||||
retail: { short: 'Supermarkten', domain: 'Groei' },
|
||||
bizdev: { short: 'Deals', domain: 'Groei' },
|
||||
research: { short: 'Marktintel', domain: 'Groei' },
|
||||
browser: { short: 'Web research', domain: 'Groei' },
|
||||
webbuilder: { short: 'Websites', domain: 'Product' },
|
||||
product: { short: 'Assortiment', domain: 'Product' },
|
||||
packaging: { short: 'Verpakking', domain: 'Product' },
|
||||
design: { short: 'Visuals', domain: 'Product' },
|
||||
halal: { short: 'Compliance', domain: 'Product' },
|
||||
sourcing: { short: 'Inkoop', domain: 'Product' },
|
||||
finance: { short: 'Cijfers', domain: 'Operatie' },
|
||||
hr: { short: 'Team', domain: 'Operatie' },
|
||||
knowledge: { short: 'Kennisbank', domain: 'Operatie' },
|
||||
email: { short: 'Inbox', domain: 'Operatie' },
|
||||
sysops: { short: 'Infra', domain: 'Operatie' },
|
||||
};
|
||||
|
||||
const CLUSTERS = {
|
||||
growth: {
|
||||
label: 'Groei',
|
||||
icon: '📈',
|
||||
color: 'rgba(0, 229, 255, 0.14)',
|
||||
stroke: 'rgba(0, 229, 255, 0.35)',
|
||||
agents: ['marketing', 'retail', 'bizdev', 'research', 'browser'],
|
||||
},
|
||||
product: {
|
||||
label: 'Product',
|
||||
icon: '📦',
|
||||
color: 'rgba(168, 85, 247, 0.12)',
|
||||
stroke: 'rgba(168, 85, 247, 0.35)',
|
||||
agents: ['product', 'packaging', 'design', 'halal', 'sourcing', 'webbuilder'],
|
||||
},
|
||||
ops: {
|
||||
label: 'Operatie',
|
||||
icon: '⚙️',
|
||||
color: 'rgba(34, 197, 94, 0.1)',
|
||||
stroke: 'rgba(34, 197, 94, 0.32)',
|
||||
agents: ['finance', 'hr', 'knowledge', 'email', 'sysops'],
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {Record<string, object>} */
|
||||
const COMM_MODES = {
|
||||
pyramid: {
|
||||
id: 'pyramid',
|
||||
label: 'Piramide',
|
||||
subtitle: 'Herman als hub',
|
||||
icon: '◆',
|
||||
desc: 'Rapport ↑ en delegatie ↓. Alleen live handoffs tussen agents.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: false,
|
||||
activeOnly: false,
|
||||
edgeBend: 0.42,
|
||||
},
|
||||
mesh: {
|
||||
id: 'mesh',
|
||||
label: 'Vol netwerk',
|
||||
subtitle: 'Alle partners',
|
||||
icon: '⬡',
|
||||
desc: 'Volledige samenwerkingsmatrix — wie met wie kan werken.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: true,
|
||||
activeOnly: false,
|
||||
staticPeerOpacity: 0.35,
|
||||
edgeBend: 0.38,
|
||||
},
|
||||
org: {
|
||||
id: 'org',
|
||||
label: 'Organigram',
|
||||
subtitle: 'Pure hiërarchie',
|
||||
icon: '▤',
|
||||
desc: 'CEO → Herman → agents. Geen peer-lijnen, overzichtelijk.',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: false,
|
||||
showStaticPeers: false,
|
||||
activeOnly: false,
|
||||
edgeBend: 0.12,
|
||||
straightExec: true,
|
||||
},
|
||||
pulse: {
|
||||
id: 'pulse',
|
||||
label: 'Live stream',
|
||||
subtitle: 'Alleen actief',
|
||||
icon: '◎',
|
||||
desc: 'Geen stille lijnen — alleen wat nu beweegt (pulse).',
|
||||
layout: 'pyramid',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: false,
|
||||
activeOnly: true,
|
||||
edgeBend: 0.45,
|
||||
},
|
||||
clusters: {
|
||||
id: 'clusters',
|
||||
label: 'Domeinen',
|
||||
subtitle: 'Per afdeling',
|
||||
icon: '◉',
|
||||
desc: 'Groei · Product · Operatie — agents gegroepeerd rond Herman.',
|
||||
layout: 'clusters',
|
||||
showExecutive: true,
|
||||
showReport: true,
|
||||
showDelegate: true,
|
||||
showLivePeers: true,
|
||||
showStaticPeers: true,
|
||||
activeOnly: false,
|
||||
staticPeerOpacity: 0.2,
|
||||
edgeBend: 0.5,
|
||||
intraClusterOnly: false,
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'foodlinkk_mesh_comm_mode';
|
||||
let cachedData = null;
|
||||
let pickerBound = false;
|
||||
let onModeChangeCb = null;
|
||||
let lastMountOpts = {};
|
||||
|
||||
function make(tag, attrs) {
|
||||
attrs = attrs || {};
|
||||
const el = document.createElementNS(NS, tag);
|
||||
Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, String(v)));
|
||||
Object.entries(attrs).forEach(function (pair) {
|
||||
el.setAttribute(pair[0], String(pair[1]));
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -11,9 +151,234 @@
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function linePath(x1, y1, x2, y2) {
|
||||
const midY = y1 + (y2 - y1) * 0.45;
|
||||
return `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
|
||||
function curvePath(x1, y1, x2, y2, bend) {
|
||||
if (bend != null && bend < 0.18) {
|
||||
return 'M ' + x1 + ' ' + y1 + ' L ' + x2 + ' ' + y2;
|
||||
}
|
||||
const midY = y1 + (y2 - y1) * (bend != null ? bend : 0.42);
|
||||
return 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + midY + ', ' + x2 + ' ' + midY + ', ' + x2 + ' ' + y2;
|
||||
}
|
||||
|
||||
function clusterOf(key) {
|
||||
const k = (key || '').toLowerCase();
|
||||
return (
|
||||
Object.keys(CLUSTERS).find(function (cid) {
|
||||
return CLUSTERS[cid].agents.indexOf(k) >= 0;
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
function spreadTier(nodes, width, minGap) {
|
||||
if (nodes.length <= 1) return;
|
||||
nodes.sort(function (a, b) {
|
||||
return a._x - b._x;
|
||||
});
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
const gap = nodes[i]._x - nodes[i - 1]._x;
|
||||
if (gap < minGap) {
|
||||
const shift = minGap - gap;
|
||||
for (let j = i; j < nodes.length; j++) nodes[j]._x += shift;
|
||||
}
|
||||
}
|
||||
const tierCx = (nodes[0]._x + nodes[nodes.length - 1]._x) / 2;
|
||||
const offset = width / 2 - tierCx;
|
||||
nodes.forEach(function (n) {
|
||||
n._x += offset;
|
||||
});
|
||||
}
|
||||
|
||||
function deoverlapLayout(souls, width) {
|
||||
const tiers = {};
|
||||
souls.forEach(function (n) {
|
||||
const y = n._y || 0;
|
||||
if (!tiers[y]) tiers[y] = [];
|
||||
tiers[y].push(n);
|
||||
});
|
||||
Object.keys(tiers).forEach(function (y) {
|
||||
spreadTier(tiers[y], width, MIN_NODE_GAP);
|
||||
});
|
||||
}
|
||||
|
||||
function agentSubtitle(node) {
|
||||
const hint = AGENT_HINTS[(node.agent_key || '').toLowerCase()];
|
||||
const task = (node.current_task || node.last_event_title || '').trim();
|
||||
if (task) return task.length > 32 ? task.slice(0, 30) + '…' : task;
|
||||
if (hint) return hint.short;
|
||||
return (node.role_title || '').split('·')[0].trim().slice(0, 24);
|
||||
}
|
||||
|
||||
function agentStatus(node) {
|
||||
if (node.is_active) return 'live';
|
||||
const h = node.health || 'idle';
|
||||
if (h === 'warn') return 'warn';
|
||||
if (h === 'healthy') return 'ready';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
function addRotateAnim(group, dur) {
|
||||
const anim = document.createElementNS(NS, 'animateTransform');
|
||||
anim.setAttribute('attributeName', 'transform');
|
||||
anim.setAttribute('type', 'rotate');
|
||||
anim.setAttribute('from', '0 0 0');
|
||||
anim.setAttribute('to', '360 0 0');
|
||||
anim.setAttribute('dur', dur || '12s');
|
||||
anim.setAttribute('repeatCount', 'indefinite');
|
||||
group.appendChild(anim);
|
||||
}
|
||||
|
||||
function layoutPyramid(souls, width, positions) {
|
||||
const cx = width / 2;
|
||||
const hermanY = positions.hermanY;
|
||||
const execY = positions.execY;
|
||||
const tierYs = positions.tierYs;
|
||||
const minGap = MIN_NODE_GAP;
|
||||
let idx = 0;
|
||||
TIER_ROWS.forEach(function (count, tierIdx) {
|
||||
const y = tierYs[tierIdx] || 580;
|
||||
const slice = souls.slice(idx, idx + count);
|
||||
idx += count;
|
||||
const n = slice.length;
|
||||
if (!n) return;
|
||||
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
|
||||
const startX = cx - span / 2;
|
||||
slice.forEach(function (node, i) {
|
||||
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
|
||||
node._y = y;
|
||||
node._tier = tierIdx;
|
||||
node._cluster = clusterOf(node.agent_key);
|
||||
});
|
||||
});
|
||||
while (idx < souls.length) {
|
||||
const y = 660;
|
||||
const rest = souls.slice(idx);
|
||||
const n = rest.length;
|
||||
const span = Math.max(minGap * Math.max(n - 1, 1), minGap);
|
||||
const startX = cx - span / 2;
|
||||
rest.forEach(function (node, i) {
|
||||
node._x = n === 1 ? cx : startX + (span * i) / Math.max(1, n - 1);
|
||||
node._y = y;
|
||||
node._cluster = clusterOf(node.agent_key);
|
||||
});
|
||||
idx = souls.length;
|
||||
}
|
||||
deoverlapLayout(souls, width);
|
||||
return { cx: cx, hermanY: hermanY, execY: execY };
|
||||
}
|
||||
|
||||
function layoutClusters(souls, width, height) {
|
||||
const cx = width / 2;
|
||||
const hermanY = height * 0.42;
|
||||
const execY = 58;
|
||||
const clusterCenters = {
|
||||
growth: { x: cx - 280, y: height * 0.72, r: 118 },
|
||||
product: { x: cx, y: height * 0.78, r: 118 },
|
||||
ops: { x: cx + 280, y: height * 0.72, r: 118 },
|
||||
};
|
||||
|
||||
souls.forEach(function (node) {
|
||||
const key = (node.agent_key || '').toLowerCase();
|
||||
const cid = clusterOf(key) || 'product';
|
||||
const center = clusterCenters[cid];
|
||||
const members = CLUSTERS[cid].agents;
|
||||
const idx = members.indexOf(key);
|
||||
const n = members.length;
|
||||
const angleStart = -Math.PI * 0.72;
|
||||
const angleEnd = -Math.PI * 0.28;
|
||||
const angle =
|
||||
n <= 1 ? (angleStart + angleEnd) / 2 : angleStart + ((angleEnd - angleStart) * idx) / (n - 1);
|
||||
const dist = center.r * 0.72;
|
||||
node._x = center.x + Math.cos(angle) * dist;
|
||||
node._y = center.y + Math.sin(angle) * dist * 0.55;
|
||||
node._cluster = cid;
|
||||
node._clusterCx = center.x;
|
||||
node._clusterCy = center.y;
|
||||
});
|
||||
|
||||
return { cx: cx, hermanY: hermanY, execY: execY, clusterCenters: clusterCenters };
|
||||
}
|
||||
|
||||
function isSvgVisible(svg) {
|
||||
if (!svg) return false;
|
||||
const r = svg.getBoundingClientRect();
|
||||
return r.width > 8 && r.height > 8;
|
||||
}
|
||||
|
||||
function showMeshLoading(svg, text) {
|
||||
clear(svg);
|
||||
const g = make('g', { class: 'mesh-loading' });
|
||||
const t = make('text', {
|
||||
x: 600,
|
||||
y: 360,
|
||||
'text-anchor': 'middle',
|
||||
fill: '#94a3b8',
|
||||
'font-size': 14,
|
||||
});
|
||||
t.textContent = text || 'Netwerk laden…';
|
||||
g.appendChild(t);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
function showMeshError(svg, text) {
|
||||
clear(svg);
|
||||
const t = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
|
||||
t.textContent = text || 'Mesh kon niet geladen worden';
|
||||
svg.appendChild(t);
|
||||
}
|
||||
|
||||
function whenSvgVisible(svg, fn) {
|
||||
if (!svg) return;
|
||||
if (isSvgVisible(svg)) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
let tries = 0;
|
||||
const tick = function () {
|
||||
tries += 1;
|
||||
if (isSvgVisible(svg)) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
if (tries < 60) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function watchSvgResize(svg, renderFn) {
|
||||
if (!svg || svg._meshResizeObs || typeof ResizeObserver === 'undefined') return;
|
||||
const wrap = svg.closest('.mesh-wrap') || svg.parentElement;
|
||||
if (!wrap) return;
|
||||
let hadSize = isSvgVisible(svg);
|
||||
svg._meshResizeObs = new ResizeObserver(function () {
|
||||
const vis = isSvgVisible(svg);
|
||||
if (vis && !hadSize) {
|
||||
hadSize = true;
|
||||
renderFn();
|
||||
} else if (vis) {
|
||||
hadSize = true;
|
||||
}
|
||||
});
|
||||
svg._meshResizeObs.observe(wrap);
|
||||
}
|
||||
|
||||
function renderMesh(svg, opts) {
|
||||
if (!svg || !cachedData) return;
|
||||
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, {
|
||||
commMode: (opts && opts.commMode) || getSavedMode(),
|
||||
});
|
||||
const doDraw = function () {
|
||||
try {
|
||||
drawMesh(svg, cachedData, drawOpts);
|
||||
} catch (e) {
|
||||
console.error('drawMesh failed', e);
|
||||
showMeshError(svg, 'Diagram fout: ' + (e.message || 'onbekend'));
|
||||
}
|
||||
};
|
||||
doDraw();
|
||||
watchSvgResize(svg, doDraw);
|
||||
}
|
||||
|
||||
async function loadMeshData() {
|
||||
@@ -22,67 +387,209 @@
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function drawMesh(svg, data) {
|
||||
async function ensureMeshData() {
|
||||
if (cachedData) return cachedData;
|
||||
cachedData = await loadMeshData();
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
let pendingRender = false;
|
||||
|
||||
function scheduleRender(opts) {
|
||||
const svg = document.getElementById('agents-mesh-canvas');
|
||||
if (!svg) return;
|
||||
if (opts) lastMountOpts = Object.assign({}, lastMountOpts, opts);
|
||||
if (pendingRender) return;
|
||||
pendingRender = true;
|
||||
whenSvgVisible(svg, function () {
|
||||
pendingRender = false;
|
||||
renderMesh(svg, opts);
|
||||
});
|
||||
}
|
||||
|
||||
function nodePos(nodesByKey, key) {
|
||||
const n = nodesByKey[key];
|
||||
if (n && n._x != null) return { x: n._x, y: n._y };
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMode(opts) {
|
||||
const id = (opts && opts.commMode) || localStorage.getItem(STORAGE_KEY) || 'pyramid';
|
||||
return COMM_MODES[id] || COMM_MODES.pyramid;
|
||||
}
|
||||
|
||||
function drawMesh(svg, data, opts) {
|
||||
opts = opts || {};
|
||||
const mode = resolveMode(opts);
|
||||
const onNodeClick = opts.onNodeClick;
|
||||
|
||||
clear(svg);
|
||||
svg.setAttribute('data-mesh-mode', mode.id);
|
||||
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const width = vb && vb.width ? vb.width : 1100;
|
||||
const height = vb && vb.height ? vb.height : 640;
|
||||
const width = vb && vb.width ? vb.width : VIEW_W;
|
||||
const height = vb && vb.height ? vb.height : VIEW_H;
|
||||
if (!vb || vb.width !== VIEW_W || vb.height !== VIEW_H) {
|
||||
svg.setAttribute('viewBox', '0 0 ' + VIEW_W + ' ' + VIEW_H);
|
||||
}
|
||||
|
||||
const cx = width / 2;
|
||||
const hermanY = 280;
|
||||
const execY = 75;
|
||||
const agentY = 520;
|
||||
const positions = {
|
||||
execY: 72,
|
||||
hermanY: mode.layout === 'clusters' ? height * 0.38 : 228,
|
||||
tierYs: [400, 540, 690],
|
||||
};
|
||||
|
||||
const souls = (data.nodes || []).filter((n) => {
|
||||
const souls = (data.nodes || []).filter(function (n) {
|
||||
const key = (n.agent_key || '').toLowerCase();
|
||||
return key && key !== 'herman';
|
||||
});
|
||||
|
||||
const edgesBySource = {};
|
||||
(data.edges || []).forEach((e) => { edgesBySource[e.source] = e; });
|
||||
const layoutMeta =
|
||||
mode.layout === 'clusters'
|
||||
? layoutClusters(souls, width, height)
|
||||
: layoutPyramid(souls, width, positions);
|
||||
|
||||
const agentCount = Math.max(1, souls.length);
|
||||
const pad = 70;
|
||||
const span = width - pad * 2;
|
||||
souls.forEach((node, i) => {
|
||||
node._x = pad + (span * i) / Math.max(1, agentCount - 1 || 1);
|
||||
if (agentCount === 1) node._x = cx;
|
||||
node._y = agentY;
|
||||
const cx = layoutMeta.cx;
|
||||
const hermanY = layoutMeta.hermanY;
|
||||
const execY = layoutMeta.execY;
|
||||
|
||||
const nodesByKey = {};
|
||||
souls.forEach(function (n) {
|
||||
nodesByKey[(n.agent_key || '').toLowerCase()] = n;
|
||||
});
|
||||
|
||||
const ceo = { x: cx - 180, y: execY, label: 'CEO', sub: 'Aissa · beslissingen' };
|
||||
const cto = { x: cx + 180, y: execY, label: 'CTO', sub: 'Platform · techniek' };
|
||||
const ceo = { x: cx - 200, y: execY, label: 'CEO', sub: 'Aissa' };
|
||||
const cto = { x: cx + 200, y: execY, label: 'CTO', sub: 'Platform' };
|
||||
|
||||
const edgesLayer = make('g');
|
||||
const nodesLayer = make('g');
|
||||
const bgLayer = make('g', { class: 'mesh-bg' });
|
||||
const clusterLayer = make('g', { class: 'mesh-clusters' });
|
||||
const edgesLayer = make('g', { class: 'mesh-edges' });
|
||||
const nodesLayer = make('g', { class: 'mesh-nodes' });
|
||||
svg.appendChild(bgLayer);
|
||||
if (mode.layout === 'clusters') svg.appendChild(clusterLayer);
|
||||
svg.appendChild(edgesLayer);
|
||||
svg.appendChild(nodesLayer);
|
||||
|
||||
function addEdge(x1, y1, x2, y2, pulse) {
|
||||
const d = linePath(x1, y1, x2, y2);
|
||||
edgesLayer.appendChild(make('path', { d, class: 'mesh-edge' }));
|
||||
if (mode.layout === 'pyramid') {
|
||||
positions.tierYs.forEach(function (y, i) {
|
||||
bgLayer.appendChild(
|
||||
make('rect', {
|
||||
x: 40,
|
||||
y: y - 42,
|
||||
width: width - 80,
|
||||
height: 84,
|
||||
rx: 12,
|
||||
class: 'mesh-tier-band mesh-tier-' + i,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode.layout === 'clusters' && layoutMeta.clusterCenters) {
|
||||
Object.keys(CLUSTERS).forEach(function (cid) {
|
||||
const c = CLUSTERS[cid];
|
||||
const center = layoutMeta.clusterCenters[cid];
|
||||
if (!center) return;
|
||||
const g = make('g', { class: 'mesh-cluster-blob mesh-cluster-' + cid });
|
||||
g.appendChild(
|
||||
make('ellipse', {
|
||||
cx: center.x,
|
||||
cy: center.y,
|
||||
rx: center.r + 28,
|
||||
ry: center.r * 0.62,
|
||||
fill: c.color,
|
||||
stroke: c.stroke,
|
||||
'stroke-width': 1.2,
|
||||
})
|
||||
);
|
||||
const lbl = make('text', {
|
||||
x: center.x,
|
||||
y: center.y - center.r * 0.35,
|
||||
class: 'mesh-cluster-label',
|
||||
'text-anchor': 'middle',
|
||||
});
|
||||
lbl.textContent = c.icon + ' ' + c.label;
|
||||
g.appendChild(lbl);
|
||||
clusterLayer.appendChild(g);
|
||||
});
|
||||
}
|
||||
|
||||
function addEdge(x1, y1, x2, y2, cls, pulse, extraAttrs) {
|
||||
const d = curvePath(x1, y1, x2, y2, mode.edgeBend);
|
||||
const attrs = { d: d, class: 'mesh-edge ' + cls };
|
||||
if (extraAttrs) Object.assign(attrs, extraAttrs);
|
||||
edgesLayer.appendChild(make('path', attrs));
|
||||
if (pulse) {
|
||||
const p = make('path', { d, class: 'mesh-pulse' });
|
||||
p.style.animationDuration = pulse + 's';
|
||||
const p = make('path', { d: d, class: 'mesh-pulse mesh-pulse-active ' + cls });
|
||||
p.style.animationDuration = (typeof pulse === 'number' ? pulse : 2.6) + 's';
|
||||
edgesLayer.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
souls.forEach((node) => {
|
||||
const weight = edgesBySource[node.agent_key]?.weight || 1;
|
||||
addEdge(node._x, node._y - 26, cx, hermanY + 48, Math.max(1.2, 3.2 - Math.min(2, weight / 10)));
|
||||
if (mode.showExecutive) {
|
||||
const execCls = mode.straightExec ? 'mesh-edge-executive mesh-edge-straight' : 'mesh-edge-executive';
|
||||
addEdge(ceo.x, ceo.y + 30, cx, hermanY - 46, execCls, false);
|
||||
addEdge(cto.x, cto.y + 30, cx, hermanY - 46, execCls, false);
|
||||
}
|
||||
|
||||
(data.peer_edges || []).forEach(function (e) {
|
||||
const live = e.type === 'live';
|
||||
if (live && !mode.showLivePeers) return;
|
||||
if (!live && !mode.showStaticPeers) return;
|
||||
if (mode.activeOnly && !live) return;
|
||||
|
||||
const a = nodePos(nodesByKey, e.source);
|
||||
const b = nodePos(nodesByKey, e.target);
|
||||
if (!a || !b) return;
|
||||
|
||||
const srcNode = nodesByKey[e.source];
|
||||
const tgtNode = nodesByKey[e.target];
|
||||
const sameCluster =
|
||||
srcNode &&
|
||||
tgtNode &&
|
||||
srcNode._cluster &&
|
||||
srcNode._cluster === tgtNode._cluster;
|
||||
|
||||
if (mode.intraClusterOnly && !sameCluster) return;
|
||||
|
||||
const cls = live ? 'mesh-edge-peer-live' : 'mesh-edge-peer';
|
||||
const pulse = live ? 2.2 : false;
|
||||
const extra = {};
|
||||
if (!live && mode.staticPeerOpacity != null) {
|
||||
extra['stroke-opacity'] = mode.staticPeerOpacity;
|
||||
}
|
||||
if (sameCluster && mode.layout === 'clusters') {
|
||||
extra['stroke-opacity'] = live ? 0.75 : mode.staticPeerOpacity || 0.25;
|
||||
}
|
||||
|
||||
addEdge(a.x, a.y, b.x, b.y, cls, pulse, extra);
|
||||
});
|
||||
|
||||
addEdge(cx, hermanY - 48, ceo.x, ceo.y + 28, 2.2);
|
||||
addEdge(cx, hermanY - 48, cto.x, cto.y + 28, 2.4);
|
||||
if (mode.showReport) {
|
||||
(data.report_edges || []).forEach(function (e) {
|
||||
if (mode.activeOnly && !e.active) return;
|
||||
const a = nodePos(nodesByKey, e.source);
|
||||
if (!a) return;
|
||||
addEdge(a.x, a.y - 24, cx, hermanY + 42, 'mesh-edge-report', e.active ? 2.4 : false);
|
||||
});
|
||||
}
|
||||
|
||||
if (mode.showDelegate) {
|
||||
(data.delegate_edges || []).forEach(function (e) {
|
||||
if (mode.activeOnly && !e.active) return;
|
||||
const b = nodePos(nodesByKey, e.target);
|
||||
if (!b) return;
|
||||
addEdge(cx, hermanY + 42, b.x, b.y - 24, 'mesh-edge-delegate', e.active ? 2.0 : false);
|
||||
});
|
||||
}
|
||||
|
||||
function drawExec(node, cls) {
|
||||
const g = make('g', { class: `mesh-node mesh-exec ${cls}` });
|
||||
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 34, class: 'main' }));
|
||||
g.appendChild(make('circle', { cx: node.x, cy: node.y, r: 42, class: 'ring' }));
|
||||
g.appendChild(make('text', { x: node.x, y: node.y - 2, 'font-size': 13 }));
|
||||
g.lastChild.textContent = node.label;
|
||||
const sub = make('text', { x: node.x, y: node.y + 14, 'font-size': 9, opacity: 0.85 });
|
||||
const g = make('g', { class: 'mesh-node mesh-exec ' + cls, transform: 'translate(' + node.x + ',' + node.y + ')' });
|
||||
g.appendChild(make('circle', { cx: 0, cy: 0, r: 30, class: 'main' }));
|
||||
const t = make('text', { x: 0, y: -42, class: 'mesh-label mesh-label-exec' });
|
||||
t.textContent = node.label;
|
||||
g.appendChild(t);
|
||||
const sub = make('text', { x: 0, y: -26, class: 'mesh-sublabel' });
|
||||
sub.textContent = node.sub;
|
||||
g.appendChild(sub);
|
||||
nodesLayer.appendChild(g);
|
||||
@@ -91,56 +598,222 @@
|
||||
drawExec(ceo, 'mesh-ceo');
|
||||
drawExec(cto, 'mesh-cto');
|
||||
|
||||
const herman = make('g', { class: 'mesh-node mesh-herman' });
|
||||
herman.appendChild(make('circle', { class: 'ring', cx, cy: hermanY, r: 58 }));
|
||||
herman.appendChild(make('circle', { class: 'main', cx, cy: hermanY, r: 44 }));
|
||||
const crown = make('text', { x: cx, y: hermanY - 4, 'font-size': 22, 'text-anchor': 'middle' });
|
||||
crown.textContent = '👑';
|
||||
herman.appendChild(crown);
|
||||
const hLabel = make('text', { x: cx, y: hermanY + 18 });
|
||||
hLabel.textContent = 'Herman · Co-CEO';
|
||||
herman.appendChild(hLabel);
|
||||
const hSub = make('text', { x: cx, y: hermanY + 34, 'font-size': 10, opacity: 0.85 });
|
||||
hSub.textContent = 'Takenverdeler';
|
||||
herman.appendChild(hSub);
|
||||
nodesLayer.appendChild(herman);
|
||||
const hermanActive =
|
||||
(data.delegate_edges || []).some(function (e) {
|
||||
return e.active;
|
||||
}) ||
|
||||
(data.report_edges || []).some(function (e) {
|
||||
return e.active;
|
||||
});
|
||||
|
||||
souls.forEach((node) => {
|
||||
const hermanG = make('g', {
|
||||
class: 'mesh-node mesh-herman' + (hermanActive ? ' active' : ''),
|
||||
transform: 'translate(' + cx + ',' + hermanY + ')',
|
||||
});
|
||||
if (hermanActive) {
|
||||
const ringG = make('g', { class: 'mesh-herman-ring' });
|
||||
ringG.appendChild(make('circle', { class: 'ring', cx: 0, cy: 0, r: 54 }));
|
||||
addRotateAnim(ringG, '14s');
|
||||
hermanG.appendChild(ringG);
|
||||
}
|
||||
hermanG.appendChild(make('circle', { class: 'main', cx: 0, cy: 0, r: 40 }));
|
||||
const crown = make('text', { x: 0, y: 6, class: 'mesh-herman-crown', 'text-anchor': 'middle' });
|
||||
crown.textContent = '👑';
|
||||
hermanG.appendChild(crown);
|
||||
const hLabel = make('text', { x: 0, y: 64, class: 'mesh-label mesh-label-herman' });
|
||||
hLabel.textContent = 'Herman';
|
||||
hermanG.appendChild(hLabel);
|
||||
const hSub = make('text', { x: 0, y: 80, class: 'mesh-sublabel mesh-sublabel-herman' });
|
||||
hSub.textContent = 'Co-CEO · takenverdeler';
|
||||
hermanG.appendChild(hSub);
|
||||
const hTask = make('text', { x: 0, y: 94, class: 'mesh-context' });
|
||||
const hermanNode = (data.nodes || []).find(function (n) {
|
||||
return (n.agent_key || '').toLowerCase() === 'herman';
|
||||
});
|
||||
hTask.textContent = hermanNode ? agentSubtitle(hermanNode) : 'Coördineert alle agents';
|
||||
hermanG.appendChild(hTask);
|
||||
nodesLayer.appendChild(hermanG);
|
||||
|
||||
souls.forEach(function (node, idx) {
|
||||
const key = (node.agent_key || '').toLowerCase();
|
||||
const group = make('g', { class: `mesh-node mesh-agent ${node.health || 'idle'}` });
|
||||
group.appendChild(make('circle', { cx: node._x, cy: node._y, r: 22 }));
|
||||
const shortName = (node.display_name || node.agent_key || '?').split(' ')[0];
|
||||
const t = make('text', { x: node._x, y: node._y + 4 });
|
||||
t.textContent = shortName;
|
||||
const live = !!node.is_active;
|
||||
const status = agentStatus(node);
|
||||
const hint = AGENT_HINTS[key];
|
||||
const clusterCls = node._cluster ? ' mesh-agent-cluster-' + node._cluster : '';
|
||||
const labelAbove = typeof node._tier === 'number' ? node._tier % 2 === 1 : idx % 2 === 1;
|
||||
const group = make('g', {
|
||||
class:
|
||||
'mesh-node mesh-agent mesh-status-' +
|
||||
status +
|
||||
' ' +
|
||||
(node.health || 'idle') +
|
||||
(live ? ' mesh-agent-live' : '') +
|
||||
clusterCls,
|
||||
transform: 'translate(' + node._x + ',' + node._y + ')',
|
||||
});
|
||||
if (live) {
|
||||
const pulseG = make('g', { class: 'mesh-pulse-wrap' });
|
||||
pulseG.appendChild(make('circle', { cx: 0, cy: 0, r: 28, class: 'pulse-ring' }));
|
||||
group.appendChild(pulseG);
|
||||
}
|
||||
group.appendChild(make('circle', { cx: 0, cy: 0, r: 22, class: 'main' }));
|
||||
const emoji = make('text', { x: 0, y: 5, class: 'mesh-emoji', 'text-anchor': 'middle' });
|
||||
emoji.textContent = node.avatar_emoji || (hint && hint.short ? hint.short.charAt(0) : '') || '●';
|
||||
group.appendChild(emoji);
|
||||
const displayName = (node.display_name || key || '?').split(' ')[0];
|
||||
const nameY = labelAbove ? -30 : 36;
|
||||
const ctxY = labelAbove ? -16 : 52;
|
||||
const domY = labelAbove ? -2 : 66;
|
||||
const t = make('text', { x: 0, y: nameY, class: 'mesh-label mesh-label-agent' });
|
||||
t.textContent = displayName;
|
||||
group.appendChild(t);
|
||||
const role = make('text', { x: node._x, y: node._y + 38, 'font-size': 9, opacity: 0.82 });
|
||||
role.textContent = (node.role_title || key).split('·')[0].trim().slice(0, 18);
|
||||
group.appendChild(role);
|
||||
const status = make('text', { x: node._x, y: node._y + 52, 'font-size': 8, class: 'mesh-status' });
|
||||
status.textContent = (node.health || 'idle').toUpperCase();
|
||||
group.appendChild(status);
|
||||
group.addEventListener('click', () => {
|
||||
if (window.Cockpit && Cockpit.toast) {
|
||||
Cockpit.toast(`${node.display_name || key}: ${node.health || 'idle'}`, 'info');
|
||||
}
|
||||
const ctx = make('text', { x: 0, y: ctxY, class: 'mesh-context' });
|
||||
ctx.textContent = agentSubtitle(node);
|
||||
group.appendChild(ctx);
|
||||
if (hint && mode.layout !== 'clusters') {
|
||||
const dom = make('text', { x: 0, y: domY, class: 'mesh-domain' });
|
||||
dom.textContent = hint.domain;
|
||||
group.appendChild(dom);
|
||||
}
|
||||
const tip =
|
||||
(node.role_title || '') +
|
||||
(node.responsibilities ? '\n' + String(node.responsibilities).slice(0, 120) : '') +
|
||||
(node.current_task ? '\nNu: ' + node.current_task : '');
|
||||
group.setAttribute('title', tip.slice(0, 200));
|
||||
group.addEventListener('click', function () {
|
||||
if (onNodeClick) onNodeClick(node);
|
||||
});
|
||||
nodesLayer.appendChild(group);
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(targetId) {
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg) return;
|
||||
try {
|
||||
const data = await loadMeshData();
|
||||
drawMesh(svg, data);
|
||||
} catch (e) {
|
||||
clear(svg);
|
||||
const msg = make('text', { x: 32, y: 40, fill: '#ef4444', 'font-size': 14 });
|
||||
msg.textContent = 'Mesh kon niet geladen worden';
|
||||
svg.appendChild(msg);
|
||||
function updatePickerUI(modeId) {
|
||||
document.querySelectorAll('[data-mesh-mode]').forEach(function (btn) {
|
||||
const active = btn.getAttribute('data-mesh-mode') === modeId;
|
||||
btn.classList.toggle('is-active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
});
|
||||
const wrap = document.querySelector('.mesh-wrap');
|
||||
if (wrap) {
|
||||
wrap.className = 'mesh-wrap mesh-wrap-mode-' + modeId;
|
||||
wrap.setAttribute('data-mode', modeId);
|
||||
}
|
||||
const badge = document.getElementById('mesh-mode-badge');
|
||||
if (badge && COMM_MODES[modeId]) {
|
||||
badge.textContent = COMM_MODES[modeId].label;
|
||||
}
|
||||
}
|
||||
|
||||
window.AgentsMesh = { mount };
|
||||
function pickMode(modeId, opts, silent) {
|
||||
const mode = COMM_MODES[modeId] ? modeId : 'pyramid';
|
||||
const drawOpts = Object.assign({}, lastMountOpts, opts || {}, { commMode: mode });
|
||||
if (!silent) saveMode(mode);
|
||||
updatePickerUI(mode);
|
||||
if (onModeChangeCb) onModeChangeCb(mode);
|
||||
const svg = document.getElementById('agents-mesh-canvas');
|
||||
if (!svg) return mode;
|
||||
showMeshLoading(svg, 'Stijl: ' + COMM_MODES[mode].label + '…');
|
||||
ensureMeshData()
|
||||
.then(function () {
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, drawOpts);
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
|
||||
});
|
||||
return mode;
|
||||
}
|
||||
|
||||
function bindModePicker(opts) {
|
||||
lastMountOpts = Object.assign({}, lastMountOpts, opts || {});
|
||||
if (pickerBound) return;
|
||||
pickerBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-mesh-mode]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = btn.getAttribute('data-mesh-mode');
|
||||
pickMode(id);
|
||||
});
|
||||
}
|
||||
|
||||
function setModeChangeCallback(fn) {
|
||||
onModeChangeCb = fn;
|
||||
}
|
||||
|
||||
async function mount(targetId, opts) {
|
||||
opts = opts || {};
|
||||
lastMountOpts = opts;
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg) return null;
|
||||
bindModePicker(opts);
|
||||
const modeObj = resolveMode(opts);
|
||||
updatePickerUI(modeObj.id);
|
||||
showMeshLoading(svg, 'Netwerk laden…');
|
||||
try {
|
||||
await ensureMeshData();
|
||||
const drawOpts = Object.assign({}, opts, { commMode: modeObj.id });
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, drawOpts);
|
||||
});
|
||||
return cachedData;
|
||||
} catch (e) {
|
||||
showMeshError(svg, 'Mesh laden mislukt — ' + (e.message || ''));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(targetId, opts) {
|
||||
cachedData = null;
|
||||
return mount(targetId, opts);
|
||||
}
|
||||
|
||||
function redraw(targetId, opts) {
|
||||
const svg = document.getElementById(targetId);
|
||||
if (!svg || !cachedData) return;
|
||||
whenSvgVisible(svg, function () {
|
||||
renderMesh(svg, opts || {});
|
||||
});
|
||||
}
|
||||
|
||||
function saveMode(modeId) {
|
||||
if (COMM_MODES[modeId]) localStorage.setItem(STORAGE_KEY, modeId);
|
||||
}
|
||||
|
||||
function getSavedMode() {
|
||||
const id = localStorage.getItem(STORAGE_KEY) || 'pyramid';
|
||||
return COMM_MODES[id] ? id : 'pyramid';
|
||||
}
|
||||
|
||||
window.AgentsMesh = {
|
||||
mount,
|
||||
refresh,
|
||||
redraw,
|
||||
pickMode,
|
||||
bindModePicker,
|
||||
setModeChangeCallback,
|
||||
loadMeshData,
|
||||
drawMesh,
|
||||
COMM_MODES,
|
||||
CLUSTERS,
|
||||
saveMode,
|
||||
getSavedMode,
|
||||
};
|
||||
|
||||
function bootPicker() {
|
||||
bindModePicker({});
|
||||
const saved = getSavedMode();
|
||||
updatePickerUI(saved);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootPicker);
|
||||
} else {
|
||||
bootPicker();
|
||||
}
|
||||
|
||||
if (window.location.pathname.indexOf('/agents') >= 0) {
|
||||
ensureMeshData().catch(function () {});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
(function () {
|
||||
const buffers = {};
|
||||
let liveEs = null;
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function lineClass(type) {
|
||||
if (!type) return 'line-action';
|
||||
if (type === 'command') return 'line-command';
|
||||
if (type === 'output') return 'line-output';
|
||||
if (String(type).indexOf('handoff') >= 0) return 'line-handoff';
|
||||
if (type === 'error') return 'line-error';
|
||||
if (String(type).indexOf('monitor') >= 0) return 'line-handoff';
|
||||
if (String(type).indexOf('browse') >= 0) return 'line-action';
|
||||
return 'line-action';
|
||||
}
|
||||
|
||||
function formatLine(entry) {
|
||||
const t = (entry.at || '').substring(11, 19) || (entry.at || '').substring(11, 16) || '--:--';
|
||||
const msg = entry.message || entry.title || '';
|
||||
const detail = entry.detail ? ' — ' + String(entry.detail).slice(0, 200) : '';
|
||||
return (
|
||||
'<div class="term-line ' +
|
||||
lineClass(entry.type) +
|
||||
'"><span class="term-ts">' +
|
||||
esc(t) +
|
||||
'</span><span class="term-msg">' +
|
||||
esc(msg + detail) +
|
||||
'</span></div>'
|
||||
);
|
||||
}
|
||||
|
||||
function render(key) {
|
||||
const els = document.querySelectorAll('[data-term-key="' + key + '"]');
|
||||
if (!els.length) return;
|
||||
const lines = buffers[key] || [];
|
||||
const html =
|
||||
!lines.length
|
||||
? '<div class="term-line term-idle"><span class="term-msg">Typ <code>help</code> voor commando\'s · Enter om uit te voeren</span></div>'
|
||||
: lines.slice(-80).map(formatLine).join('');
|
||||
els.forEach(function (el) {
|
||||
el.innerHTML = html;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const win = el.closest('.agent-term-window');
|
||||
if (win && lines.length) win.classList.add('is-live');
|
||||
});
|
||||
}
|
||||
|
||||
function pushLine(key, entry, skipDup) {
|
||||
if (!key || !entry) return;
|
||||
key = String(key).toLowerCase();
|
||||
if (!buffers[key]) buffers[key] = [];
|
||||
if (skipDup && entry._id) {
|
||||
const seen = buffers[key].some(function (l) {
|
||||
return l._id === entry._id;
|
||||
});
|
||||
if (seen) return;
|
||||
}
|
||||
if (!entry.at) entry.at = nowIso();
|
||||
buffers[key].push(entry);
|
||||
if (buffers[key].length > 120) buffers[key].shift();
|
||||
render(key);
|
||||
}
|
||||
|
||||
function setBusy(key, busy) {
|
||||
document.querySelectorAll('[data-term-input="' + key + '"]').forEach(function (input) {
|
||||
input.disabled = !!busy;
|
||||
input.classList.toggle('is-busy', !!busy);
|
||||
});
|
||||
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (body) {
|
||||
const win = body.closest('.agent-term-window');
|
||||
if (win) win.classList.toggle('is-running', !!busy);
|
||||
});
|
||||
}
|
||||
|
||||
async function runCommand(key, cmd) {
|
||||
key = String(key || '').toLowerCase();
|
||||
cmd = (cmd || '').trim();
|
||||
if (!cmd) return;
|
||||
if (cmd.toLowerCase() === 'clear') {
|
||||
buffers[key] = [];
|
||||
render(key);
|
||||
return;
|
||||
}
|
||||
pushLine(key, { type: 'command', message: '$ ' + cmd, at: nowIso() });
|
||||
setBusy(key, true);
|
||||
try {
|
||||
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/terminal/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err =
|
||||
(typeof data.detail === 'string' && data.detail) ||
|
||||
(Array.isArray(data.detail) && data.detail[0]?.msg) ||
|
||||
'Commando mislukt';
|
||||
pushLine(key, { type: 'error', message: err, at: nowIso() });
|
||||
return;
|
||||
}
|
||||
(data.lines || []).forEach(function (line) {
|
||||
pushLine(key, {
|
||||
type: line.type || 'output',
|
||||
message: line.message || '',
|
||||
detail: line.detail || '',
|
||||
at: nowIso(),
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
pushLine(key, { type: 'error', message: e.message || 'Netwerkfout', at: nowIso() });
|
||||
} finally {
|
||||
setBusy(key, false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindInputs() {
|
||||
document.querySelectorAll('[data-term-input]').forEach(function (input) {
|
||||
if (input._termBound) return;
|
||||
input._termBound = true;
|
||||
const key = input.getAttribute('data-term-input');
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const val = input.value;
|
||||
input.value = '';
|
||||
runCommand(key, val);
|
||||
}
|
||||
});
|
||||
input.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory(key) {
|
||||
try {
|
||||
const r = await fetch('/api/agents/souls/' + encodeURIComponent(key) + '/events?limit=30');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
buffers[key] = (data.items || []).map(function (ev) {
|
||||
const meta = ev.metadata || {};
|
||||
let type = 'action';
|
||||
const et = ev.event_type || '';
|
||||
if (et.indexOf('handoff') >= 0) type = 'handoff_out';
|
||||
if (meta.target_agent) type = 'handoff_out';
|
||||
if (meta.source_agent) type = 'handoff_in';
|
||||
if (et.indexOf('monitor') >= 0) type = 'monitor';
|
||||
if (et.indexOf('browse') >= 0) type = 'browse';
|
||||
if (et === 'terminal_in') type = 'command';
|
||||
if (et === 'terminal_out' || et === 'terminal_note') type = 'output';
|
||||
return {
|
||||
_id: ev.id,
|
||||
type: type,
|
||||
message: ev.title || ev.event_type,
|
||||
detail: ev.body,
|
||||
at: ev.created_at,
|
||||
};
|
||||
});
|
||||
render(key);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function handleLivePayload(d) {
|
||||
if (!d || d.type === 'connected' || d.type === 'error') return;
|
||||
const key = (d.agent || '').toLowerCase();
|
||||
if (!key) return;
|
||||
pushLine(
|
||||
key,
|
||||
{
|
||||
_id: d.id,
|
||||
type: d.type,
|
||||
message: d.message,
|
||||
detail: d.detail,
|
||||
at: d.at,
|
||||
},
|
||||
true
|
||||
);
|
||||
document.querySelectorAll('[data-term-key="' + key + '"]').forEach(function (el) {
|
||||
const win = el.closest('.agent-term-window');
|
||||
if (win) win.classList.add('is-live');
|
||||
});
|
||||
}
|
||||
|
||||
function startLiveStream() {
|
||||
if (liveEs || typeof EventSource === 'undefined') return;
|
||||
liveEs = new EventSource('/api/agents/live/stream');
|
||||
liveEs.onmessage = function (ev) {
|
||||
try {
|
||||
handleLivePayload(JSON.parse(ev.data));
|
||||
} catch (e) {}
|
||||
};
|
||||
liveEs.onerror = function () {
|
||||
/* EventSource auto-reconnects */
|
||||
};
|
||||
}
|
||||
|
||||
function stopLiveStream() {
|
||||
if (liveEs) {
|
||||
try {
|
||||
liveEs.close();
|
||||
} catch (e) {}
|
||||
liveEs = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function mountAll() {
|
||||
const nodes = document.querySelectorAll('[data-term-key]');
|
||||
const keys = [];
|
||||
nodes.forEach(function (n) {
|
||||
const k = n.getAttribute('data-term-key');
|
||||
if (k && keys.indexOf(k) < 0) keys.push(k);
|
||||
});
|
||||
await Promise.all(keys.map(loadHistory));
|
||||
bindInputs();
|
||||
startLiveStream();
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
stopLiveStream();
|
||||
}
|
||||
|
||||
window.AgentsTerminals = {
|
||||
mountAll,
|
||||
stopAll,
|
||||
pushLine,
|
||||
render,
|
||||
runCommand,
|
||||
bindInputs,
|
||||
startLiveStream,
|
||||
stopLiveStream,
|
||||
};
|
||||
})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -174,19 +174,15 @@ window.BriefingCharts = (function () {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function rssTeaserHtml(stats) {
|
||||
var n = stats.rss_items || (stats.rss_live || []).length || 0;
|
||||
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
|
||||
'<a href="/?tab=rss">Open RSS tab →</a></p>';
|
||||
}
|
||||
|
||||
function renderFoodHighlights(container, stats) {
|
||||
if (!container) return;
|
||||
var items = stats.food_market_highlights || stats.rss_highlights || [];
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<p class="empty-state">Geen highlights — <a href="/marketing">RSS ophalen</a></p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (r) {
|
||||
var cat = (r.category || 'markt').toUpperCase();
|
||||
return '<div class="hm-highlight-item"><span class="hm-cat-badge">' + cat + '</span>' +
|
||||
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
|
||||
'<small>' + (r.feed_name || '') + '</small></div>';
|
||||
}).join('');
|
||||
container.innerHTML = rssTeaserHtml(stats);
|
||||
}
|
||||
|
||||
function renderRegulations(container, stats) {
|
||||
@@ -332,17 +328,7 @@ window.BriefingCharts = (function () {
|
||||
|
||||
function renderRetail(container, stats) {
|
||||
if (!container) return;
|
||||
var items = (stats.trending_food || stats.food_market_highlights || []).slice(0, 6);
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<p class="empty-state">Geen trending — <a href="/marketing">RSS ophalen in Marketing Hub</a></p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (r) {
|
||||
var when = (r.published_at || '').substring(0, 10);
|
||||
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="retail-highlight">' +
|
||||
'<span><strong>' + (r.title || '') + '</strong><br><small>' + (r.feed_name || 'Retail') + (when ? ' · ' + when : '') + '</small></span>' +
|
||||
'<span class="ticker-item">' + ((r.category || 'food').toUpperCase()) + '</span></a>';
|
||||
}).join('');
|
||||
container.innerHTML = rssTeaserHtml(stats);
|
||||
}
|
||||
|
||||
function renderMilestones(container, stats) {
|
||||
@@ -369,19 +355,15 @@ window.BriefingCharts = (function () {
|
||||
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
|
||||
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
|
||||
];
|
||||
var trending = (stats.trending_food || stats.food_market_highlights || []).slice(0, 5);
|
||||
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
|
||||
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
|
||||
var ms = (stats.milestones_pending || []).slice(0, 3);
|
||||
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
|
||||
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
|
||||
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
|
||||
}).join('') + '</div>';
|
||||
if (trending.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8"><span class="hm-live-dot" style="display:inline-block;margin-right:0.35rem"></span>Trending food retail</h4><ul class="hm-exec-list hm-trend-list">';
|
||||
trending.forEach(function (r) {
|
||||
html += '<li><a href="' + (r.link || '#') + '" target="_blank" rel="noopener">' + (r.title || '') + '</a>' +
|
||||
' <small>· ' + (r.feed_name || 'RSS') + '</small></li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
if (rssN) {
|
||||
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
|
||||
}
|
||||
if (ms.length) {
|
||||
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
|
||||
@@ -423,7 +405,6 @@ window.BriefingCharts = (function () {
|
||||
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
|
||||
var kpis = kpiSelection || window._dashboardKpis;
|
||||
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
|
||||
renderRssFeed(document.getElementById('briefing-rss-feed'), stats);
|
||||
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
|
||||
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
|
||||
renderRetail(document.getElementById('briefing-retail'), stats);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
kpis: { title: 'CEO KPI\'s', icon: '📊' },
|
||||
executive: { title: 'Alles op een rij', icon: '📋' },
|
||||
briefing: { title: 'Herman briefing', icon: '📝' },
|
||||
rss: { title: 'RSS live feed', icon: '📰' },
|
||||
retail: { title: 'Retail operatie', icon: '🏪' },
|
||||
analytics: { title: 'Data & analytics', icon: '📈' },
|
||||
approvals: { title: 'Agent goedkeuringen', icon: '✓' },
|
||||
@@ -17,9 +16,9 @@
|
||||
crm_partnerships: { label: 'CRM partnerships', sub: 'actieve filialen', icon: '🏪', color: '#ff9f43', link: '/retail' },
|
||||
supermarkets: { label: 'Supermarkten', sub: 'Retail 360 DB', icon: '🛒', color: '#b8ff3c', link: '/retail' },
|
||||
wholesalers: { label: 'Groothandels', sub: 'Retail 360 DB', icon: '📦', color: '#a78bfa', link: '/retail' },
|
||||
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/marketing' },
|
||||
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/marketing' },
|
||||
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing' },
|
||||
trending_food: { label: 'Food trends', sub: 'RSS live', icon: '📰', color: '#38bdf8', link: '/?tab=rss' },
|
||||
rss_items: { label: 'RSS items', sub: 'totaal in DB', icon: '📡', color: '#0ea5e9', link: '/?tab=rss' },
|
||||
rss_bookmarks: { label: 'RSS bookmarks', sub: 'opgeslagen', icon: '★', color: '#ffd700', link: '/marketing?tab=bookmarks' },
|
||||
pending_approvals: { label: 'Goedkeuringen', sub: 'wacht op OK', icon: '✓', color: '#ffd700', link: '/' },
|
||||
deals: { label: 'Deals', sub: 'totaal CRM', icon: '💼', color: '#f472b6', link: '/deals' },
|
||||
products: { label: 'Producten', sub: 'catalogus', icon: '🥫', color: '#fb923c', link: '/products' },
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Documenten tabs — CSS radio primary, JS syncs Alpine */
|
||||
(function () {
|
||||
var DEFAULT = 'overview';
|
||||
|
||||
function radioId(tab) {
|
||||
return 'doctab-' + tab;
|
||||
}
|
||||
|
||||
function getActiveTab() {
|
||||
var checked = document.querySelector('.doc-tab-input:checked');
|
||||
return (checked && checked.value) || window.__docActiveTab || DEFAULT;
|
||||
}
|
||||
|
||||
function show(tab) {
|
||||
if (!tab) tab = DEFAULT;
|
||||
var radio = document.getElementById(radioId(tab));
|
||||
if (radio) radio.checked = true;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
window.__docActiveTab = tab;
|
||||
}
|
||||
|
||||
function syncAlpine(tab) {
|
||||
var hub = document.querySelector('.doc-hub');
|
||||
if (!hub || !window.Alpine) return;
|
||||
try {
|
||||
var d = Alpine.$data(hub);
|
||||
if (!d) return;
|
||||
if (d.activeTab !== tab) d.activeTab = tab;
|
||||
if (typeof d.onTabShown === 'function') d.onTabShown(tab);
|
||||
} catch (e) {
|
||||
console.warn('doc-tabs alpine', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
var tab = getActiveTab();
|
||||
window.__docActiveTab = tab;
|
||||
document.querySelectorAll('#doc-tab-stage [data-tab-panel]').forEach(function (el) {
|
||||
el.classList.toggle('tab-active', el.getAttribute('data-tab-panel') === tab);
|
||||
});
|
||||
syncAlpine(tab);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
document.querySelectorAll('.doc-tab-input').forEach(function (radio) {
|
||||
if (radio.dataset.bound) return;
|
||||
radio.dataset.bound = '1';
|
||||
radio.addEventListener('change', onTabChange);
|
||||
});
|
||||
}
|
||||
|
||||
window.DocTabs = { show: show, getActive: getActiveTab };
|
||||
|
||||
bind();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
});
|
||||
document.addEventListener('alpine:initialized', function () {
|
||||
bind();
|
||||
show(window.__docActiveTab || getActiveTab());
|
||||
syncAlpine(getActiveTab());
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Export Intel map — Leaflet + clustering + rich popups
|
||||
*/
|
||||
window.ExportIntelMap = (function () {
|
||||
var map = null;
|
||||
var clusterLayer = null;
|
||||
var onEntityClick = null;
|
||||
var halalMode = false;
|
||||
var markersById = {};
|
||||
|
||||
var HALAL_COLORS = {
|
||||
hot: '#22c55e',
|
||||
warm: '#eab308',
|
||||
mild: '#f97316',
|
||||
low: '#64748b',
|
||||
};
|
||||
|
||||
var TYPE_CFG = {
|
||||
distributor: { color: '#f59e0b', icon: '📦', label: 'Distributeur' },
|
||||
wholesaler: { color: '#eab308', icon: '🏪', label: 'Groothandel' },
|
||||
importer: { color: '#f97316', icon: '🚢', label: 'Importeur' },
|
||||
logistics: { color: '#38bdf8', icon: '🚚', label: 'Logistiek' },
|
||||
contract_caterer:{ color: '#a78bfa', icon: '🏢', label: 'Cateraar' },
|
||||
restaurant: { color: '#34d399', icon: '🍽️', label: 'Restaurant' },
|
||||
doner_shoarma: { color: '#4ade80', icon: '🥙', label: 'Döner / shoarma' },
|
||||
butcher: { color: '#f87171', icon: '🥩', label: 'Slager' },
|
||||
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
|
||||
};
|
||||
|
||||
function cfg(type) {
|
||||
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function row(label, val, link) {
|
||||
if (!val) return '';
|
||||
var inner = link
|
||||
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
|
||||
: escapeHtml(val);
|
||||
return (
|
||||
'<div class="ei-popup-row">' +
|
||||
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
|
||||
'<span class="ei-popup-v">' + inner + '</span>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function halalColor(p) {
|
||||
var tier = p.halal_tier || 'low';
|
||||
return HALAL_COLORS[tier] || HALAL_COLORS.low;
|
||||
}
|
||||
|
||||
function popupHtml(p) {
|
||||
var c = cfg(p.entity_type);
|
||||
var id = p.id;
|
||||
var web = p.website;
|
||||
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
|
||||
var halalRow = '';
|
||||
if (p.halal_score != null) {
|
||||
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
|
||||
}
|
||||
var contactBits = [];
|
||||
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
|
||||
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
|
||||
var contactRow = contactBits.length
|
||||
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
|
||||
: '';
|
||||
return (
|
||||
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
|
||||
'<div class="ei-popup-top">' +
|
||||
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
|
||||
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
|
||||
'</div>' +
|
||||
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
|
||||
'<div class="ei-popup-grid">' +
|
||||
halalRow +
|
||||
contactRow +
|
||||
row('Stad', p.city) +
|
||||
row('Adres', p.address_line) +
|
||||
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
|
||||
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
|
||||
row('Website', p.website, web) +
|
||||
row('Volume', p.volume_band) +
|
||||
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
|
||||
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
|
||||
row('Pipeline', p.pipeline_stage) +
|
||||
'</div>' +
|
||||
'<div class="ei-popup-actions">' +
|
||||
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function pinIcon(type, p) {
|
||||
var c = cfg(type);
|
||||
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
|
||||
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
|
||||
return L.divIcon({
|
||||
className: 'ei-pin-wrap',
|
||||
html:
|
||||
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
|
||||
'<span class="ei-pin-ring"></span>' +
|
||||
'<span class="ei-pin-icon">' + c.icon + '</span>' +
|
||||
'</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
}
|
||||
|
||||
function openEntityId(rawId) {
|
||||
var id = Number(rawId);
|
||||
if (!Number.isFinite(id)) return;
|
||||
if (onEntityClick) {
|
||||
onEntityClick(id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
|
||||
} catch (e) { /* empty */ }
|
||||
}
|
||||
|
||||
function wirePopup(container) {
|
||||
if (!container) return;
|
||||
var popupRoot = container.closest('.leaflet-popup') || container;
|
||||
if (window.L && L.DomEvent) {
|
||||
L.DomEvent.disableClickPropagation(popupRoot);
|
||||
L.DomEvent.disableScrollPropagation(popupRoot);
|
||||
}
|
||||
var btn = container.querySelector('.ei-popup-btn');
|
||||
var id = btn && btn.getAttribute('data-entity-id');
|
||||
if (btn && id) {
|
||||
var onBtn = function (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
openEntityId(id);
|
||||
};
|
||||
if (window.L && L.DomEvent) {
|
||||
L.DomEvent.on(btn, 'click', onBtn);
|
||||
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
|
||||
} else {
|
||||
btn.addEventListener('click', onBtn);
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
}
|
||||
container.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
|
||||
var card = e.target.closest('[data-entity-id]');
|
||||
if (card) openEntityId(card.getAttribute('data-entity-id'));
|
||||
});
|
||||
}
|
||||
|
||||
function init(containerId) {
|
||||
var el = document.getElementById(containerId);
|
||||
if (!el || !window.L) return null;
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
clusterLayer = null;
|
||||
}
|
||||
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '© OSM © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
if (window.L.markerClusterGroup) {
|
||||
clusterLayer = L.markerClusterGroup({
|
||||
maxClusterRadius: 42,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
zoomToBoundsOnClick: true,
|
||||
iconCreateFunction: function (cluster) {
|
||||
var n = cluster.getChildCount();
|
||||
var size = n < 10 ? 'sm' : n < 50 ? 'md' : 'lg';
|
||||
return L.divIcon({
|
||||
html: '<div class="ei-cluster ei-cluster-' + size + '"><span>' + n + '</span></div>',
|
||||
className: 'ei-cluster-wrap',
|
||||
iconSize: L.point(44, 44),
|
||||
});
|
||||
},
|
||||
});
|
||||
map.addLayer(clusterLayer);
|
||||
} else {
|
||||
clusterLayer = L.layerGroup();
|
||||
map.addLayer(clusterLayer);
|
||||
}
|
||||
|
||||
map.on('popupopen', function (e) {
|
||||
var root = e.popup && e.popup.getElement();
|
||||
if (!root) return;
|
||||
if (window.L && L.DomEvent) {
|
||||
L.DomEvent.disableClickPropagation(root);
|
||||
L.DomEvent.disableScrollPropagation(root);
|
||||
}
|
||||
wirePopup(root.querySelector('.leaflet-popup-content') || root);
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function setFeatures(geojson) {
|
||||
if (!map || !clusterLayer) return;
|
||||
clusterLayer.clearLayers();
|
||||
markersById = {};
|
||||
var features = (geojson && geojson.features) || [];
|
||||
var markers = [];
|
||||
|
||||
features.forEach(function (f) {
|
||||
var coords = f.geometry && f.geometry.coordinates;
|
||||
if (!coords) return;
|
||||
var p = f.properties || {};
|
||||
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) });
|
||||
marker._eiId = p.id;
|
||||
if (p.id != null) markersById[p.id] = marker;
|
||||
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' });
|
||||
marker.on('dblclick', function () {
|
||||
if (onEntityClick && p.id) onEntityClick(p.id);
|
||||
});
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
|
||||
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
|
||||
|
||||
if (features.length === 1) {
|
||||
var c = features[0].geometry.coordinates;
|
||||
map.setView([c[1], c[0]], 10);
|
||||
} else if (markers.length > 1 && clusterLayer.getBounds) {
|
||||
try {
|
||||
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
|
||||
} catch (e) { /* empty */ }
|
||||
}
|
||||
}
|
||||
|
||||
function setHalalMode(on) {
|
||||
halalMode = !!on;
|
||||
}
|
||||
|
||||
function setOnEntityClick(fn) {
|
||||
onEntityClick = fn;
|
||||
}
|
||||
|
||||
function flyTo(lat, lon, zoom) {
|
||||
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
|
||||
}
|
||||
|
||||
function highlightEntity(id) {
|
||||
var marker = markersById[id];
|
||||
if (!marker || !map) return;
|
||||
var latlng = marker.getLatLng();
|
||||
map.setView(latlng, Math.max(map.getZoom(), 14));
|
||||
marker.openPopup();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (map) map.closePopup();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
clusterLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
|
||||
})();
|
||||
@@ -0,0 +1,596 @@
|
||||
function exportIntelApp() {
|
||||
return {
|
||||
tab: 'map',
|
||||
tabIndicatorTop: 0,
|
||||
tabIndicatorH: 40,
|
||||
busy: false,
|
||||
syncMsg: '',
|
||||
stats: {},
|
||||
regions: [],
|
||||
territories: [],
|
||||
country: '',
|
||||
region: '',
|
||||
entities: [],
|
||||
contacts: [],
|
||||
tenders: [],
|
||||
catererPresence: [],
|
||||
catererBrands: [],
|
||||
govSources: [],
|
||||
selected: null,
|
||||
drawerOpen: false,
|
||||
q: '',
|
||||
entityTypeFilter: '',
|
||||
mapTypesSelected: [],
|
||||
mapHalalMode: false,
|
||||
mapHalalMin: 55,
|
||||
halalTopMarkets: [],
|
||||
halalTopEntities: [],
|
||||
listFocusId: null,
|
||||
listFocusDetail: null,
|
||||
listFocusLoading: false,
|
||||
drawerLoading: false,
|
||||
filterHasEmail: '',
|
||||
filterFavoritesOnly: false,
|
||||
filterCrmStatus: '',
|
||||
selectedIds: [],
|
||||
crmPipeline: [],
|
||||
crmModalOpen: false,
|
||||
crmCreateDeals: true,
|
||||
crmPushBusy: false,
|
||||
resultCount: 0,
|
||||
_searchTimer: null,
|
||||
|
||||
tabs: [
|
||||
{ id: 'map', icon: '🗺️', label: 'Kaart', color: '#0ea5e9', key: '1' },
|
||||
{ id: 'distributors', icon: '📦', label: 'Distributeurs', color: '#fbbf24', key: '2' },
|
||||
{ id: 'customers', icon: '🍽️', label: 'Eindklanten', color: '#34d399', key: '3' },
|
||||
{ id: 'caterers', icon: '🏢', label: 'Cateraars', color: '#a78bfa', key: '4' },
|
||||
{ id: 'contacts', icon: '📇', label: 'Contacten', color: '#38bdf8', key: '5' },
|
||||
{ id: 'tenders', icon: '📋', label: 'Tenders', color: '#c084fc', key: '6' },
|
||||
{ id: 'gov', icon: '🏛️', label: 'Overheid', color: '#94a3b8', key: '7' },
|
||||
{ id: 'pipeline', icon: '🔗', label: 'Pipeline', color: '#fb923c', key: '8' },
|
||||
],
|
||||
|
||||
mapTypeChips: [
|
||||
{ v: 'distributor', l: 'Distributeur', icon: '📦' },
|
||||
{ v: 'wholesaler', l: 'Groothandel', icon: '🏪' },
|
||||
{ v: 'importer', l: 'Importeur', icon: '🚢' },
|
||||
{ v: 'logistics', l: 'Logistiek', icon: '🚚' },
|
||||
{ v: 'restaurant', l: 'Restaurant', icon: '🍽️' },
|
||||
{ v: 'doner_shoarma', l: 'Döner', icon: '🥙' },
|
||||
{ v: 'butcher', l: 'Slager', icon: '🥩' },
|
||||
{ v: 'contract_caterer', l: 'Cateraar', icon: '🏢' },
|
||||
{ v: 'foodservice', l: 'Foodservice', icon: '🍴' },
|
||||
],
|
||||
|
||||
async init() {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var t = params.get('tab');
|
||||
if (t) this.tab = t;
|
||||
var self = this;
|
||||
document.addEventListener('export-intel:open-entity', function (ev) {
|
||||
if (ev.detail && ev.detail.id) self.openEntity(ev.detail.id);
|
||||
});
|
||||
await this.loadMeta();
|
||||
await this.refresh();
|
||||
this.$nextTick(() => this.updateTabIndicator());
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.target.matches('input, textarea, select')) return;
|
||||
var n = parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= 8) {
|
||||
var btn = this.$refs.vtabsNav?.querySelector('[data-tab="' + this.tabs[n - 1].id + '"]');
|
||||
this.setTab(this.tabs[n - 1].id, btn);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async loadMeta() {
|
||||
try {
|
||||
this.regions = await fetch('/api/export-intel/regions').then((r) => r.json());
|
||||
this.territories = await fetch('/api/export-intel/territories').then((r) => r.json());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.busy = true;
|
||||
try {
|
||||
var cp = this.country ? '?country=' + encodeURIComponent(this.country) : '';
|
||||
var rp = this.region ? (cp ? '&' : '?') + 'region=' + encodeURIComponent(this.region) : '';
|
||||
this.stats = await fetch('/api/export-intel/stats' + cp + rp).then((r) => r.json());
|
||||
await this.loadTabData();
|
||||
if (this.tab === 'map') this.loadMap();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
filterQs() {
|
||||
var parts = [];
|
||||
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
|
||||
if (this.region) parts.push('region=' + encodeURIComponent(this.region));
|
||||
if (this.q && this.q.trim()) parts.push('q=' + encodeURIComponent(this.q.trim()));
|
||||
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
|
||||
if (this.filterHasEmail === 'yes') parts.push('has_email=true');
|
||||
if (this.filterHasEmail === 'no') parts.push('has_email=false');
|
||||
if (this.filterFavoritesOnly) parts.push('favorite_only=true');
|
||||
if (this.filterCrmStatus === 'linked') parts.push('crm_linked=true');
|
||||
if (this.filterCrmStatus === 'not_linked') parts.push('crm_linked=false');
|
||||
return parts.length ? '&' + parts.join('&') : '';
|
||||
},
|
||||
|
||||
onSearchInput() {
|
||||
clearTimeout(this._searchTimer);
|
||||
this._searchTimer = setTimeout(() => this.applyMapFilters(), 350);
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.q = '';
|
||||
this.entityTypeFilter = '';
|
||||
this.mapTypesSelected = [];
|
||||
this.mapHalalMode = false;
|
||||
this.filterHasEmail = '';
|
||||
this.filterFavoritesOnly = false;
|
||||
this.filterCrmStatus = '';
|
||||
this.selectedIds = [];
|
||||
this.applyMapFilters();
|
||||
},
|
||||
|
||||
showEntityTypeFilter() {
|
||||
return ['distributors', 'customers', 'caterers'].indexOf(this.tab) >= 0;
|
||||
},
|
||||
|
||||
showMapTypeFilter() {
|
||||
return this.tab === 'map';
|
||||
},
|
||||
|
||||
isMapTypeActive(v) {
|
||||
return this.mapTypesSelected.indexOf(v) >= 0;
|
||||
},
|
||||
|
||||
toggleMapType(v) {
|
||||
var i = this.mapTypesSelected.indexOf(v);
|
||||
if (i >= 0) this.mapTypesSelected.splice(i, 1);
|
||||
else this.mapTypesSelected.push(v);
|
||||
this.entityTypeFilter = '';
|
||||
this.applyMapFilters();
|
||||
},
|
||||
|
||||
clearMapTypes() {
|
||||
this.mapTypesSelected = [];
|
||||
this.applyMapFilters();
|
||||
},
|
||||
|
||||
mapTypesParam() {
|
||||
if (this.entityTypeFilter) return { entity_type: this.entityTypeFilter };
|
||||
if (this.mapTypesSelected.length) {
|
||||
return { entity_types: this.mapTypesSelected.join(',') };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
applyMapFilters() {
|
||||
if (this.tab !== 'map') {
|
||||
this.loadTabData();
|
||||
return;
|
||||
}
|
||||
this.loadTabData().then(() => this.loadMap());
|
||||
},
|
||||
|
||||
showEmailFilter() {
|
||||
return this.tab === 'contacts';
|
||||
},
|
||||
|
||||
entityTypeOptions() {
|
||||
if (this.tab === 'distributors') {
|
||||
return [
|
||||
{ v: '', l: 'Alle types' },
|
||||
{ v: 'distributor', l: 'Distributeur' },
|
||||
{ v: 'wholesaler', l: 'Groothandel' },
|
||||
{ v: 'importer', l: 'Importeur' },
|
||||
{ v: 'logistics', l: 'Logistiek' },
|
||||
];
|
||||
}
|
||||
if (this.tab === 'customers') {
|
||||
return [
|
||||
{ v: '', l: 'Alle types' },
|
||||
{ v: 'restaurant', l: 'Restaurant' },
|
||||
{ v: 'doner_shoarma', l: 'Döner / shoarma' },
|
||||
{ v: 'butcher', l: 'Slager' },
|
||||
{ v: 'foodservice', l: 'Foodservice' },
|
||||
];
|
||||
}
|
||||
if (this.tab === 'caterers') {
|
||||
return [{ v: '', l: 'Alle types' }, { v: 'contract_caterer', l: 'Contract cateraar' }];
|
||||
}
|
||||
return [
|
||||
{ v: '', l: 'Alle types' },
|
||||
{ v: 'distributor', l: 'Distributeur' },
|
||||
{ v: 'wholesaler', l: 'Groothandel' },
|
||||
{ v: 'contract_caterer', l: 'Cateraar' },
|
||||
{ v: 'restaurant', l: 'Restaurant' },
|
||||
];
|
||||
},
|
||||
|
||||
async loadTabData() {
|
||||
var base = '/api/export-intel';
|
||||
var fq = this.filterQs();
|
||||
if (this.tab === 'map') {
|
||||
var mapQs = this.filterQs();
|
||||
var mt = this.mapTypesParam();
|
||||
if (mt.entity_type) mapQs += '&entity_type=' + encodeURIComponent(mt.entity_type);
|
||||
else if (mt.entity_types) mapQs += '&entity_types=' + encodeURIComponent(mt.entity_types);
|
||||
var rm = await fetch(base + '/entities?limit=200' + mapQs).then((x) => x.json());
|
||||
this.entities = rm.items || [];
|
||||
this.resultCount = rm.total != null ? rm.total : this.entities.length;
|
||||
} else if (this.tab === 'distributors') {
|
||||
var types = 'distributor,wholesaler,importer,logistics';
|
||||
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
|
||||
var r = await fetch(base + '/entities?entity_types=' + types + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et).then((x) => x.json());
|
||||
this.entities = r.items || [];
|
||||
this.resultCount = r.total != null ? r.total : this.entities.length;
|
||||
} else if (this.tab === 'customers') {
|
||||
var types2 = 'restaurant,doner_shoarma,butcher,foodservice';
|
||||
var et2 = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
|
||||
var r2 = await fetch(base + '/entities?entity_types=' + types2 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '') + et2).then((x) => x.json());
|
||||
this.entities = r2.items || [];
|
||||
this.resultCount = r2.total != null ? r2.total : this.entities.length;
|
||||
} else if (this.tab === 'caterers') {
|
||||
var et3 = this.entityTypeFilter || 'contract_caterer';
|
||||
var r3 = await fetch(base + '/entities?entity_type=' + et3 + '&limit=500' + fq.replace(/&?entity_type=[^&]*/g, '')).then((x) => x.json());
|
||||
this.entities = r3.items || [];
|
||||
this.resultCount = r3.total != null ? r3.total : this.entities.length;
|
||||
this.catererPresence = await fetch(base + '/caterers/presence' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
|
||||
this.catererBrands = await fetch(base + '/caterers/brands').then((x) => x.json());
|
||||
} else if (this.tab === 'contacts') {
|
||||
var r4 = await fetch(base + '/contacts?limit=500' + fq).then((x) => x.json());
|
||||
this.contacts = r4.items || [];
|
||||
this.resultCount = r4.total != null ? r4.total : this.contacts.length;
|
||||
} else if (this.tab === 'tenders') {
|
||||
var tenders = await fetch(base + '/tenders' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
|
||||
var items = tenders.items || [];
|
||||
if (this.q && this.q.trim()) {
|
||||
var ql = this.q.trim().toLowerCase();
|
||||
items = items.filter((t) => (t.title || '').toLowerCase().includes(ql));
|
||||
}
|
||||
this.tenders = items;
|
||||
this.resultCount = items.length;
|
||||
} else if (this.tab === 'gov') {
|
||||
var gov = await fetch(base + '/gov-sources' + (this.country ? '?country=' + this.country : '')).then((x) => x.json());
|
||||
if (this.q && this.q.trim()) {
|
||||
var ql2 = this.q.trim().toLowerCase();
|
||||
gov = gov.filter((g) => (g.name || '').toLowerCase().includes(ql2) || (g.category || '').toLowerCase().includes(ql2));
|
||||
}
|
||||
this.govSources = gov;
|
||||
this.resultCount = gov.length;
|
||||
} else if (this.tab === 'pipeline') {
|
||||
var pipeQs = this.filterQs();
|
||||
var pipe = await fetch(base + '/crm/pipeline?limit=200' + pipeQs).then((x) => x.json());
|
||||
this.crmPipeline = pipe.items || [];
|
||||
this.resultCount = this.crmPipeline.length;
|
||||
}
|
||||
},
|
||||
|
||||
async loadMap() {
|
||||
this.clearListFocus();
|
||||
var url = '/api/export-intel/map/bundle';
|
||||
var qs = [];
|
||||
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
|
||||
if (this.region) qs.push('region=' + encodeURIComponent(this.region));
|
||||
var mt = this.mapTypesParam();
|
||||
if (mt.entity_type) qs.push('entity_type=' + encodeURIComponent(mt.entity_type));
|
||||
else if (mt.entity_types) qs.push('entity_types=' + encodeURIComponent(mt.entity_types));
|
||||
if (this.q && this.q.trim()) qs.push('q=' + encodeURIComponent(this.q.trim()));
|
||||
if (this.mapHalalMode) qs.push('halal_min=' + encodeURIComponent(this.mapHalalMin));
|
||||
if (this.filterFavoritesOnly) qs.push('favorite_only=true');
|
||||
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
|
||||
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
|
||||
if (qs.length) url += '?' + qs.join('&');
|
||||
var bundle = await fetch(url).then((r) => r.json());
|
||||
this.halalTopMarkets = (bundle.meta && bundle.meta.top_markets) || [];
|
||||
this.halalTopEntities = (bundle.meta && bundle.meta.top_halal_entities) || [];
|
||||
if (window.ExportIntelMap) {
|
||||
if (!document.getElementById('ei-map')) return;
|
||||
var self = this;
|
||||
window.ExportIntelMap.init('ei-map');
|
||||
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
|
||||
window.ExportIntelMap.setHalalMode(this.mapHalalMode);
|
||||
window.ExportIntelMap.setFeatures(bundle.entities || { features: [] });
|
||||
if (this.country) {
|
||||
var t = this.territories.find((x) => x.country_iso2 === this.country);
|
||||
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setTab(id, el) {
|
||||
this.tab = id;
|
||||
this.entityTypeFilter = '';
|
||||
this.mapTypesSelected = [];
|
||||
this.mapHalalMode = false;
|
||||
this.filterHasEmail = '';
|
||||
var u = new URL(location.href);
|
||||
u.searchParams.set('tab', id);
|
||||
history.replaceState(null, '', u.pathname + u.search);
|
||||
this.loadTabData();
|
||||
if (id === 'map') this.$nextTick(() => this.loadMap());
|
||||
this.$nextTick(() => this.updateTabIndicator(el));
|
||||
},
|
||||
|
||||
updateTabIndicator(el) {
|
||||
var nav = this.$refs.vtabsNav;
|
||||
if (!nav) return;
|
||||
var btn = el || nav.querySelector('.vtab-btn.active');
|
||||
if (!btn) return;
|
||||
var navRect = nav.getBoundingClientRect();
|
||||
var btnRect = btn.getBoundingClientRect();
|
||||
this.tabIndicatorTop = btnRect.top - navRect.top + nav.scrollTop;
|
||||
this.tabIndicatorH = btnRect.height;
|
||||
},
|
||||
|
||||
async onCountryChange() {
|
||||
await this.refresh();
|
||||
},
|
||||
|
||||
contactPreview(e) {
|
||||
if (!e) return '—';
|
||||
if (e.email || e.primary_email) return e.email || e.primary_email;
|
||||
if (e.phone || e.primary_phone) return e.phone || e.primary_phone;
|
||||
if (e.website) return e.website.replace(/^https?:\/\//, '').slice(0, 28);
|
||||
if (e.has_contact) return 'Contact in registry';
|
||||
return 'Geen contact';
|
||||
},
|
||||
|
||||
async fetchEntityDetail(id) {
|
||||
var r = await fetch('/api/export-intel/entities/' + id);
|
||||
if (!r.ok) {
|
||||
var err = await r.json().catch(function () { return {}; });
|
||||
throw new Error((err && err.error) || ('HTTP ' + r.status));
|
||||
}
|
||||
var data = await r.json();
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
async selectListEntity(id) {
|
||||
if (!id) return;
|
||||
this.listFocusId = id;
|
||||
this.listFocusLoading = true;
|
||||
this.listFocusDetail = null;
|
||||
try {
|
||||
var detail = await this.fetchEntityDetail(id);
|
||||
this.listFocusDetail = detail;
|
||||
if (window.ExportIntelMap && detail.lat && detail.lon) {
|
||||
window.ExportIntelMap.flyTo(detail.lat, detail.lon, 14);
|
||||
window.ExportIntelMap.highlightEntity(detail.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
|
||||
this.listFocusId = null;
|
||||
}
|
||||
this.listFocusLoading = false;
|
||||
},
|
||||
|
||||
clearListFocus() {
|
||||
this.listFocusId = null;
|
||||
this.listFocusDetail = null;
|
||||
},
|
||||
|
||||
async openEntity(id) {
|
||||
if (!id) return;
|
||||
this.drawerLoading = true;
|
||||
this.drawerOpen = true;
|
||||
try {
|
||||
if (this.listFocusDetail && this.listFocusDetail.id === id) {
|
||||
this.selected = this.listFocusDetail;
|
||||
} else {
|
||||
this.selected = await this.fetchEntityDetail(id);
|
||||
this.listFocusId = id;
|
||||
this.listFocusDetail = this.selected;
|
||||
}
|
||||
if (window.ExportIntelMap && this.selected.lat && this.selected.lon) {
|
||||
window.ExportIntelMap.flyTo(this.selected.lat, this.selected.lon, 14);
|
||||
window.ExportIntelMap.highlightEntity(this.selected.id);
|
||||
}
|
||||
if (window.ExportIntelMap && window.ExportIntelMap.closePopup) {
|
||||
window.ExportIntelMap.closePopup();
|
||||
}
|
||||
} catch (e) {
|
||||
this.syncMsg = 'Profiel laden mislukt: ' + e.message;
|
||||
this.drawerOpen = false;
|
||||
this.selected = null;
|
||||
}
|
||||
this.drawerLoading = false;
|
||||
},
|
||||
|
||||
closeDrawer() {
|
||||
this.drawerOpen = false;
|
||||
this.selected = null;
|
||||
this.drawerLoading = false;
|
||||
},
|
||||
|
||||
async syncKind(kind) {
|
||||
if (kind === 'world') {
|
||||
if (!confirm('Wereld-sync: Europa, Midden-Oosten, Afrika en Amerika. OSM + contacten — kan lang duren. Doorgaan?')) return;
|
||||
}
|
||||
if (kind === 'region' && !this.region) {
|
||||
this.syncMsg = 'Selecteer eerst een regio in de filterbalk';
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.syncMsg = kind === 'world' ? 'Wereld-sync gestart…' : 'Sync gestart…';
|
||||
try {
|
||||
var body = {
|
||||
country_iso2: this.country || null,
|
||||
region_code: kind === 'region' ? this.region : (this.region || null),
|
||||
max_priority: 2,
|
||||
};
|
||||
if (kind === 'world') body = { max_priority: 2 };
|
||||
var endpoint = kind === 'region' ? 'region' : kind;
|
||||
var r = await fetch('/api/export-intel/sync/' + endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
if (r.error) this.syncMsg = 'Fout: ' + r.error;
|
||||
else if (kind === 'world') this.syncMsg = 'Wereld-sync voltooid — kaart wordt ververst';
|
||||
else if (kind === 'all' || kind === 'region') this.syncMsg = 'Regio-sync voltooid';
|
||||
else this.syncMsg = 'Sync voltooid';
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
this.syncMsg = 'Sync mislukt: ' + e.message;
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
tabCount(id) {
|
||||
if (id === 'distributors') return this.stats.distributors || 0;
|
||||
if (id === 'caterers') return this.stats.caterers || 0;
|
||||
if (id === 'contacts') return this.stats.contacts || 0;
|
||||
if (id === 'tenders') return this.stats.tenders_open || 0;
|
||||
if (id === 'pipeline') return this.stats.crm_linked || 0;
|
||||
if (id === 'map') return this.stats.entities || 0;
|
||||
return 0;
|
||||
},
|
||||
|
||||
tabSub(id) {
|
||||
var n = this.tabCount(id);
|
||||
if (id === 'map') return n + ' op kaart';
|
||||
if (id === 'gov') return 'Bronnen per land';
|
||||
if (id === 'pipeline') return 'CRM koppeling';
|
||||
if (n > 0) return n + ' records';
|
||||
return '';
|
||||
},
|
||||
|
||||
contactsExportUrl() {
|
||||
var u = '/api/export-intel/contacts/export.csv?';
|
||||
var parts = [];
|
||||
if (this.country) parts.push('country=' + encodeURIComponent(this.country));
|
||||
if (this.entityTypeFilter) parts.push('entity_type=' + encodeURIComponent(this.entityTypeFilter));
|
||||
return u + parts.join('&');
|
||||
},
|
||||
|
||||
typeLabel(t) {
|
||||
return (t || '').replace(/_/g, ' ');
|
||||
},
|
||||
|
||||
territoryName(iso2) {
|
||||
if (!iso2) return '';
|
||||
var t = this.territories.find(function (x) { return x.country_iso2 === iso2; });
|
||||
return t ? t.name_nl : iso2;
|
||||
},
|
||||
|
||||
isSelected(id) {
|
||||
return this.selectedIds.indexOf(id) >= 0;
|
||||
},
|
||||
|
||||
toggleSelect(id, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
var i = this.selectedIds.indexOf(id);
|
||||
if (i >= 0) this.selectedIds.splice(i, 1);
|
||||
else this.selectedIds.push(id);
|
||||
},
|
||||
|
||||
toggleSelectAllVisible() {
|
||||
var list = this.mapHalalMode && this.halalTopEntities.length ? this.halalTopEntities : this.entities;
|
||||
if (!list.length) return;
|
||||
var allSelected = list.every((e) => this.isSelected(e.id));
|
||||
if (allSelected) {
|
||||
list.forEach((e) => {
|
||||
var i = this.selectedIds.indexOf(e.id);
|
||||
if (i >= 0) this.selectedIds.splice(i, 1);
|
||||
});
|
||||
} else {
|
||||
list.forEach((e) => {
|
||||
if (!this.isSelected(e.id)) this.selectedIds.push(e.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
this.selectedIds = [];
|
||||
},
|
||||
|
||||
async setFavorites(ids, favorite) {
|
||||
if (!ids.length) return;
|
||||
this.busy = true;
|
||||
try {
|
||||
await fetch('/api/export-intel/entities/favorites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entity_ids: ids, favorite: favorite }),
|
||||
});
|
||||
this.syncMsg = favorite
|
||||
? ids.length + ' favoriet' + (ids.length > 1 ? 'en' : '') + ' opgeslagen'
|
||||
: 'Favoriet verwijderd';
|
||||
await this.refresh();
|
||||
} catch (e) {
|
||||
this.syncMsg = 'Favoriet mislukt: ' + e.message;
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
async toggleFavorite(id, current, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
await this.setFavorites([id], !current);
|
||||
},
|
||||
|
||||
async favoriteSelection() {
|
||||
await this.setFavorites(this.selectedIds.slice(), true);
|
||||
},
|
||||
|
||||
async unfavoriteSelection() {
|
||||
await this.setFavorites(this.selectedIds.slice(), false);
|
||||
},
|
||||
|
||||
openCrmModal() {
|
||||
if (!this.selectedIds.length) {
|
||||
this.syncMsg = 'Selecteer eerst één of meer rijen (checkbox)';
|
||||
return;
|
||||
}
|
||||
this.crmModalOpen = true;
|
||||
},
|
||||
|
||||
async pushToCrm(ids) {
|
||||
var entityIds = (ids && ids.length) ? ids : this.selectedIds.slice();
|
||||
if (!entityIds.length) return;
|
||||
this.crmPushBusy = true;
|
||||
this.syncMsg = 'CRM import gestart…';
|
||||
try {
|
||||
var r = await fetch('/api/export-intel/crm/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entity_ids: entityIds,
|
||||
create_deals: this.crmCreateDeals,
|
||||
}),
|
||||
}).then((x) => x.json());
|
||||
if (r.error) throw new Error(r.error);
|
||||
var created = r.created || 0;
|
||||
var linked = r.linked_existing || 0;
|
||||
this.syncMsg = 'CRM: ' + created + ' nieuw, ' + linked + ' bestaand gekoppeld';
|
||||
this.crmModalOpen = false;
|
||||
this.selectedIds = [];
|
||||
await this.refresh();
|
||||
if (this.listFocusDetail && entityIds.indexOf(this.listFocusDetail.id) >= 0) {
|
||||
this.listFocusDetail = await this.fetchEntityDetail(this.listFocusDetail.id);
|
||||
}
|
||||
if (this.tab === 'pipeline') await this.loadTabData();
|
||||
} catch (e) {
|
||||
this.syncMsg = 'CRM import mislukt: ' + e.message;
|
||||
}
|
||||
this.crmPushBusy = false;
|
||||
},
|
||||
|
||||
async pushEntityToCrm(id) {
|
||||
this.crmCreateDeals = true;
|
||||
await this.pushToCrm([id]);
|
||||
},
|
||||
|
||||
onFilterCollectionChange() {
|
||||
this.applyMapFilters();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Herman Assistant — browser widget + gedeelde chat/voice logica
|
||||
*/
|
||||
window.HermanAssistant = (function () {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function entityTypeLabel(t) {
|
||||
var m = {
|
||||
distributor: 'Distributeur', wholesaler: 'Groothandel', importer: 'Importeur',
|
||||
logistics: 'Logistiek', restaurant: 'Restaurant', caterer: 'Cateraar',
|
||||
butcher: 'Slager', doner: 'Döner',
|
||||
};
|
||||
return m[t] || t || '—';
|
||||
}
|
||||
|
||||
function newSessionId(prefix) {
|
||||
return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
function baseState(opts) {
|
||||
opts = opts || {};
|
||||
return {
|
||||
channel: opts.channel || 'browser',
|
||||
sessionPrefix: opts.sessionPrefix || 'br',
|
||||
open: false,
|
||||
hidden: false,
|
||||
status: 'idle',
|
||||
statusDetail: '',
|
||||
livePreview: '',
|
||||
liveInterim: false,
|
||||
recording: false,
|
||||
busy: false,
|
||||
speakReply: true,
|
||||
handsFreeMode: false,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
turns: [],
|
||||
manualText: '',
|
||||
pendingAction: null,
|
||||
resultsOpen: false,
|
||||
resultsTitle: '',
|
||||
resultsTotal: 0,
|
||||
resultsEntities: [],
|
||||
resultsOpenUrl: '',
|
||||
webbuilderOpen: false,
|
||||
webbuilderTitle: '',
|
||||
webbuilderProject: '',
|
||||
webbuilderPreview: '',
|
||||
webbuilderAgentsUrl: '/agents',
|
||||
webbuilderNas: '',
|
||||
sessionId: newSessionId(opts.sessionPrefix || 'br'),
|
||||
mediaRecorder: null,
|
||||
chunks: [],
|
||||
stream: null,
|
||||
recognition: null,
|
||||
holdIgnoreClick: false,
|
||||
_resumeTimer: null,
|
||||
_silenceTimer: null,
|
||||
_maxRecordTimer: null,
|
||||
|
||||
initWidget: function () { /* overridden in browserWidget */ },
|
||||
|
||||
toggleOpen: function () {
|
||||
this.open = !this.open;
|
||||
try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
scrollLog: function () {
|
||||
var self = this;
|
||||
this.$nextTick(function () {
|
||||
var log = self.$refs.chatLog;
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
},
|
||||
|
||||
addTurn: function (role, text, meta) {
|
||||
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
|
||||
this.scrollLog();
|
||||
},
|
||||
|
||||
handleHermanResponse: function (h) {
|
||||
if (!h) return;
|
||||
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
|
||||
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
|
||||
if (h.reply) {
|
||||
this.addTurn('agent', h.reply, {
|
||||
agent_label: h.agent_label,
|
||||
delegated: h.delegated_agents,
|
||||
routing_reason: h.routing_reason,
|
||||
});
|
||||
}
|
||||
var actions = h.ui_actions || [];
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
|
||||
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
|
||||
}
|
||||
if (!actions.length && h.webbuilder_preview_url) {
|
||||
this.openWebbuilderModal({
|
||||
type: 'open_webbuilder_build',
|
||||
title: 'Website build — ' + (h.webbuilder_project || 'project'),
|
||||
project: h.webbuilder_project,
|
||||
preview_url: h.webbuilder_preview_url,
|
||||
agents_url: '/agents',
|
||||
});
|
||||
}
|
||||
if (this.speakReply && h.reply && window.speechSynthesis) {
|
||||
var self = this;
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
|
||||
}
|
||||
},
|
||||
|
||||
maybeResumeListening: function (delay) {
|
||||
var self = this;
|
||||
if (!this.handsFreeMode || this.busy || this.recording) return;
|
||||
if (this._resumeTimer) clearTimeout(this._resumeTimer);
|
||||
this._resumeTimer = setTimeout(function () {
|
||||
if (self.handsFreeMode && !self.busy && !self.recording) self.startListen();
|
||||
}, delay || 500);
|
||||
},
|
||||
|
||||
openResultsModal: function (action) {
|
||||
this.resultsTitle = action.title || 'Export Intel';
|
||||
this.resultsEntities = action.entities || [];
|
||||
this.resultsTotal = action.total || this.resultsEntities.length;
|
||||
this.resultsOpenUrl = action.open_url || '/export-intel';
|
||||
this.resultsOpen = true;
|
||||
},
|
||||
|
||||
closeResultsModal: function () { this.resultsOpen = false; },
|
||||
|
||||
openWebbuilderModal: function (action) {
|
||||
this.webbuilderTitle = action.title || 'Website build';
|
||||
this.webbuilderProject = action.project || '';
|
||||
this.webbuilderPreview = action.preview_url || '';
|
||||
this.webbuilderAgentsUrl = action.agents_url || '/agents';
|
||||
this.webbuilderNas = action.nas_path || '';
|
||||
this.webbuilderOpen = true;
|
||||
},
|
||||
|
||||
closeWebbuilderModal: function () { this.webbuilderOpen = false; },
|
||||
|
||||
confirmPending: function () {
|
||||
if (!this.pendingAction || !this.pendingAction.id) return;
|
||||
this.manualText = 'ja';
|
||||
this.sendText(this.pendingAction.id);
|
||||
},
|
||||
|
||||
cancelPending: function () {
|
||||
this.pendingAction = null;
|
||||
this.manualText = 'nee';
|
||||
this.sendText();
|
||||
},
|
||||
|
||||
entityTypeLabel: entityTypeLabel,
|
||||
|
||||
_startRecognition: function () {
|
||||
if (!this.useBrowserPreview || !SpeechRecognition) return;
|
||||
var self = this;
|
||||
try {
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'nl-NL';
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.onresult = function (e) {
|
||||
var interim = '', final = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) final += e.results[i][0].transcript;
|
||||
else interim += e.results[i][0].transcript;
|
||||
}
|
||||
self.liveInterim = !!interim && !final;
|
||||
self.livePreview = final || interim;
|
||||
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
|
||||
};
|
||||
this.recognition.start();
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
_stopRecognition: function () {
|
||||
if (this.recognition) {
|
||||
try { this.recognition.stop(); } catch (e) { /* empty */ }
|
||||
this.recognition = null;
|
||||
}
|
||||
},
|
||||
|
||||
_stopStream: function () {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
this.stream = null;
|
||||
}
|
||||
},
|
||||
|
||||
_scheduleSilenceStop: function () {
|
||||
var self = this;
|
||||
if (this._silenceTimer) clearTimeout(this._silenceTimer);
|
||||
this._silenceTimer = setTimeout(function () {
|
||||
if (self.recording && self.handsFreeMode) self.stopListen();
|
||||
}, 1600);
|
||||
},
|
||||
|
||||
async startListen() {
|
||||
if (this.recording || this.busy) return;
|
||||
var self = this;
|
||||
this.livePreview = '';
|
||||
this.status = 'listening';
|
||||
this.statusDetail = 'Luisteren…';
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.chunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.mediaRecorder.ondataavailable = function (e) {
|
||||
if (e.data && e.data.size) self.chunks.push(e.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = function () {
|
||||
self._stopRecognition();
|
||||
self._stopStream();
|
||||
if (self._silenceTimer) clearTimeout(self._silenceTimer);
|
||||
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
|
||||
var blob = new Blob(self.chunks, { type: 'audio/webm' });
|
||||
self.recording = false;
|
||||
if (blob.size > 0) self.processVoice(blob);
|
||||
else { self.status = 'idle'; self.statusDetail = ''; }
|
||||
};
|
||||
this.mediaRecorder.start(250);
|
||||
this.recording = true;
|
||||
this._startRecognition();
|
||||
if (this.handsFreeMode) {
|
||||
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
|
||||
this._maxRecordTimer = setTimeout(function () {
|
||||
if (self.recording) self.stopListen();
|
||||
}, 18000);
|
||||
}
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast('Microfoon: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
stopListen: function () {
|
||||
if (!this.recording || !this.mediaRecorder) return;
|
||||
this.mediaRecorder.stop();
|
||||
},
|
||||
|
||||
toggleListen: function () {
|
||||
if (this.recording) this.stopListen();
|
||||
else this.startListen();
|
||||
},
|
||||
|
||||
micClass: function () {
|
||||
if (this.recording) return 'is-listening';
|
||||
if (this.busy) return 'is-busy';
|
||||
return '';
|
||||
},
|
||||
|
||||
onMicDown: function () { this.holdIgnoreClick = true; this.startListen(); },
|
||||
onMicUp: function () {
|
||||
if (this.recording && !this.handsFreeMode) this.stopListen();
|
||||
var self = this;
|
||||
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
|
||||
},
|
||||
onMicClick: function () {
|
||||
if (this.holdIgnoreClick) return;
|
||||
this.toggleListen();
|
||||
},
|
||||
|
||||
async processVoice(blob) {
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Whisper + Herman…';
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
|
||||
fd.append('session_id', this.sessionId);
|
||||
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
|
||||
var j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || j.error || 'Voice mislukt');
|
||||
var text = j.text || '';
|
||||
this.livePreview = text;
|
||||
this.manualText = text;
|
||||
if (text) this.addTurn('user', text, { source: 'voice' });
|
||||
this.handleHermanResponse(j.herman || {});
|
||||
this.status = 'idle';
|
||||
this.statusDetail = '';
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
Cockpit.toast(e.message, 'error');
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
async sendText(confirmActionId) {
|
||||
var text = (this.manualText || '').trim();
|
||||
if (!text) return;
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
try {
|
||||
var body = { message: text, channel: this.channel, session_id: this.sessionId };
|
||||
if (confirmActionId) body.confirm_action_id = confirmActionId;
|
||||
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
|
||||
this.addTurn('user', text, { source: 'text' });
|
||||
this.manualText = '';
|
||||
this.handleHermanResponse(r);
|
||||
this.status = 'idle';
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.status = 'error';
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
formatDelegated: function (meta) {
|
||||
var d = (meta && meta.delegated) || [];
|
||||
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
browserWidget: function () {
|
||||
var s = baseState({ channel: 'browser', sessionPrefix: 'br' });
|
||||
s.initWidget = function () {
|
||||
if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true;
|
||||
if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true;
|
||||
try {
|
||||
var raw = localStorage.getItem('herman_assistant_open');
|
||||
if (raw === '1') this.open = true;
|
||||
} catch (e) { /* empty */ }
|
||||
};
|
||||
return s;
|
||||
},
|
||||
chatPage: function () {
|
||||
var s = baseState({ channel: 'browser', sessionPrefix: 'hc' });
|
||||
s.imagePrompt = '';
|
||||
s.generating = false;
|
||||
s.lastImage = '';
|
||||
s.initChat = function () {
|
||||
this.open = true;
|
||||
};
|
||||
s.generateImage = async function () {
|
||||
if (!this.imagePrompt.trim()) return;
|
||||
this.generating = true;
|
||||
try {
|
||||
var r = await Cockpit.api('/api/ai/generate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt: this.imagePrompt, width: 512, height: 512, steps: 15 }),
|
||||
});
|
||||
if (r.proxy_url) {
|
||||
this.lastImage = r.proxy_url;
|
||||
this.addTurn('agent', 'Afbeelding: ' + this.imagePrompt, { agent_label: 'Design', image_url: r.proxy_url });
|
||||
}
|
||||
Cockpit.toast('Afbeelding gegenereerd', 'success');
|
||||
} catch (e) { Cockpit.toast(e.message, 'error'); }
|
||||
this.generating = false;
|
||||
};
|
||||
return s;
|
||||
},
|
||||
entityTypeLabel: entityTypeLabel,
|
||||
};
|
||||
})();
|
||||
|
||||
function hermanBrowserWidget() {
|
||||
return HermanAssistant.browserWidget();
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -17,7 +17,14 @@ window.I18n = (function () {
|
||||
function applyDom() {
|
||||
document.querySelectorAll('[data-i18n]').forEach(function (el) {
|
||||
var k = el.getAttribute('data-i18n');
|
||||
if (k) el.textContent = t(k);
|
||||
if (!k) return;
|
||||
var text = t(k);
|
||||
/* Sidebar nav: never replace whole link (destroys icons) */
|
||||
if (el.classList && el.classList.contains('nav-pill')) {
|
||||
var lbl = el.querySelector('.sidebar-nav-label');
|
||||
if (lbl) { lbl.textContent = text; return; }
|
||||
}
|
||||
el.textContent = text;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
|
||||
var k = el.getAttribute('data-i18n-placeholder');
|
||||
@@ -31,7 +38,7 @@ window.I18n = (function () {
|
||||
}
|
||||
|
||||
async function loadBundle(loc) {
|
||||
var r = await fetch('/static/i18n/' + loc + '.json?v=2');
|
||||
var r = await fetch('/static/i18n/' + loc + '.json?v=3');
|
||||
if (!r.ok) throw new Error('Locale ' + loc + ' not found');
|
||||
strings = await r.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
(function () {
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function pageMatches(page, filters) {
|
||||
const q = (filters.search || '').trim().toLowerCase();
|
||||
const terms = filters.terms || [];
|
||||
const siteId = filters.siteId ? String(filters.siteId) : '';
|
||||
const minWords = parseInt(filters.minWords, 10) || 0;
|
||||
const minHype = parseInt(filters.minHype, 10) || 0;
|
||||
|
||||
if (siteId && String(page.site_id) !== siteId) return false;
|
||||
if ((page.word_count || 0) < minWords) return false;
|
||||
if ((page.hype_score || 0) < minHype) return false;
|
||||
if (filters.changedOnly && !page.recently_changed) return false;
|
||||
|
||||
const hay = [
|
||||
page.site_name,
|
||||
page.title,
|
||||
page.url,
|
||||
page.excerpt,
|
||||
page.content_read,
|
||||
page.meta_description,
|
||||
(page.headings || []).join(' '),
|
||||
(page.signals || []).join(' '),
|
||||
(page.links_sample || []).map(function (l) {
|
||||
return (l.label || '') + ' ' + (l.href || '');
|
||||
}).join(' '),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
if (q && hay.indexOf(q) < 0) return false;
|
||||
|
||||
for (let i = 0; i < terms.length; i++) {
|
||||
if (hay.indexOf(String(terms[i]).toLowerCase()) < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortPages(pages, col, dir) {
|
||||
const mul = dir === 'asc' ? 1 : -1;
|
||||
const copy = pages.slice();
|
||||
copy.sort(function (a, b) {
|
||||
let va;
|
||||
let vb;
|
||||
switch (col) {
|
||||
case 'site_name':
|
||||
va = (a.site_name || '').toLowerCase();
|
||||
vb = (b.site_name || '').toLowerCase();
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'title':
|
||||
va = (a.title || a.url || '').toLowerCase();
|
||||
vb = (b.title || b.url || '').toLowerCase();
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'word_count':
|
||||
return ((a.word_count || 0) - (b.word_count || 0)) * mul;
|
||||
case 'links_count':
|
||||
return ((a.links_count || 0) - (b.links_count || 0)) * mul;
|
||||
case 'crawled_at':
|
||||
va = a.crawled_at || '';
|
||||
vb = b.crawled_at || '';
|
||||
return va < vb ? -mul : va > vb ? mul : 0;
|
||||
case 'hype_score':
|
||||
default:
|
||||
return ((a.hype_score || 0) - (b.hype_score || 0)) * mul;
|
||||
}
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function filterPages(pages, filters) {
|
||||
if (!pages || !pages.length) return [];
|
||||
return pages.filter(function (p) {
|
||||
return pageMatches(p, filters);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightHtml(text, queries) {
|
||||
const raw = text || '';
|
||||
if (!raw) return '<span class="muted">Geen tekst</span>';
|
||||
let html = esc(raw);
|
||||
const list = [];
|
||||
if (queries && queries.length) {
|
||||
queries.forEach(function (q) {
|
||||
if (q && String(q).trim()) list.push(String(q).trim());
|
||||
});
|
||||
}
|
||||
list.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
list.forEach(function (term) {
|
||||
if (term.length < 2) return;
|
||||
const re = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||||
html = html.replace(re, '<mark>$1</mark>');
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function exportCsv(pages) {
|
||||
const rows = [['site', 'title', 'url', 'words', 'hype', 'changed', 'crawled_at', 'excerpt']];
|
||||
pages.forEach(function (p) {
|
||||
rows.push([
|
||||
p.site_name || '',
|
||||
(p.title || '').replace(/"/g, '""'),
|
||||
p.url || '',
|
||||
String(p.word_count || 0),
|
||||
String(p.hype_score || 0),
|
||||
p.recently_changed ? 'yes' : 'no',
|
||||
p.crawled_at || '',
|
||||
(p.excerpt || '').replace(/"/g, '""').slice(0, 500),
|
||||
]);
|
||||
});
|
||||
return rows
|
||||
.map(function (r) {
|
||||
return r.map(function (c) {
|
||||
return '"' + c + '"';
|
||||
}).join(',');
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function downloadCsv(pages, filename) {
|
||||
const csv = exportCsv(pages);
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename || 'parse-analyse.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
window.ParseIntel = {
|
||||
filterPages: filterPages,
|
||||
sortPages: sortPages,
|
||||
highlightHtml: highlightHtml,
|
||||
exportCsv: exportCsv,
|
||||
downloadCsv: downloadCsv,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,422 @@
|
||||
function revenueCockpit() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
savedAt: '',
|
||||
viewMode: 'cockpit',
|
||||
goals: { vision_text: '', horizon_text: '', mid_text: '', tagline: '' },
|
||||
projects: [],
|
||||
dbProjects: [],
|
||||
sheetRows: [],
|
||||
selectedId: null,
|
||||
selectedKey: null,
|
||||
expandedKey: null,
|
||||
meta: {},
|
||||
aggregates: {},
|
||||
filterQ: '',
|
||||
filterStyle: '',
|
||||
filterCategory: '',
|
||||
filterHasMargin: false,
|
||||
agents: ['herman', 'research', 'marketing', 'retail', 'packaging', 'sysops', 'knowledge'],
|
||||
taskForm: { agent_name: 'research', title: '', description: '' },
|
||||
styleFilters: [
|
||||
{ id: 'green', label: 'Live' },
|
||||
{ id: 'red', label: 'Inactief' },
|
||||
{ id: 'orange', label: 'Oranje' },
|
||||
{ id: 'yellow', label: 'Strategisch' },
|
||||
{ id: 'blue', label: 'Blauw' },
|
||||
{ id: 'white', label: 'Neutraal' },
|
||||
],
|
||||
categoryFilters: [
|
||||
{ id: 'deal', label: 'Deal' },
|
||||
{ id: 'initiative', label: 'Initiatief' },
|
||||
{ id: 'strategic', label: 'Strategisch' },
|
||||
],
|
||||
|
||||
get selectedRow() {
|
||||
return this.dbProjects.find((p) => p.id === this.selectedId) || null;
|
||||
},
|
||||
|
||||
get selectedProject() {
|
||||
if (!this.selectedKey) return null;
|
||||
return this.projects.find((p) => this.projectKey(p) === this.selectedKey) || null;
|
||||
},
|
||||
|
||||
async init() {
|
||||
localStorage.setItem('foodlinkk-persona', 'ceo');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
},
|
||||
|
||||
projectKey(p) {
|
||||
return String(p.source_row || '') + '|' + (p.name || '');
|
||||
},
|
||||
|
||||
fmtNum(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '';
|
||||
return Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
|
||||
fmtEuro(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
return '€ ' + Number(n).toLocaleString('nl-NL', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
},
|
||||
|
||||
fmtEuroCompact(n) {
|
||||
if (n == null || n === '' || isNaN(n)) return '—';
|
||||
const v = Number(n);
|
||||
if (v >= 1000000) return '€' + (v / 1000000).toFixed(1) + 'M';
|
||||
if (v >= 1000) return '€' + Math.round(v / 1000) + 'k';
|
||||
return '€' + Math.round(v);
|
||||
},
|
||||
|
||||
fmtDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||
} catch (e) {
|
||||
return iso;
|
||||
}
|
||||
},
|
||||
|
||||
categoryLabel(c) {
|
||||
if (c === 'deal') return 'Deal';
|
||||
if (c === 'strategic') return 'Strategisch';
|
||||
return 'Initiatief';
|
||||
},
|
||||
|
||||
parseNum(text) {
|
||||
const t = String(text || '').trim();
|
||||
if (!t || t === '—') return null;
|
||||
const n = parseFloat(t.replace(/\./g, '').replace(',', '.'));
|
||||
return isNaN(n) ? null : n;
|
||||
},
|
||||
|
||||
filteredProjects() {
|
||||
const q = (this.filterQ || '').trim().toLowerCase();
|
||||
return this.projects.filter((p) => {
|
||||
if (this.filterStyle && (p.row_style || 'white') !== this.filterStyle) return false;
|
||||
if (this.filterCategory && p.category !== this.filterCategory) return false;
|
||||
if (this.filterHasMargin && !p.margin_month) return false;
|
||||
if (q) {
|
||||
const hay = ((p.name || '') + ' ' + (p.next_steps || '')).toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.filterQ = '';
|
||||
this.filterStyle = '';
|
||||
this.filterCategory = '';
|
||||
this.filterHasMargin = false;
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
const t = String(s).replace(/\s+/g, ' ').trim();
|
||||
return t.length <= n ? t : t.slice(0, n).trim() + '…';
|
||||
},
|
||||
|
||||
styleColor(style) {
|
||||
const map = {
|
||||
green: '#22c55e', red: '#ef4444', orange: '#f59e0b',
|
||||
yellow: '#eab308', blue: '#3b82f6', white: '#64748b',
|
||||
};
|
||||
return map[style] || map.white;
|
||||
},
|
||||
|
||||
foodlinkkIndexFrom(list) {
|
||||
const idx = list.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : list.length;
|
||||
},
|
||||
|
||||
visualRows() {
|
||||
const items = this.filteredProjects();
|
||||
const flIdx = this.foodlinkkIndexFrom(items);
|
||||
return items.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let brand = 'cucina';
|
||||
if (i >= flIdx && flIdx < items.length) brand = 'foodlinkk';
|
||||
if (i === 0) logoCell = 'cucina';
|
||||
else if (i === flIdx && flIdx < items.length) logoCell = 'foodlinkk';
|
||||
return { ...p, logoCell, brand };
|
||||
});
|
||||
},
|
||||
|
||||
maxMargin(field) {
|
||||
const vals = this.filteredProjects().map((p) => p[field] || 0);
|
||||
return Math.max(...vals, 1);
|
||||
},
|
||||
|
||||
marginBarPct(p, field) {
|
||||
if (!p[field]) return 0;
|
||||
return Math.min(100, Math.round((p[field] / this.maxMargin(field)) * 100));
|
||||
},
|
||||
|
||||
sharePct(p) {
|
||||
const total = this.aggregates.total_margin_month || 0;
|
||||
if (!p.margin_month || !total) return 0;
|
||||
return Math.min(100, Math.round((p.margin_month / total) * 1000) / 10);
|
||||
},
|
||||
|
||||
donutStyle(p) {
|
||||
const pct = this.sharePct(p);
|
||||
const color = this.styleColor(p.row_style);
|
||||
return 'background:conic-gradient(' + color + ' 0% ' + pct + '%, #2d3748 ' + pct + '% 100%)';
|
||||
},
|
||||
|
||||
toggleExpand(p) {
|
||||
const key = this.projectKey(p);
|
||||
this.expandedKey = this.expandedKey === key ? null : key;
|
||||
this.selectedKey = key;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = p.next_steps || '';
|
||||
},
|
||||
|
||||
selectProject(p) {
|
||||
this.toggleExpand(p);
|
||||
},
|
||||
|
||||
mergeDbIds() {
|
||||
const byName = {};
|
||||
for (const d of this.dbProjects) {
|
||||
const k = (d.name || '').trim().toLowerCase();
|
||||
if (k) byName[k] = d.id;
|
||||
}
|
||||
this.projects = this.projects.map((p) => ({
|
||||
...p,
|
||||
db_id: byName[(p.name || '').trim().toLowerCase()] || null,
|
||||
}));
|
||||
},
|
||||
|
||||
async loadLive() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/live').then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || 'Excel laden mislukt');
|
||||
this.goals = r.goals || {};
|
||||
this.projects = r.projects || [];
|
||||
this.aggregates = {
|
||||
...(r.aggregates || {}),
|
||||
project_count: r.project_count || this.projects.length,
|
||||
};
|
||||
this.meta = {
|
||||
source_file: r.source_file,
|
||||
sheet_name: r.sheet_name,
|
||||
file_mtime: r.file_mtime,
|
||||
parsed_at: r.parsed_at,
|
||||
};
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Excel laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadDbQuiet() {
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.dbProjects = r.projects || [];
|
||||
this.mergeDbIds();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
foodlinkkIndex() {
|
||||
const idx = this.dbProjects.findIndex((p) =>
|
||||
(p.row_style === 'yellow' && /foodlinkk|total earnings|loonkosten/i.test(p.name)) ||
|
||||
/foodlinkk food marketing/i.test(p.name)
|
||||
);
|
||||
return idx >= 0 ? idx : this.dbProjects.length;
|
||||
},
|
||||
|
||||
buildSheetRows() {
|
||||
const flIdx = this.foodlinkkIndex();
|
||||
this.sheetRows = this.dbProjects.map((p, i) => {
|
||||
let logoCell = null;
|
||||
let logoSpan = 1;
|
||||
if (i === 0) {
|
||||
logoCell = 'cucina';
|
||||
logoSpan = flIdx > 0 ? flIdx : this.dbProjects.length;
|
||||
} else if (i === flIdx && flIdx < this.dbProjects.length) {
|
||||
logoCell = 'foodlinkk';
|
||||
logoSpan = this.dbProjects.length - flIdx;
|
||||
}
|
||||
return { ...p, logoCell, logoSpan };
|
||||
});
|
||||
},
|
||||
|
||||
async switchToSheet() {
|
||||
this.viewMode = 'sheet';
|
||||
await this.loadDb();
|
||||
},
|
||||
|
||||
async loadDb() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/dashboard').then((x) => x.json());
|
||||
this.goals = r.stats?.goals || this.goals;
|
||||
this.dbProjects = r.projects || [];
|
||||
this.buildSheetRows();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'DB laden mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
editStart(ev) {
|
||||
const el = ev.target;
|
||||
if (el.classList.contains('rc-cell-num')) {
|
||||
const raw = el.textContent.trim();
|
||||
if (raw) el.textContent = raw.replace(/\./g, '').replace(/,(\d+)$/, '.$1');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
},
|
||||
|
||||
markSaved() {
|
||||
const now = new Date();
|
||||
this.savedAt = now.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
},
|
||||
|
||||
async saveGoalsField(field, el) {
|
||||
const val = el.innerText.trim();
|
||||
if (this.goals[field] === val) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/goals', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: val }),
|
||||
}).then((x) => x.json());
|
||||
this.goals = r.goals || this.goals;
|
||||
this.markSaved();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
async saveProjectField(row, field, el) {
|
||||
let val;
|
||||
if (field === 'margin_month' || field === 'margin_year') {
|
||||
val = this.parseNum(el.innerText);
|
||||
el.textContent = this.fmtNum(val);
|
||||
} else {
|
||||
val = el.innerText.trim();
|
||||
}
|
||||
if (String(row[field] ?? '') === String(val ?? '')) return;
|
||||
|
||||
const body = { [field]: val };
|
||||
if (field === 'margin_month' && val != null) {
|
||||
body.margin_year = val * 12;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/projects/' + row.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((x) => x.json());
|
||||
const updated = r.project || {};
|
||||
Object.assign(row, updated);
|
||||
row.row_style = updated.row_style || row.row_style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === row.id);
|
||||
if (pi >= 0) this.dbProjects[pi] = { ...this.dbProjects[pi], ...updated };
|
||||
if (field === 'margin_month' && body.margin_year != null) {
|
||||
const yearCell = el.nextElementSibling;
|
||||
if (yearCell) yearCell.textContent = this.fmtNum(body.margin_year);
|
||||
row.margin_year = body.margin_year;
|
||||
}
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Opslaan mislukt', 'error');
|
||||
}
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
selectRow(row) {
|
||||
this.selectedId = row.id;
|
||||
this.taskForm.title = '';
|
||||
this.taskForm.description = row.next_steps || '';
|
||||
},
|
||||
|
||||
async setRowStyle(style) {
|
||||
if (!this.selectedRow) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + this.selectedRow.id, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ row_style: style }),
|
||||
});
|
||||
this.selectedRow.row_style = style;
|
||||
const pi = this.dbProjects.findIndex((p) => p.id === this.selectedRow.id);
|
||||
if (pi >= 0) this.dbProjects[pi].row_style = style;
|
||||
this.buildSheetRows();
|
||||
this.markSaved();
|
||||
await this.loadLive();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Kleur wijzigen mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async assignTask() {
|
||||
const row = this.viewMode === 'sheet' ? this.selectedRow : this.selectedProject;
|
||||
const pid = row?.id || row?.db_id;
|
||||
if (!pid || !this.taskForm.title.trim()) return;
|
||||
try {
|
||||
await fetch('/api/revenue-cockpit/projects/' + pid + '/assign-task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_name: this.taskForm.agent_name,
|
||||
title: this.taskForm.title.trim(),
|
||||
description: this.taskForm.description,
|
||||
delegate_herman: true,
|
||||
}),
|
||||
});
|
||||
if (window.Cockpit) Cockpit.toast('Taak → ' + this.taskForm.agent_name, 'success');
|
||||
this.taskForm.title = '';
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast(e.message || 'Taak mislukt', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async takeSnapshot() {
|
||||
await fetch('/api/revenue-cockpit/snapshot', { method: 'POST' });
|
||||
if (window.Cockpit) Cockpit.toast('Snapshot opgeslagen', 'success');
|
||||
},
|
||||
|
||||
async importExcel() {
|
||||
if (!confirm('Sync DB uit Succes Sheet .xlsx — alle rijen worden vervangen. Doorgaan?')) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const r = await fetch('/api/revenue-cockpit/import-from-excel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ replace: true }),
|
||||
}).then((x) => x.json());
|
||||
if (window.Cockpit) Cockpit.toast('Import: ' + (r.projects_imported || 0) + ' rijen', 'success');
|
||||
await this.loadLive();
|
||||
await this.loadDbQuiet();
|
||||
if (this.viewMode === 'sheet') await this.loadDb();
|
||||
} catch (e) {
|
||||
if (window.Cockpit) Cockpit.toast('Import mislukt', 'error');
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* Voice Live — push-to-talk, hands-free, export results popup, Herman bevestiging
|
||||
*/
|
||||
window.VoiceLive = (function () {
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
function nowTime() {
|
||||
return new Date().toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function entityTypeLabel(t) {
|
||||
var m = {
|
||||
distributor: 'Distributeur',
|
||||
wholesaler: 'Groothandel',
|
||||
importer: 'Importeur',
|
||||
logistics: 'Logistiek',
|
||||
restaurant: 'Restaurant',
|
||||
caterer: 'Cateraar',
|
||||
butcher: 'Slager',
|
||||
doner: 'Döner',
|
||||
};
|
||||
return m[t] || t || '—';
|
||||
}
|
||||
|
||||
return {
|
||||
page: function () {
|
||||
return {
|
||||
status: 'idle',
|
||||
statusDetail: 'Houd de microfoon ingedrukt en spreek',
|
||||
livePreview: '',
|
||||
liveInterim: false,
|
||||
recording: false,
|
||||
busy: false,
|
||||
conversationMode: true,
|
||||
handsFreeMode: false,
|
||||
speakReply: true,
|
||||
useBrowserPreview: !!SpeechRecognition,
|
||||
pipeline: [],
|
||||
feed: [],
|
||||
turns: [],
|
||||
manualText: '',
|
||||
holdIgnoreClick: false,
|
||||
sessionId: 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
|
||||
pendingAction: null,
|
||||
resultsOpen: false,
|
||||
resultsTitle: '',
|
||||
resultsTotal: 0,
|
||||
resultsEntities: [],
|
||||
resultsOpenUrl: '',
|
||||
webbuilderOpen: false,
|
||||
webbuilderTitle: '',
|
||||
webbuilderProject: '',
|
||||
webbuilderPreview: '',
|
||||
webbuilderAgentsUrl: '/agents',
|
||||
webbuilderNas: '',
|
||||
mediaRecorder: null,
|
||||
chunks: [],
|
||||
stream: null,
|
||||
recognition: null,
|
||||
agentEs: null,
|
||||
_resumeTimer: null,
|
||||
_silenceTimer: null,
|
||||
_maxRecordTimer: null,
|
||||
|
||||
init: function () {
|
||||
var self = this;
|
||||
this.connectAgentFeed();
|
||||
window.addEventListener('keydown', function (e) {
|
||||
if (e.code === 'Space' && !e.target.matches('input, textarea') && !self.recording && !self.busy) {
|
||||
e.preventDefault();
|
||||
self.startListen();
|
||||
}
|
||||
});
|
||||
window.addEventListener('keyup', function (e) {
|
||||
if (e.code === 'Space' && self.recording && !e.target.matches('input, textarea')) {
|
||||
e.preventDefault();
|
||||
self.stopListen();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
destroy: function () {
|
||||
if (this._resumeTimer) clearTimeout(this._resumeTimer);
|
||||
if (this._silenceTimer) clearTimeout(this._silenceTimer);
|
||||
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
|
||||
if (this.agentEs) {
|
||||
this.agentEs.close();
|
||||
this.agentEs = null;
|
||||
}
|
||||
this._stopRecognition();
|
||||
this._stopStream();
|
||||
},
|
||||
|
||||
connectAgentFeed: function () {
|
||||
var self = this;
|
||||
if (typeof EventSource === 'undefined') return;
|
||||
try {
|
||||
this.agentEs = new EventSource('/api/agents/live/stream');
|
||||
this.agentEs.onmessage = function (ev) {
|
||||
try {
|
||||
var data = JSON.parse(ev.data);
|
||||
if (!data || data.type === 'connected') return;
|
||||
var ch = (data.channel || '').toLowerCase();
|
||||
var agent = (data.agent || data.agent_name || '').toLowerCase();
|
||||
if (ch !== 'voice' && agent !== 'herman' && agent !== 'voice' && agent !== 'sourcing') return;
|
||||
self.feed.unshift({
|
||||
at: nowTime(),
|
||||
agent: data.agent || data.agent_name || 'agent',
|
||||
message: data.message || data.title || '',
|
||||
});
|
||||
if (self.feed.length > 40) self.feed.pop();
|
||||
} catch (e) { /* empty */ }
|
||||
};
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
pushPipeline: function (entries) {
|
||||
if (!entries || !entries.length) return;
|
||||
this.pipeline = this.pipeline.concat(entries);
|
||||
if (this.pipeline.length > 60) this.pipeline = this.pipeline.slice(-60);
|
||||
},
|
||||
|
||||
addTurn: function (role, text, meta) {
|
||||
this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() });
|
||||
this.$nextTick(function () {
|
||||
var log = document.getElementById('voice-chat-log');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
},
|
||||
|
||||
handleHermanResponse: function (h) {
|
||||
if (!h) return;
|
||||
this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null);
|
||||
if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null;
|
||||
|
||||
if (h.reply) {
|
||||
this.addTurn('agent', h.reply, {
|
||||
agent_label: h.agent_label,
|
||||
delegated: h.delegated_agents,
|
||||
routing_reason: h.routing_reason,
|
||||
needs_confirmation: h.needs_confirmation,
|
||||
});
|
||||
}
|
||||
|
||||
var actions = h.ui_actions || [];
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
if (actions[i].type === 'show_export_results') this.openResultsModal(actions[i]);
|
||||
if (actions[i].type === 'open_webbuilder_build') this.openWebbuilderModal(actions[i]);
|
||||
}
|
||||
if (!actions.length && h.webbuilder_preview_url) {
|
||||
this.openWebbuilderModal({
|
||||
type: 'open_webbuilder_build',
|
||||
title: 'Website build — ' + (h.webbuilder_project || 'project'),
|
||||
project: h.webbuilder_project,
|
||||
preview_url: h.webbuilder_preview_url,
|
||||
agents_url: '/agents',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.speakReply && h.reply && window.speechSynthesis) {
|
||||
var self = this;
|
||||
window.speechSynthesis.cancel();
|
||||
var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800));
|
||||
u.lang = 'nl-NL';
|
||||
u.onend = function () { self.maybeResumeListening(300); };
|
||||
window.speechSynthesis.speak(u);
|
||||
} else {
|
||||
this.maybeResumeListening(h.needs_confirmation ? 800 : 500);
|
||||
}
|
||||
},
|
||||
|
||||
maybeResumeListening: function (delay) {
|
||||
var self = this;
|
||||
if (!this.handsFreeMode || this.busy || this.recording) return;
|
||||
if (this._resumeTimer) clearTimeout(this._resumeTimer);
|
||||
this._resumeTimer = setTimeout(function () {
|
||||
if (self.handsFreeMode && !self.busy && !self.recording && self.status !== 'error') {
|
||||
self.statusDetail = 'Hands-free — luisteren…';
|
||||
self.startListen();
|
||||
}
|
||||
}, delay || 500);
|
||||
},
|
||||
|
||||
openResultsModal: function (action) {
|
||||
this.resultsTitle = action.title || 'Export Intel resultaten';
|
||||
this.resultsEntities = action.entities || [];
|
||||
this.resultsTotal = action.total || this.resultsEntities.length;
|
||||
this.resultsOpenUrl = action.open_url || '/export-intel';
|
||||
this.resultsOpen = true;
|
||||
this.pushPipeline([{
|
||||
phase: 'ui',
|
||||
label: 'Popup geopend',
|
||||
detail: this.resultsTotal + ' resultaten',
|
||||
status: 'done',
|
||||
at: new Date().toISOString(),
|
||||
}]);
|
||||
},
|
||||
|
||||
closeResultsModal: function () {
|
||||
this.resultsOpen = false;
|
||||
},
|
||||
|
||||
openWebbuilderModal: function (action) {
|
||||
this.webbuilderTitle = action.title || 'Website build';
|
||||
this.webbuilderProject = action.project || '';
|
||||
this.webbuilderPreview = action.preview_url || '';
|
||||
this.webbuilderAgentsUrl = action.agents_url || '/agents';
|
||||
this.webbuilderNas = action.nas_path || '';
|
||||
this.webbuilderOpen = true;
|
||||
this.pushPipeline([{
|
||||
phase: 'ui',
|
||||
label: 'Agy build gestart',
|
||||
detail: this.webbuilderProject,
|
||||
status: 'running',
|
||||
at: new Date().toISOString(),
|
||||
}]);
|
||||
},
|
||||
|
||||
closeWebbuilderModal: function () {
|
||||
this.webbuilderOpen = false;
|
||||
},
|
||||
|
||||
confirmPending: function () {
|
||||
if (!this.pendingAction || !this.pendingAction.id) return;
|
||||
var id = this.pendingAction.id;
|
||||
this.manualText = 'ja';
|
||||
this.sendManual(id);
|
||||
},
|
||||
|
||||
cancelPending: function () {
|
||||
this.pendingAction = null;
|
||||
this.manualText = 'nee';
|
||||
this.sendManual();
|
||||
},
|
||||
|
||||
entityTypeLabel: entityTypeLabel,
|
||||
|
||||
_startRecognition: function () {
|
||||
if (!this.useBrowserPreview || !SpeechRecognition) return;
|
||||
var self = this;
|
||||
try {
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'nl-NL';
|
||||
this.recognition.interimResults = true;
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.onresult = function (e) {
|
||||
var interim = '';
|
||||
var final = '';
|
||||
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||
if (e.results[i].isFinal) final += e.results[i][0].transcript;
|
||||
else interim += e.results[i][0].transcript;
|
||||
}
|
||||
self.liveInterim = !!interim && !final;
|
||||
self.livePreview = final || interim;
|
||||
if (final && self.handsFreeMode && self.recording) self._scheduleSilenceStop();
|
||||
};
|
||||
this.recognition.onerror = function () { /* optional */ };
|
||||
this.recognition.start();
|
||||
} catch (e) { /* empty */ }
|
||||
},
|
||||
|
||||
_stopRecognition: function () {
|
||||
if (this.recognition) {
|
||||
try { this.recognition.stop(); } catch (e) { /* empty */ }
|
||||
this.recognition = null;
|
||||
}
|
||||
},
|
||||
|
||||
_stopStream: function () {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(function (t) { t.stop(); });
|
||||
this.stream = null;
|
||||
}
|
||||
},
|
||||
|
||||
_scheduleSilenceStop: function () {
|
||||
var self = this;
|
||||
if (this._silenceTimer) clearTimeout(this._silenceTimer);
|
||||
this._silenceTimer = setTimeout(function () {
|
||||
if (self.recording && self.handsFreeMode) self.stopListen();
|
||||
}, 1600);
|
||||
},
|
||||
|
||||
async startListen() {
|
||||
if (this.recording || this.busy) return;
|
||||
var self = this;
|
||||
if (this._resumeTimer) clearTimeout(this._resumeTimer);
|
||||
this.livePreview = '';
|
||||
this.liveInterim = false;
|
||||
if (!this.handsFreeMode) this.pipeline = [];
|
||||
this.status = 'listening';
|
||||
this.statusDetail = this.handsFreeMode
|
||||
? 'Hands-free — spreek je vraag'
|
||||
: 'Spreek nu — laat los om te versturen';
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.chunks = [];
|
||||
this.mediaRecorder = new MediaRecorder(this.stream);
|
||||
this.mediaRecorder.ondataavailable = function (e) {
|
||||
if (e.data && e.data.size) self.chunks.push(e.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = function () {
|
||||
self._stopRecognition();
|
||||
self._stopStream();
|
||||
if (self._silenceTimer) clearTimeout(self._silenceTimer);
|
||||
if (self._maxRecordTimer) clearTimeout(self._maxRecordTimer);
|
||||
var blob = new Blob(self.chunks, { type: 'audio/webm' });
|
||||
self.recording = false;
|
||||
if ((self.conversationMode || self.handsFreeMode) && blob.size > 0) self.processTurn(blob);
|
||||
else if (blob.size > 0) {
|
||||
self.status = 'idle';
|
||||
self.statusDetail = 'Opname klaar';
|
||||
}
|
||||
};
|
||||
this.mediaRecorder.start(250);
|
||||
this.recording = true;
|
||||
this._startRecognition();
|
||||
if (this.handsFreeMode) {
|
||||
var self = this;
|
||||
if (this._maxRecordTimer) clearTimeout(this._maxRecordTimer);
|
||||
this._maxRecordTimer = setTimeout(function () {
|
||||
if (self.recording) self.stopListen();
|
||||
}, 18000);
|
||||
}
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = 'Microfoon: ' + e.message;
|
||||
Cockpit.toast(this.statusDetail, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
stopListen() {
|
||||
if (!this.recording || !this.mediaRecorder) return;
|
||||
this.mediaRecorder.stop();
|
||||
},
|
||||
|
||||
toggleListen() {
|
||||
if (this.recording) this.stopListen();
|
||||
else this.startListen();
|
||||
},
|
||||
|
||||
toggleHandsFree() {
|
||||
if (this.handsFreeMode && !this.recording && !this.busy) {
|
||||
Cockpit.toast('Hands-free aan — ik luister na elk antwoord opnieuw', 'success');
|
||||
this.startListen();
|
||||
} else if (!this.handsFreeMode && this.recording) {
|
||||
this.stopListen();
|
||||
}
|
||||
},
|
||||
|
||||
onHandsFreeChange() {
|
||||
if (this.handsFreeMode) this.toggleHandsFree();
|
||||
else if (this.recording) this.stopListen();
|
||||
},
|
||||
|
||||
async processTurn(blob) {
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Whisper + Herman…';
|
||||
this.pushPipeline([{
|
||||
phase: 'capture',
|
||||
label: 'Audio verstuurd',
|
||||
detail: Math.round(blob.size / 1024) + ' KB',
|
||||
status: 'done',
|
||||
at: new Date().toISOString(),
|
||||
}]);
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('file', new File([blob], 'live.webm', { type: 'audio/webm' }));
|
||||
fd.append('session_id', this.sessionId);
|
||||
var r = await fetch('/api/voice/turn', { method: 'POST', body: fd });
|
||||
var j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || j.error || 'Voice turn mislukt');
|
||||
|
||||
this.pushPipeline(j.pipeline || []);
|
||||
var text = j.text || '';
|
||||
this.livePreview = text;
|
||||
this.liveInterim = false;
|
||||
this.manualText = text;
|
||||
if (text) this.addTurn('user', text, { source: 'whisper' });
|
||||
|
||||
var h = j.herman || {};
|
||||
this.handleHermanResponse(h);
|
||||
|
||||
this.status = 'idle';
|
||||
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar — spreek opnieuw';
|
||||
if (!h.needs_confirmation && h.reply) Cockpit.toast('Herman antwoordde', 'success');
|
||||
} catch (e) {
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
this.pushPipeline([{ phase: 'error', label: 'Fout', detail: e.message, status: 'error', at: new Date().toISOString() }]);
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.maybeResumeListening(1500);
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
async sendManual(confirmActionId) {
|
||||
var text = (this.manualText || '').trim();
|
||||
if (!text) return;
|
||||
this.busy = true;
|
||||
this.status = 'processing';
|
||||
this.statusDetail = 'Herman denkt na…';
|
||||
var confirmId = confirmActionId || (this.pendingAction && this.pendingAction.id) || null;
|
||||
if (confirmId && (text.toLowerCase() === 'ja' || confirmActionId)) {
|
||||
/* confirm via button passes id explicitly */
|
||||
}
|
||||
this.pushPipeline([{ phase: 'herman', label: 'Tekst naar Herman', detail: text.slice(0, 120), status: 'running', at: new Date().toISOString() }]);
|
||||
try {
|
||||
var body = { message: text, channel: 'voice', session_id: this.sessionId };
|
||||
if (confirmActionId) body.confirm_action_id = confirmActionId;
|
||||
var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) });
|
||||
if (this.pipeline.length) this.pipeline[this.pipeline.length - 1].status = 'done';
|
||||
this.addTurn('user', text, { source: 'typed' });
|
||||
this.handleHermanResponse(r);
|
||||
this.status = 'idle';
|
||||
this.statusDetail = this.handsFreeMode ? 'Hands-free actief' : 'Klaar';
|
||||
} catch (e) {
|
||||
Cockpit.toast(e.message, 'error');
|
||||
this.status = 'error';
|
||||
this.statusDetail = e.message;
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
clearSession() {
|
||||
this.turns = [];
|
||||
this.pipeline = [];
|
||||
this.feed = [];
|
||||
this.livePreview = '';
|
||||
this.manualText = '';
|
||||
this.pendingAction = null;
|
||||
this.resultsOpen = false;
|
||||
this.sessionId = 'vl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
|
||||
this.status = 'idle';
|
||||
this.statusDetail = 'Sessie gewist';
|
||||
},
|
||||
|
||||
micClass() {
|
||||
if (this.recording) return 'is-listening';
|
||||
if (this.busy) return 'is-busy';
|
||||
if (this.handsFreeMode) return 'is-handsfree';
|
||||
return '';
|
||||
},
|
||||
|
||||
onMicDown() { this.holdIgnoreClick = true; this.startListen(); },
|
||||
onMicUp() {
|
||||
if (this.recording && !this.handsFreeMode) this.stopListen();
|
||||
var self = this;
|
||||
setTimeout(function () { self.holdIgnoreClick = false; }, 250);
|
||||
},
|
||||
onMicClick() {
|
||||
if (this.holdIgnoreClick) return;
|
||||
this.toggleListen();
|
||||
},
|
||||
|
||||
formatDelegated(meta) {
|
||||
var d = (meta && meta.delegated) || [];
|
||||
return d.filter(function (a) { return String(a).toLowerCase() !== 'herman'; }).join(', ');
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Foodlinkk Command Center",
|
||||
"name": "Cucina · Foodlinkk Command Center",
|
||||
"short_name": "Foodlinkk",
|
||||
"description": "Herman CEO dashboard · CRM · Retail · Marketing",
|
||||
"description": "CEO Business dashboard · CTO Tech dashboard · CRM · Retail",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
|
||||
+15
-43
@@ -1,46 +1,18 @@
|
||||
/* Foodlinkk PWA — light cache for shell */
|
||||
const CACHE = 'foodlinkk-v1';
|
||||
const ASSETS = [
|
||||
'/',
|
||||
'/static/css/palantir-theme.css',
|
||||
'/static/css/pulse-theme.css',
|
||||
'/static/css/mobile.css',
|
||||
'/static/css/vertical-tabs.css',
|
||||
'/static/js/cockpit.js',
|
||||
'/static/js/live-pulse.js',
|
||||
'/static/icons/app-icon.svg',
|
||||
'/static/manifest.json',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then((c) => c.addAll(ASSETS).catch(() => {}))
|
||||
);
|
||||
/* PWA cache disabled — clears legacy caches and unregisters itself */
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
if (e.request.method !== 'GET') return;
|
||||
const url = new URL(e.request.url);
|
||||
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then((r) => {
|
||||
if (r.ok && url.pathname.startsWith('/static/')) {
|
||||
const clone = r.clone();
|
||||
caches.open(CACHE).then((c) => c.put(e.request, clone));
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(() => caches.match(e.request).then((m) => m || caches.match('/')))
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
/* Do not intercept fetches — always use network */
|
||||
self.addEventListener('fetch', () => {});
|
||||
|
||||
Reference in New Issue
Block a user