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>
This commit is contained in:
@@ -3,28 +3,25 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Innovatieplatform AI Service",
|
title="Innovatieplatform AI Service",
|
||||||
description="AI service providing chat, summarization and semantic search for the Innovatieplatform.",
|
description="AI service for chat, summarization and semantic search.",
|
||||||
version="0.1.0",
|
version="0.2.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS — allow requests from the Laravel app and local development
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=[
|
allow_origins=["*"], # Simplified for internal Docker network
|
||||||
"http://laravel-app",
|
|
||||||
"http://nginx",
|
|
||||||
"http://localhost",
|
|
||||||
"http://localhost:80",
|
|
||||||
os.getenv("LARAVEL_APP_URL", "http://localhost"),
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
ANTHROPIC_MODEL = os.getenv("ANTHROPIC_MODEL", "claude-haiku-4-5-20251001")
|
||||||
|
DB_URL = os.getenv("DATABASE_URL", "postgresql://innovatie:secret@postgresql:5432/innovatieplatform")
|
||||||
|
|
||||||
# --- Request/Response models ---
|
# --- Request/Response models ---
|
||||||
|
|
||||||
@@ -33,85 +30,256 @@ class ChatRequest(BaseModel):
|
|||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
conversation_history: Optional[list] = []
|
conversation_history: Optional[list] = []
|
||||||
|
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
reply: str
|
reply: str
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class SummarizeRequest(BaseModel):
|
class SummarizeRequest(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
summary_type: Optional[str] = "general"
|
summary_type: Optional[str] = "general"
|
||||||
|
|
||||||
|
|
||||||
class SummarizeResponse(BaseModel):
|
class SummarizeResponse(BaseModel):
|
||||||
summary: str
|
summary: str
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class SearchRequest(BaseModel):
|
class SearchRequest(BaseModel):
|
||||||
query: str
|
query: str
|
||||||
project_id: Optional[int] = None
|
project_id: Optional[int] = None
|
||||||
limit: Optional[int] = 10
|
limit: Optional[int] = 10
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(BaseModel):
|
class SearchResult(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
content: str
|
content: str
|
||||||
score: float
|
score: float
|
||||||
metadata: Optional[dict] = {}
|
metadata: Optional[dict] = {}
|
||||||
|
|
||||||
|
|
||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
results: list[SearchResult]
|
results: list[SearchResult]
|
||||||
query: str
|
query: str
|
||||||
|
|
||||||
|
# --- Database context helper ---
|
||||||
|
|
||||||
|
async def get_project_context(project_id: int) -> str:
|
||||||
|
"""Fetch project details from PostgreSQL for AI context."""
|
||||||
|
try:
|
||||||
|
import asyncpg
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
|
||||||
|
# Get project info
|
||||||
|
project = await conn.fetchrow(
|
||||||
|
"SELECT naam, beschrijving, status, prioriteit FROM projects WHERE id = $1",
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
if not project:
|
||||||
|
await conn.close()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Get documents
|
||||||
|
docs = await conn.fetch(
|
||||||
|
"SELECT titel, inhoud FROM documents WHERE project_id = $1 ORDER BY versie DESC LIMIT 5",
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get commitments
|
||||||
|
commits = await conn.fetch(
|
||||||
|
"SELECT beschrijving, status, deadline FROM commitments WHERE project_id = $1 ORDER BY deadline LIMIT 5",
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
context = f"Project: {project['naam']}\nStatus: {project['status']}\nBeschrijving: {project['beschrijving']}\n"
|
||||||
|
|
||||||
|
if docs:
|
||||||
|
context += "\nDocumenten:\n"
|
||||||
|
for d in docs:
|
||||||
|
content_preview = (d['inhoud'] or '')[:300]
|
||||||
|
context += f"- {d['titel']}: {content_preview}\n"
|
||||||
|
|
||||||
|
if commits:
|
||||||
|
context += "\nCommitments:\n"
|
||||||
|
for c in commits:
|
||||||
|
context += f"- {c['beschrijving']} [{c['status']}] (deadline: {c['deadline']})\n"
|
||||||
|
|
||||||
|
return context
|
||||||
|
except Exception as e:
|
||||||
|
return f"[Context fetch error: {str(e)}]"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_global_context() -> str:
|
||||||
|
"""Fetch overview of all projects and kennis artikelen."""
|
||||||
|
try:
|
||||||
|
import asyncpg
|
||||||
|
conn = await asyncpg.connect(DB_URL)
|
||||||
|
|
||||||
|
projects = await conn.fetch(
|
||||||
|
"SELECT naam, status, beschrijving FROM projects ORDER BY naam LIMIT 20"
|
||||||
|
)
|
||||||
|
|
||||||
|
artikelen = await conn.fetch(
|
||||||
|
"SELECT titel, inhoud FROM kennis_artikelen ORDER BY created_at DESC LIMIT 10"
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
context = "Projecten overzicht:\n"
|
||||||
|
for p in projects:
|
||||||
|
context += f"- {p['naam']} [{p['status']}]: {(p['beschrijving'] or '')[:100]}\n"
|
||||||
|
|
||||||
|
if artikelen:
|
||||||
|
context += "\nKennisbasis:\n"
|
||||||
|
for a in artikelen:
|
||||||
|
context += f"- {a['titel']}: {(a['inhoud'] or '')[:200]}\n"
|
||||||
|
|
||||||
|
return context
|
||||||
|
except Exception as e:
|
||||||
|
return f"[Context fetch error: {str(e)}]"
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
# --- Endpoints ---
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check endpoint used by Docker and monitoring."""
|
has_key = bool(ANTHROPIC_API_KEY)
|
||||||
return {"status": "ok", "service": "ai-service"}
|
return {"status": "ok", "service": "ai-service", "model": ANTHROPIC_MODEL, "has_api_key": has_key}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chat", response_model=ChatResponse)
|
@app.post("/api/chat", response_model=ChatResponse)
|
||||||
async def chat(request: ChatRequest):
|
async def chat(request: ChatRequest):
|
||||||
"""
|
"""Chat with AI, optionally scoped to a project."""
|
||||||
Handle a chat message, optionally scoped to a project.
|
if not ANTHROPIC_API_KEY:
|
||||||
Placeholder — wire up LangGraph + Anthropic in the full implementation.
|
return ChatResponse(
|
||||||
"""
|
reply="AI niet geconfigureerd. Stel ANTHROPIC_API_KEY in als environment variable.",
|
||||||
# TODO: integrate LangGraph agent with Anthropic Claude
|
project_id=request.project_id
|
||||||
reply = (
|
|
||||||
f"[AI placeholder] Received: '{request.message}'. "
|
|
||||||
"Full AI integration pending."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build context
|
||||||
|
if request.project_id:
|
||||||
|
context = await get_project_context(request.project_id)
|
||||||
|
else:
|
||||||
|
context = await get_global_context()
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"Je bent de AI-assistent van het Innovatieplatform van Waterschap Brabantse Delta R&D Lab. "
|
||||||
|
"Je helpt met vragen over projecten, onderzoek, architectuur en innovatie. "
|
||||||
|
"Antwoord bondig in het Nederlands tenzij de gebruiker Engels spreekt. "
|
||||||
|
"Gebruik de projectcontext om relevante antwoorden te geven.\n\n"
|
||||||
|
f"Context:\n{context}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build messages
|
||||||
|
messages = []
|
||||||
|
for entry in (request.conversation_history or []):
|
||||||
|
role = "user" if entry.get("type") == "input" else "assistant"
|
||||||
|
messages.append({"role": role, "content": entry.get("text", "")})
|
||||||
|
messages.append({"role": "user", "content": request.message})
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": ANTHROPIC_MODEL,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": messages,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_detail = response.text[:200]
|
||||||
|
return ChatResponse(
|
||||||
|
reply=f"AI fout ({response.status_code}): {error_detail}",
|
||||||
|
project_id=request.project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
reply = data["content"][0]["text"]
|
||||||
return ChatResponse(reply=reply, project_id=request.project_id)
|
return ChatResponse(reply=reply, project_id=request.project_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return ChatResponse(
|
||||||
|
reply=f"AI verbindingsfout: {str(e)}",
|
||||||
|
project_id=request.project_id
|
||||||
|
)
|
||||||
|
|
||||||
@app.post("/api/summarize", response_model=SummarizeResponse)
|
@app.post("/api/summarize", response_model=SummarizeResponse)
|
||||||
async def summarize(request: SummarizeRequest):
|
async def summarize(request: SummarizeRequest):
|
||||||
"""
|
"""Summarize content using AI."""
|
||||||
Summarize content for a given project.
|
if not ANTHROPIC_API_KEY:
|
||||||
Placeholder — wire up Anthropic in the full implementation.
|
return SummarizeResponse(
|
||||||
"""
|
summary="AI niet geconfigureerd.",
|
||||||
# TODO: integrate Anthropic Claude summarization
|
project_id=request.project_id
|
||||||
summary = (
|
|
||||||
f"[AI placeholder] Summary of {len(request.content)} characters "
|
|
||||||
f"(type: {request.summary_type}). Full AI integration pending."
|
|
||||||
)
|
)
|
||||||
return SummarizeResponse(summary=summary, project_id=request.project_id)
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://api.anthropic.com/v1/messages",
|
||||||
|
headers={
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": ANTHROPIC_MODEL,
|
||||||
|
"max_tokens": 512,
|
||||||
|
"system": "Maak een beknopte samenvatting in het Nederlands. Focus op de kernpunten.",
|
||||||
|
"messages": [{"role": "user", "content": request.content}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return SummarizeResponse(summary=f"Samenvatting fout: {response.text[:200]}", project_id=request.project_id)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return SummarizeResponse(summary=data["content"][0]["text"], project_id=request.project_id)
|
||||||
|
except Exception as e:
|
||||||
|
return SummarizeResponse(summary=f"Fout: {str(e)}", project_id=request.project_id)
|
||||||
|
|
||||||
@app.post("/api/search", response_model=SearchResponse)
|
@app.post("/api/search", response_model=SearchResponse)
|
||||||
async def search(request: SearchRequest):
|
async def search(request: SearchRequest):
|
||||||
"""
|
"""Search projects and documents by keyword (basic text search, no embeddings yet)."""
|
||||||
Semantic search using pgvector embeddings.
|
try:
|
||||||
Placeholder — wire up pgvector + embeddings in the full implementation.
|
import asyncpg
|
||||||
"""
|
conn = await asyncpg.connect(DB_URL)
|
||||||
# TODO: integrate pgvector similarity search with embeddings
|
|
||||||
return SearchResponse(
|
query_pattern = f"%{request.query}%"
|
||||||
results=[],
|
|
||||||
query=request.query,
|
results = []
|
||||||
|
|
||||||
|
# Search documents
|
||||||
|
docs = await conn.fetch(
|
||||||
|
"SELECT id, titel, inhoud FROM documents WHERE inhoud ILIKE $1 OR titel ILIKE $1 LIMIT $2",
|
||||||
|
query_pattern, request.limit
|
||||||
)
|
)
|
||||||
|
for d in docs:
|
||||||
|
results.append(SearchResult(
|
||||||
|
id=d['id'],
|
||||||
|
content=f"{d['titel']}: {(d['inhoud'] or '')[:200]}",
|
||||||
|
score=1.0,
|
||||||
|
metadata={"type": "document"}
|
||||||
|
))
|
||||||
|
|
||||||
|
# Search kennis artikelen
|
||||||
|
artikelen = await conn.fetch(
|
||||||
|
"SELECT id, titel, inhoud FROM kennis_artikelen WHERE inhoud ILIKE $1 OR titel ILIKE $1 LIMIT $2",
|
||||||
|
query_pattern, request.limit
|
||||||
|
)
|
||||||
|
for a in artikelen:
|
||||||
|
results.append(SearchResult(
|
||||||
|
id=a['id'],
|
||||||
|
content=f"{a['titel']}: {(a['inhoud'] or '')[:200]}",
|
||||||
|
score=0.9,
|
||||||
|
metadata={"type": "kennis_artikel"}
|
||||||
|
))
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
return SearchResponse(results=results, query=request.query)
|
||||||
|
except Exception as e:
|
||||||
|
return SearchResponse(results=[], query=request.query)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ langchain>=0.1.0
|
|||||||
anthropic>=0.30.0
|
anthropic>=0.30.0
|
||||||
pgvector>=0.2.0
|
pgvector>=0.2.0
|
||||||
psycopg2-binary>=2.9.0
|
psycopg2-binary>=2.9.0
|
||||||
|
asyncpg>=0.29.0
|
||||||
numpy>=1.26.0
|
numpy>=1.26.0
|
||||||
pydantic>=2.0.0
|
pydantic>=2.0.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|||||||
36
app/Http/Controllers/AiController.php
Normal file
36
app/Http/Controllers/AiController.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
class AiController extends Controller
|
||||||
|
{
|
||||||
|
public function chat(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'message' => 'required|string|max:2000',
|
||||||
|
'project_id' => 'nullable|integer',
|
||||||
|
'conversation_history' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$aiServiceUrl = config('services.ai.url', 'http://ai-service:8000');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(30)->post("{$aiServiceUrl}/api/chat", $validated);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return response()->json($response->json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'reply' => 'AI service fout: ' . $response->body(),
|
||||||
|
], 500);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'reply' => 'AI service niet bereikbaar: ' . $e->getMessage(),
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,4 +35,8 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'ai' => [
|
||||||
|
'url' => env('AI_SERVICE_URL', 'http://ai-service:8000'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./ai-service:/app
|
- ./ai-service:/app
|
||||||
environment:
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
|
||||||
|
- DATABASE_URL=postgresql://${DB_USERNAME:-innovatie}:${DB_PASSWORD:-secret}@postgresql:5432/${DB_DATABASE:-innovatieplatform}
|
||||||
- DB_HOST=postgresql
|
- DB_HOST=postgresql
|
||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_DATABASE=${DB_DATABASE:-innovatieplatform}
|
- DB_DATABASE=${DB_DATABASE:-innovatieplatform}
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
const emit = defineEmits(['command'])
|
const props = defineProps({
|
||||||
|
projectId: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
const history = ref([])
|
const history = ref([])
|
||||||
const showHistory = ref(false)
|
const showHistory = ref(false)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async () => {
|
||||||
if (!input.value.trim()) return
|
if (!input.value.trim() || isProcessing.value) return
|
||||||
|
|
||||||
const command = input.value.trim()
|
const command = input.value.trim()
|
||||||
history.value.push({ type: 'input', text: command })
|
history.value.push({ type: 'input', text: command })
|
||||||
history.value.push({ type: 'response', text: 'Processing...' })
|
|
||||||
showHistory.value = true
|
showHistory.value = true
|
||||||
|
|
||||||
emit('command', command)
|
|
||||||
input.value = ''
|
input.value = ''
|
||||||
|
isProcessing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/ai/chat', {
|
||||||
|
message: command,
|
||||||
|
project_id: props.projectId,
|
||||||
|
conversation_history: history.value.filter(e => e.type !== 'processing').slice(-10),
|
||||||
|
})
|
||||||
|
history.value.push({ type: 'response', text: response.data.reply })
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error.response?.data?.message || error.message || 'Verbindingsfout'
|
||||||
|
history.value.push({ type: 'error', text: `Fout: ${msg}` })
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
await nextTick()
|
||||||
|
const historyEl = document.querySelector('.cli-history')
|
||||||
|
if (historyEl) historyEl.scrollTop = historyEl.scrollHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
@@ -38,8 +57,13 @@ const focusInput = () => {
|
|||||||
>
|
>
|
||||||
<span v-if="entry.type === 'input'" class="prompt-char">> </span>
|
<span v-if="entry.type === 'input'" class="prompt-char">> </span>
|
||||||
<span v-if="entry.type === 'response'" class="ai-label">[AI] </span>
|
<span v-if="entry.type === 'response'" class="ai-label">[AI] </span>
|
||||||
|
<span v-if="entry.type === 'error'" class="error-label">[ERR] </span>
|
||||||
{{ entry.text }}
|
{{ entry.text }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isProcessing" class="history-entry processing">
|
||||||
|
<span class="ai-label">[AI] </span>
|
||||||
|
<span class="thinking">denken</span><span class="dots">...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
@@ -50,7 +74,8 @@ const focusInput = () => {
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
class="cli-input"
|
class="cli-input"
|
||||||
placeholder="ask me anything..."
|
:placeholder="isProcessing ? 'wachten op AI...' : 'stel een vraag...'"
|
||||||
|
:disabled="isProcessing"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@keydown.enter="handleSubmit"
|
@keydown.enter="handleSubmit"
|
||||||
@@ -72,7 +97,7 @@ const focusInput = () => {
|
|||||||
.cli-history {
|
.cli-history {
|
||||||
background: rgba(22, 33, 62, 0.95);
|
background: rgba(22, 33, 62, 0.95);
|
||||||
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
||||||
max-height: 200px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -83,6 +108,8 @@ const focusInput = () => {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #8892b0;
|
color: #8892b0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-entry.input {
|
.history-entry.input {
|
||||||
@@ -93,6 +120,14 @@ const focusInput = () => {
|
|||||||
color: #00ff88;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-entry.error {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry.processing {
|
||||||
|
color: #7b68ee;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-char {
|
.prompt-char {
|
||||||
color: #00d2ff;
|
color: #00d2ff;
|
||||||
}
|
}
|
||||||
@@ -101,6 +136,23 @@ const focusInput = () => {
|
|||||||
color: #7b68ee;
|
color: #7b68ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-label {
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dots {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.cli-bar {
|
.cli-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -129,6 +181,10 @@ const focusInput = () => {
|
|||||||
caret-color: transparent;
|
caret-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cli-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.cli-input::placeholder {
|
.cli-input::placeholder {
|
||||||
color: #8892b0;
|
color: #8892b0;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const handleClose = () => {
|
|||||||
:model-value="form.eigenaar_id"
|
:model-value="form.eigenaar_id"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
:error="form.errors.eigenaar_id"
|
:error="form.errors.eigenaar_id"
|
||||||
|
:required="true"
|
||||||
@update:model-value="form.eigenaar_id = $event"
|
@update:model-value="form.eigenaar_id = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
node: { type: Object, default: null },
|
node: { type: Object, default: null },
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'zoom-in'])
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,15 +47,61 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
{{ node.status }}
|
{{ node.status }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
|
<span v-if="node.owner" class="owner">{{ node.owner }}</span>
|
||||||
|
<span v-if="node.badge" class="badge-text">{{ node.badge }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
|
<p v-if="node.description" class="preview-desc">{{ node.description }}</p>
|
||||||
|
|
||||||
<div v-if="node.children" class="preview-children">
|
<!-- Phase summary -->
|
||||||
<div class="children-label">Contains {{ node.children }} items</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button @click="emit('zoom-in', node)" class="zoom-btn">
|
<!-- 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 >>
|
ZOOM IN >>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,21 +111,25 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.node-preview {
|
.node-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16px;
|
left: 16px;
|
||||||
top: 60px;
|
top: 60px;
|
||||||
width: 320px;
|
width: 380px;
|
||||||
background: #16213e;
|
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: 1px solid rgba(0, 210, 255, 0.3);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
|
box-shadow: 0 0 30px rgba(0, 210, 255, 0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-header {
|
.preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-title {
|
.preview-title {
|
||||||
@@ -60,6 +137,7 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: #00d2ff;
|
color: #00d2ff;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
@@ -69,6 +147,7 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
@@ -77,8 +156,9 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
|
|
||||||
.preview-meta {
|
.preview-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -91,14 +171,15 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
|
.status-badge.pilot, .status-badge.actief, .status-badge.active { background: rgba(0, 210, 255, 0.15); color: #00d2ff; }
|
||||||
.status-badge.afgerond, .status-badge.completed { background: rgba(0, 255, 136, 0.15); color: #00ff88; }
|
.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.geparkeerd { background: rgba(255, 217, 61, 0.15); color: #ffd93d; }
|
||||||
.status-badge.gestopt { background: rgba(233, 69, 96, 0.15); color: #e94560; }
|
.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 {
|
.owner { color: #8892b0; }
|
||||||
color: #8892b0;
|
.badge-text { color: #7b68ee; }
|
||||||
}
|
|
||||||
|
|
||||||
.preview-desc {
|
.preview-desc {
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
@@ -108,8 +189,97 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-children {
|
/* Sections */
|
||||||
margin-top: 12px;
|
.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 {
|
.children-label {
|
||||||
@@ -118,10 +288,18 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
color: #7b68ee;
|
color: #7b68ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8892b0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.zoom-btn {
|
.zoom-btn {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 10px;
|
||||||
background: rgba(0, 210, 255, 0.1);
|
background: rgba(0, 210, 255, 0.1);
|
||||||
border: 1px solid #00d2ff;
|
border: 1px solid #00d2ff;
|
||||||
color: #00d2ff;
|
color: #00d2ff;
|
||||||
@@ -130,11 +308,25 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-btn:hover {
|
.zoom-btn:hover {
|
||||||
background: rgba(0, 210, 255, 0.2);
|
background: rgba(0, 210, 255, 0.2);
|
||||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
|
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 {
|
.slide-enter-active, .slide-leave-active {
|
||||||
@@ -142,6 +334,6 @@ const emit = defineEmits(['close', 'zoom-in'])
|
|||||||
}
|
}
|
||||||
.slide-enter-from, .slide-leave-to {
|
.slide-enter-from, .slide-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(20px);
|
transform: translateX(-20px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -157,12 +157,42 @@ const submitTrackForm = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Metro node creation (for custom line extend) ---
|
// --- Metro node creation form (for custom line extend) ---
|
||||||
|
const showMetroNodeForm = ref(false)
|
||||||
|
const pendingMetroNodeEvent = ref(null)
|
||||||
|
const metroNodeForm = useForm({
|
||||||
|
metro_line_id: '',
|
||||||
|
naam: '',
|
||||||
|
beschrijving: '',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
})
|
||||||
|
|
||||||
const createMetroNode = (event) => {
|
const createMetroNode = (event) => {
|
||||||
// Determine metro_line_id from the lineId
|
pendingMetroNodeEvent.value = event
|
||||||
// lineId format for custom lines will need to be mapped
|
// Extract metro_line_id from lineId (format: "custom-{id}" or similar)
|
||||||
// For now, emit a placeholder
|
metroNodeForm.x = event.x
|
||||||
console.log('Create metro node on custom line:', event)
|
metroNodeForm.y = event.y
|
||||||
|
metroNodeForm.naam = ''
|
||||||
|
metroNodeForm.beschrijving = ''
|
||||||
|
showMetroNodeForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitMetroNodeForm = () => {
|
||||||
|
const event = pendingMetroNodeEvent.value
|
||||||
|
// The lineId from the canvas might be something like "custom-5" — extract the DB id
|
||||||
|
// For now, try to find the metro_line by matching
|
||||||
|
const lineId = event?.lineId ?? ''
|
||||||
|
const match = lineId.match(/\d+$/)
|
||||||
|
metroNodeForm.metro_line_id = match ? match[0] : ''
|
||||||
|
|
||||||
|
metroNodeForm.post('/metro-nodes', {
|
||||||
|
onSuccess: () => {
|
||||||
|
showMetroNodeForm.value = false
|
||||||
|
metroNodeForm.reset()
|
||||||
|
pendingMetroNodeEvent.value = null
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FAB handlers ---
|
// --- FAB handlers ---
|
||||||
@@ -188,10 +218,6 @@ const handleFabItemLeave = () => {
|
|||||||
canvasRef.value?.setHighlightedLine(null)
|
canvasRef.value?.setHighlightedLine(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCliCommand = (command) => {
|
|
||||||
console.log('CLI command:', command)
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
router.post('/logout')
|
router.post('/logout')
|
||||||
}
|
}
|
||||||
@@ -300,7 +326,46 @@ const editingProject = ref(null)
|
|||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<CliBar @command="handleCliCommand" />
|
<!-- Metro node creation modal (for custom line extend) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="modal-fade">
|
||||||
|
<div v-if="showMetroNodeForm" class="modal-backdrop" @click="showMetroNodeForm = false">
|
||||||
|
<div class="modal-content" @click.stop>
|
||||||
|
<div class="modal-header">NIEUW PUNT</div>
|
||||||
|
<form @submit.prevent="submitMetroNodeForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Naam</label>
|
||||||
|
<input
|
||||||
|
v-model="metroNodeForm.naam"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Naam van het punt..."
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Beschrijving</label>
|
||||||
|
<textarea
|
||||||
|
v-model="metroNodeForm.beschrijving"
|
||||||
|
class="form-input form-textarea"
|
||||||
|
placeholder="Optionele beschrijving..."
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" @click="showMetroNodeForm = false">Annuleren</button>
|
||||||
|
<button type="submit" class="btn-submit" :disabled="metroNodeForm.processing">
|
||||||
|
{{ metroNodeForm.processing ? 'Bezig...' : 'Aanmaken' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<CliBar :project-id="currentProjectId" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -434,6 +499,11 @@ const editingProject = ref(null)
|
|||||||
box-shadow: 0 0 8px rgba(0, 210, 255, 0.2);
|
box-shadow: 0 0 8px rgba(0, 210, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\MetroLineController;
|
|||||||
use App\Http\Controllers\MetroNodeController;
|
use App\Http\Controllers\MetroNodeController;
|
||||||
use App\Http\Controllers\ProjectController;
|
use App\Http\Controllers\ProjectController;
|
||||||
use App\Http\Controllers\ThemaController;
|
use App\Http\Controllers\ThemaController;
|
||||||
|
use App\Http\Controllers\AiController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Redirect root to map
|
// Redirect root to map
|
||||||
@@ -59,6 +60,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
Route::put('/metro-nodes/{metroNode}', [MetroNodeController::class, 'update'])->name('metro-nodes.update');
|
Route::put('/metro-nodes/{metroNode}', [MetroNodeController::class, 'update'])->name('metro-nodes.update');
|
||||||
Route::delete('/metro-nodes/{metroNode}', [MetroNodeController::class, 'destroy'])->name('metro-nodes.destroy');
|
Route::delete('/metro-nodes/{metroNode}', [MetroNodeController::class, 'destroy'])->name('metro-nodes.destroy');
|
||||||
|
|
||||||
|
// AI Chat
|
||||||
|
Route::post('/api/ai/chat', [AiController::class, 'chat'])->name('ai.chat');
|
||||||
|
|
||||||
// Dashboard (redirects to map)
|
// Dashboard (redirects to map)
|
||||||
Route::get('/dashboard', fn () => redirect('/map'))->name('dashboard');
|
Route::get('/dashboard', fn () => redirect('/map'))->name('dashboard');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user