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>
340 lines
9.2 KiB
Vue
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 >>
|
|
</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>
|