Files
innovatieplatform/resources/js/Components/MetroMap/NodePreview.vue
znetsixe f4ec49254a Full sweep: fix broken features, redesign NodePreview, wire AI service
FIXES (from comprehensive audit):
- NodePreview: complete rewrite — 380px left panel with document
  summaries, commitment list, phase track visualization, scrollable.
  Fixed children count bug (was showing [object Object]).
  Slides in from left (not right) to not overlap branch handles.
- CommitmentForm: added required validation on eigenaar_id field
- MetroMap: wired custom metro node creation with form + POST /metro-nodes
- MetroMap: removed dead handleCliCommand console.log
- MetroMap: added metro node creation modal (naam + beschrijving)

NEW — AI Service integration:
- ai-service/main.py: real Anthropic API integration via httpx
  - Reads ANTHROPIC_API_KEY from env, uses claude-haiku-4-5-20251001
  - /api/chat fetches project context from PostgreSQL (docs, commitments)
  - /api/summarize sends content to Claude for summarization
  - /api/search does basic text search on documents + kennis_artikelen
- AiController.php: Laravel proxy for /api/ai/chat → ai-service
- CliBar.vue: complete rewrite with async API calls, processing state,
  error handling, conversation history, auto-scroll
  - Receives projectId prop for context-scoped AI queries
  - Shows "denken..." animation while waiting for response
- docker-compose.yml: passes ANTHROPIC_API_KEY to ai-service container
- config/services.php: ai service URL configuration

To activate AI: set ANTHROPIC_API_KEY in .env and rebuild ai-service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:07:51 +02:00

340 lines
9.2 KiB
Vue

<script setup>
import { computed } from 'vue'
const props = defineProps({
node: { type: Object, default: null },
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'zoom-in'])
const childrenCount = computed(() => {
if (!props.node?.children) return 0
return props.node.children.nodes?.length ?? 0
})
const hasChildren = computed(() => childrenCount.value > 0 || !!props.node?.children)
/** Extract document summaries from children data (dim 2 document line) */
const documentNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'document')
})
/** Extract commitment summaries from children data */
const commitmentNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'commitment')
})
/** Extract phase nodes */
const phaseNodes = computed(() => {
if (!props.node?.children?.nodes) return []
return props.node.children.nodes.filter(n => n.entityType === 'fase')
})
</script>
<template>
<Transition name="slide">
<div v-if="visible && node" class="node-preview">
<div class="preview-header">
<h2 class="preview-title">{{ node.name }}</h2>
<button @click="emit('close')" class="close-btn">[X]</button>
</div>
<div class="preview-meta">
<span v-if="node.status" class="status-badge" :class="node.status">
{{ node.status }}
</span>
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
<span v-if="node.badge" class="badge-text">{{ node.badge }}</span>
</div>
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
<!-- Phase summary -->
<div v-if="phaseNodes.length" class="preview-section">
<h3 class="section-title">Levenscyclus</h3>
<div class="phase-track">
<span
v-for="phase in phaseNodes"
:key="phase.id"
class="phase-dot"
:class="phase.status"
:title="phase.name"
>{{ phase.name }}</span>
</div>
</div>
<!-- Commitment summary -->
<div v-if="commitmentNodes.length" class="preview-section">
<h3 class="section-title">Commitments ({{ commitmentNodes.length }})</h3>
<div v-for="c in commitmentNodes.slice(0, 4)" :key="c.id" class="summary-item">
<span class="item-status" :class="c.status"></span>
<span class="item-text">{{ c.name }}</span>
<span v-if="c.badge" class="item-badge">{{ c.badge }}</span>
</div>
<div v-if="commitmentNodes.length > 4" class="more-hint">
+{{ commitmentNodes.length - 4 }} meer...
</div>
</div>
<!-- Document summary -->
<div v-if="documentNodes.length" class="preview-section">
<h3 class="section-title">Documenten ({{ documentNodes.length }})</h3>
<div v-for="d in documentNodes.slice(0, 4)" :key="d.id" class="summary-item">
<span class="item-icon">📄</span>
<span class="item-text">{{ d.name }}</span>
<span v-if="d.badge" class="item-badge">{{ d.badge }}</span>
</div>
<div v-if="documentNodes.length > 4" class="more-hint">
+{{ documentNodes.length - 4 }} meer...
</div>
</div>
<!-- Children indicator -->
<div v-if="hasChildren && !phaseNodes.length && !commitmentNodes.length && !documentNodes.length" class="preview-section">
<div class="children-label">Bevat {{ childrenCount }} items</div>
</div>
<div v-if="!hasChildren" class="preview-hint">
Rechts-klik "Add dimension" om dieper niveau toe te voegen
</div>
<button v-if="hasChildren" @click="emit('zoom-in', node)" class="zoom-btn">
ZOOM IN &gt;&gt;
</button>
</div>
</Transition>
</template>
<style scoped>
.node-preview {
position: fixed;
left: 16px;
top: 60px;
width: 380px;
max-height: calc(100vh - 140px);
overflow-y: auto;
background: rgba(22, 33, 62, 0.95);
border: 1px solid rgba(0, 210, 255, 0.3);
border-radius: 6px;
padding: 20px;
z-index: 60;
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
backdrop-filter: blur(8px);
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.preview-title {
font-family: 'VT323', monospace;
font-size: 22px;
color: #00d2ff;
margin: 0;
line-height: 1.2;
}
.close-btn {
font-family: 'VT323', monospace;
color: #8892b0;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
flex-shrink: 0;
}
.close-btn:hover {
color: #e94560;
}
.preview-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
}
.status-badge {
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.pilot, .status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
.status-badge.evaluatie, .status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
.status-badge.verkenning, .status-badge.concept, .status-badge.experiment { background: rgba(123, 104, 238, 0.15); color: #7b68ee; }
.status-badge.geparkeerd { background: rgba(255, 217, 61, 0.15); color: #ffd93d; }
.status-badge.gestopt { background: rgba(233, 69, 96, 0.15); color: #e94560; }
.status-badge.overdracht_bouwen { background: rgba(0, 255, 136, 0.1); color: #6bcb77; }
.owner { color: #8892b0; }
.badge-text { color: #7b68ee; }
.preview-desc {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: #8892b0;
margin-top: 12px;
line-height: 1.5;
}
/* Sections */
.preview-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(0, 210, 255, 0.1);
}
.section-title {
font-family: 'VT323', monospace;
font-size: 14px;
color: #00d2ff;
margin: 0 0 8px 0;
letter-spacing: 0.1em;
text-transform: uppercase;
}
/* Phase track */
.phase-track {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.phase-dot {
font-family: 'VT323', monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 2px;
background: rgba(136, 146, 176, 0.1);
color: #8892b0;
border: 1px solid rgba(136, 146, 176, 0.2);
}
.phase-dot.afgerond, .phase-dot.completed {
background: rgba(0, 255, 136, 0.1);
color: #00ff88;
border-color: rgba(0, 255, 136, 0.3);
}
.phase-dot.actief, .phase-dot.active {
background: rgba(0, 210, 255, 0.15);
color: #00d2ff;
border-color: rgba(0, 210, 255, 0.4);
box-shadow: 0 0 6px rgba(0, 210, 255, 0.2);
}
/* Summary items */
.summary-item {
display: flex;
align-items: baseline;
gap: 6px;
padding: 3px 0;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #8892b0;
}
.item-status {
font-size: 8px;
flex-shrink: 0;
}
.item-status.open, .item-status.in_uitvoering { color: #00d2ff; }
.item-status.afgerond { color: #00ff88; }
.item-status.verlopen { color: #e94560; }
.item-icon {
font-size: 10px;
flex-shrink: 0;
}
.item-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-badge {
font-family: 'VT323', monospace;
font-size: 11px;
color: #7b68ee;
flex-shrink: 0;
}
.more-hint {
font-family: 'VT323', monospace;
font-size: 12px;
color: #8892b0;
opacity: 0.6;
padding-top: 4px;
}
.children-label {
font-family: 'VT323', monospace;
font-size: 14px;
color: #7b68ee;
}
.preview-hint {
margin-top: 12px;
font-family: 'VT323', monospace;
font-size: 12px;
color: #8892b0;
opacity: 0.5;
}
.zoom-btn {
margin-top: 16px;
width: 100%;
padding: 10px;
background: rgba(0, 210, 255, 0.1);
border: 1px solid #00d2ff;
color: #00d2ff;
font-family: 'VT323', monospace;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
letter-spacing: 0.1em;
}
.zoom-btn:hover {
background: rgba(0, 210, 255, 0.2);
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
text-shadow: 0 0 8px rgba(0, 210, 255, 0.5);
}
/* Scrollbar styling */
.node-preview::-webkit-scrollbar {
width: 4px;
}
.node-preview::-webkit-scrollbar-track {
background: transparent;
}
.node-preview::-webkit-scrollbar-thumb {
background: rgba(0, 210, 255, 0.2);
border-radius: 2px;
}
.slide-enter-active, .slide-leave-active {
transition: all 0.25s ease;
}
.slide-enter-from, .slide-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>