diff --git a/src/components/roadmap/NodeDetailPanel.jsx b/src/components/roadmap/NodeDetailPanel.jsx
new file mode 100644
index 0000000..dac9a00
--- /dev/null
+++ b/src/components/roadmap/NodeDetailPanel.jsx
@@ -0,0 +1,166 @@
+import { useEffect, useState } from 'react'
+import { IconTrash, IconX } from '@tabler/icons-react'
+import { Card, Button, Field } from '../ui'
+import { useEmployees } from '../../hooks/useEmployees'
+import { NODE_KIND, NODE_STATUS, NODE_STATUS_LABELS } from '../../lib/roadmaps'
+
+const STATUS_ORDER = [NODE_STATUS.OPEN, NODE_STATUS.IN_PROGRESS, NODE_STATUS.DONE]
+
+/**
+ * Seitliches Detail-Panel fuer den ausgewaehlten Knoten:
+ * Titel, Status, Zustaendiger, Deadline, Notizen, eingehende Verbindungen.
+ */
+export default function NodeDetailPanel({
+ node,
+ nodes,
+ readOnly,
+ onClose,
+ onSave,
+ onSetStatus,
+ onDelete,
+ onDisconnect,
+}) {
+ const { employees } = useEmployees()
+ const [form, setForm] = useState({ title: '', assignedTo: '', deadline: '', notes: '' })
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ setForm({
+ title: node.title || '',
+ assignedTo: node.assignedTo || '',
+ deadline: node.deadline || '',
+ notes: node.notes || '',
+ })
+ setError('')
+ }, [node.$id]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const isStart = node.kind === NODE_KIND.START
+ const incoming = (node.sourceIds || [])
+ .map((id) => nodes.find((n) => n.$id === id))
+ .filter(Boolean)
+
+ const handleSave = async () => {
+ if (!form.title.trim()) {
+ setError('Titel darf nicht leer sein.')
+ return
+ }
+ setSaving(true)
+ setError('')
+ const employee = employees.find((e) => e.userId === form.assignedTo)
+ const result = await onSave(node, {
+ title: form.title.trim(),
+ assignedTo: form.assignedTo,
+ assignedName: employee?.displayName || '',
+ deadline: form.deadline,
+ notes: form.notes,
+ })
+ setSaving(false)
+ if (!result.success) setError(result.error)
+ }
+
+ return (
+ }
+ >
+
+
+ setForm((f) => ({ ...f, title: e.target.value }))}
+ />
+
+
+ {!isStart && (
+
+
+ {STATUS_ORDER.map((status) => (
+ onSetStatus(node, status)}
+ >
+ {NODE_STATUS_LABELS[status]}
+
+ ))}
+
+
+ )}
+
+ {!isStart && (
+
+ setForm((f) => ({ ...f, assignedTo: e.target.value }))}
+ >
+ Niemand
+ {employees.map((e) => (
+ {e.displayName}
+ ))}
+
+
+ )}
+
+ {!isStart && (
+
+ setForm((f) => ({ ...f, deadline: e.target.value }))}
+ />
+
+ )}
+
+
+
+
+ {incoming.length > 0 && (
+
+
+ {incoming.map((source) => (
+
+ {source.title}
+ {!readOnly && (
+ onDisconnect(source.$id, node)}>
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {error &&
{error}
}
+
+ {!readOnly && (
+
+ {!isStart ? (
+ onDelete(node)} title="Knoten loeschen">
+ Loeschen
+
+ ) : }
+
+ {saving ? 'Speichere…' : 'Speichern'}
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/roadmap/RoadmapBoard.jsx b/src/components/roadmap/RoadmapBoard.jsx
new file mode 100644
index 0000000..1514b9f
--- /dev/null
+++ b/src/components/roadmap/RoadmapBoard.jsx
@@ -0,0 +1,168 @@
+import { useLayoutEffect, useRef, useState, useCallback } from 'react'
+import { IconPlus } from '@tabler/icons-react'
+import RoadmapNodeCard from './RoadmapNodeCard'
+import { NODE_KIND } from '../../lib/roadmaps'
+
+const COLUMN_WIDTH = 230
+const COLUMN_GAP = 48
+
+/**
+ * Steckbrett: Spalten von links (Start) nach rechts (Ziele), Knoten vertikal
+ * gestapelt, Kanten als SVG-Overlay. Verbinden per Klick-Modus:
+ * Connect-Handle am Quellknoten -> gueltige Ziele (Spalte weiter rechts) anklicken.
+ */
+export default function RoadmapBoard({
+ roadmap,
+ nodes,
+ selectedNode,
+ readOnly,
+ onSelectNode,
+ onAddNode,
+ onConnect,
+}) {
+ const containerRef = useRef(null)
+ const nodeRefs = useRef({})
+ const [edges, setEdges] = useState([])
+ const [connectSource, setConnectSource] = useState(null)
+
+ const columnCount = Math.max(roadmap.columnCount || 3, 2)
+ const columns = Array.from({ length: columnCount }, (_, c) =>
+ nodes.filter((n) => n.column === c).sort((a, b) => (a.row || 0) - (b.row || 0))
+ )
+
+ const setNodeRef = useCallback((id) => (el) => {
+ if (el) nodeRefs.current[id] = el
+ else delete nodeRefs.current[id]
+ }, [])
+
+ // Kanten-Geometrie nach jedem Layout neu berechnen (Anker relativ zum Board)
+ useLayoutEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ const compute = () => {
+ const base = container.getBoundingClientRect()
+ const next = []
+ for (const node of nodes) {
+ for (const sourceId of node.sourceIds || []) {
+ const sourceEl = nodeRefs.current[sourceId]
+ const targetEl = nodeRefs.current[node.$id]
+ if (!sourceEl || !targetEl) continue
+ const s = sourceEl.getBoundingClientRect()
+ const t = targetEl.getBoundingClientRect()
+ next.push({
+ id: `${sourceId}-${node.$id}`,
+ x1: s.right - base.left,
+ y1: s.top + s.height / 2 - base.top,
+ x2: t.left - base.left,
+ y2: t.top + t.height / 2 - base.top,
+ })
+ }
+ }
+ setEdges(next)
+ }
+ compute()
+ const observer = new ResizeObserver(compute)
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [nodes, columnCount])
+
+ const handleStartConnect = (node) => setConnectSource(node)
+
+ const handleSelect = async (node) => {
+ if (connectSource) {
+ if (node.column > connectSource.column) {
+ await onConnect(connectSource, node)
+ }
+ setConnectSource(null)
+ return
+ }
+ onSelectNode(node)
+ }
+
+ const columnLabel = (c) => {
+ if (c === 0) return 'Start'
+ if (c === columnCount - 1) return 'Ziele'
+ return `Phase ${c}`
+ }
+
+ return (
+
+ {connectSource && (
+
+ Verbinden von {connectSource.title} : Zielknoten rechts davon anklicken.
+ setConnectSource(null)}>Abbrechen
+
+ )}
+
+
+
+
+
+
+
+ {edges.map((e) => {
+ const dx = Math.max((e.x2 - e.x1) / 2, 24)
+ return (
+
+ )
+ })}
+
+
+ {columns.map((columnNodes, c) => (
+
+
+ {columnLabel(c)}
+
+ {columnNodes.map((node) => (
+
connectSource.column}
+ onSelect={handleSelect}
+ onStartConnect={handleStartConnect}
+ nodeRef={setNodeRef(node.$id)}
+ />
+ ))}
+ {!readOnly && c > 0 && (
+ onAddNode(c, c === columnCount - 1 ? NODE_KIND.GOAL : NODE_KIND.STEP, columnNodes.length)}
+ >
+ {c === columnCount - 1 ? 'Ziel' : 'Schritt'}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/roadmap/RoadmapNodeCard.jsx b/src/components/roadmap/RoadmapNodeCard.jsx
new file mode 100644
index 0000000..ae569f4
--- /dev/null
+++ b/src/components/roadmap/RoadmapNodeCard.jsx
@@ -0,0 +1,101 @@
+import { IconFlag, IconPlayerPlay, IconLink } from '@tabler/icons-react'
+import { NODE_KIND, NODE_STATUS, isNodeOverdue } from '../../lib/roadmaps'
+
+const STATUS_COLORS = {
+ [NODE_STATUS.OPEN]: 'var(--text-muted, #94a3b8)',
+ [NODE_STATUS.IN_PROGRESS]: '#fbbf24',
+ [NODE_STATUS.DONE]: '#34d399',
+}
+
+/**
+ * Knoten-Kachel im Steckbrett.
+ * connectMode: Board wartet auf Zielauswahl; isValidTarget markiert erlaubte Ziele.
+ */
+export default function RoadmapNodeCard({
+ node,
+ selected,
+ readOnly,
+ connectSource,
+ isValidTarget,
+ onSelect,
+ onStartConnect,
+ nodeRef,
+}) {
+ const overdue = isNodeOverdue(node)
+ const isStart = node.kind === NODE_KIND.START
+ const isGoal = node.kind === NODE_KIND.GOAL
+ const connecting = Boolean(connectSource)
+ const isConnectSource = connectSource?.$id === node.$id
+
+ const borderColor = isConnectSource
+ ? 'var(--accent, #3b82f6)'
+ : connecting && isValidTarget
+ ? '#34d399'
+ : selected
+ ? 'var(--accent, #3b82f6)'
+ : overdue
+ ? 'var(--danger, #ef4444)'
+ : 'var(--border)'
+
+ return (
+ onSelect(node)}
+ style={{
+ position: 'relative',
+ background: isGoal ? 'rgba(59,130,246,0.10)' : 'var(--surface-2, rgba(45,55,72,0.65))',
+ border: `2px solid ${borderColor}`,
+ borderRadius: 10,
+ padding: '10px 12px',
+ cursor: 'pointer',
+ opacity: connecting && !isValidTarget && !isConnectSource ? 0.45 : 1,
+ transition: 'border-color 0.15s ease, opacity 0.15s ease',
+ }}
+ >
+
+ {isStart && }
+ {isGoal && }
+ {!isStart && (
+
+ )}
+
+ {node.title}
+
+
+
+ {(node.assignedName || node.deadline) && (
+
+ {node.assignedName && {node.assignedName} }
+ {node.deadline && (
+
+ bis {node.deadline.split('-').reverse().join('.')}
+
+ )}
+
+ )}
+
+ {!readOnly && !isGoal && !connecting && (
+
{ e.stopPropagation(); onStartConnect(node) }}
+ style={{
+ position: 'absolute', right: -11, top: '50%', transform: 'translateY(-50%)',
+ width: 22, height: 22, borderRadius: 11,
+ border: '1px solid var(--border)', background: 'var(--surface, #1e293b)',
+ color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
+ justifyContent: 'center', cursor: 'crosshair', padding: 0,
+ }}
+ >
+
+
+ )}
+
+ )
+}
diff --git a/src/hooks/useRoadmaps.js b/src/hooks/useRoadmaps.js
index 6068a7a..6898b40 100644
--- a/src/hooks/useRoadmaps.js
+++ b/src/hooks/useRoadmaps.js
@@ -60,6 +60,20 @@ export function useRoadmaps() {
}
}
+ const updateRoadmap = async (roadmapId, data) => {
+ if (isDemoMode) return { success: false, error: 'Demo-Modus' }
+ try {
+ const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId, {
+ ...data,
+ updatedAt: new Date().toISOString(),
+ })
+ setRoadmaps((prev) => prev.map((r) => (r.$id === roadmapId ? doc : r)))
+ return { success: true, data: doc }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+ }
+
const deleteRoadmap = async (roadmap, user) => {
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
try {
@@ -82,5 +96,5 @@ export function useRoadmaps() {
}
}
- return { roadmaps, loading, error, fetchAll, fetchById, fetchByTicketId, createRoadmap, deleteRoadmap, completePlan }
+ return { roadmaps, loading, error, fetchAll, fetchById, fetchByTicketId, createRoadmap, updateRoadmap, deleteRoadmap, completePlan }
}
diff --git a/src/pages/RoadmapDetailPage.jsx b/src/pages/RoadmapDetailPage.jsx
new file mode 100644
index 0000000..87875da
--- /dev/null
+++ b/src/pages/RoadmapDetailPage.jsx
@@ -0,0 +1,205 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { IconArrowLeft, IconColumnInsertRight, IconTicket, IconAlertTriangle } from '@tabler/icons-react'
+import { PageHeader, Card, Button, Badge } from '../components/ui'
+import RoadmapBoard from '../components/roadmap/RoadmapBoard'
+import NodeDetailPanel from '../components/roadmap/NodeDetailPanel'
+import RoadmapProgressBar from '../components/roadmap/RoadmapProgressBar'
+import { useAuth } from '../context/AuthContext'
+import { useRoadmaps } from '../hooks/useRoadmaps'
+import { useRoadmapNodes } from '../hooks/useRoadmapNodes'
+import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
+import { computeProgress, NODE_KIND, ROADMAP_STATUS } from '../lib/roadmaps'
+
+export default function RoadmapDetailPage() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { user } = useAuth()
+ const { fetchById, updateRoadmap, completePlan } = useRoadmaps()
+ const {
+ nodes, fetchNodes, createNode, updateNode, deleteNode,
+ connectNodes, disconnectNodes, setNodeStatus,
+ } = useRoadmapNodes()
+
+ const [roadmap, setRoadmap] = useState(null)
+ const [ticketStatus, setTicketStatus] = useState(null)
+ const [selectedNode, setSelectedNode] = useState(null)
+ const [notFound, setNotFound] = useState(false)
+
+ const load = useCallback(async () => {
+ const doc = await fetchById(id)
+ if (!doc) {
+ setNotFound(true)
+ return
+ }
+ setRoadmap(doc)
+ await fetchNodes(doc.$id)
+ if (doc.ticketId) {
+ try {
+ const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, doc.ticketId)
+ setTicketStatus(ticket.status)
+ } catch {
+ setTicketStatus(null)
+ }
+ }
+ }, [id, fetchById, fetchNodes])
+
+ useEffect(() => { load() }, [load])
+
+ // Teamarbeit: bei Fenster-Fokus neu laden (einfacher Schutz gegen veraltete Ansicht)
+ useEffect(() => {
+ const onFocus = () => load()
+ window.addEventListener('focus', onFocus)
+ return () => window.removeEventListener('focus', onFocus)
+ }, [load])
+
+ // Ausgewaehlten Knoten mit frischen Daten aus der Liste spiegeln
+ useEffect(() => {
+ if (!selectedNode) return
+ const fresh = nodes.find((n) => n.$id === selectedNode.$id)
+ if (!fresh) setSelectedNode(null)
+ else if (fresh !== selectedNode) setSelectedNode(fresh)
+ }, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (notFound) {
+ return (
+
+
+
navigate('/roadmaps')}> Zur Uebersicht
+
+ )
+ }
+ if (!roadmap) {
+ return
+ }
+
+ const readOnly = roadmap.status === ROADMAP_STATUS.COMPLETED
+ const progress = computeProgress(nodes)
+ const ticketClosedButActive = !readOnly && ticketStatus === 'Closed'
+
+ const handleAddNode = async (column, kind, rowCount) => {
+ const result = await createNode(roadmap.$id, {
+ title: kind === NODE_KIND.GOAL ? 'Neues Ziel' : 'Neuer Schritt',
+ column,
+ row: rowCount,
+ kind,
+ })
+ if (result.success) setSelectedNode(result.data)
+ else window.alert(result.error)
+ }
+
+ const handleConnect = async (source, target) => {
+ const result = await connectNodes(source, target)
+ if (!result.success) window.alert(result.error)
+ }
+
+ const handleSaveNode = async (node, data) => updateNode(node.$id, data)
+
+ const handleSetStatus = async (node, status) => {
+ const result = await setNodeStatus(roadmap, node, status, user)
+ if (!result.success) {
+ window.alert(result.error)
+ return
+ }
+ if (result.allGoalsDone) {
+ const ok = window.confirm(
+ 'Alle Ziel-Knoten sind fertig!\n\n' +
+ 'Plan abschliessen und archivieren? Das Projektticket wird dabei geschlossen.'
+ )
+ if (ok) {
+ const done = await completePlan(roadmap, user)
+ if (done.success) setRoadmap(done.data)
+ else window.alert(`Abschliessen fehlgeschlagen: ${done.error}`)
+ }
+ }
+ }
+
+ const handleDeleteNode = async (node) => {
+ if (!window.confirm(`Knoten "${node.title}" loeschen?`)) return
+ const result = await deleteNode(node)
+ if (!result.success) window.alert(result.error)
+ else setSelectedNode(null)
+ }
+
+ const handleDisconnect = async (sourceId, target) => {
+ const result = await disconnectNodes(sourceId, target)
+ if (!result.success) window.alert(result.error)
+ }
+
+ /** Neue Phase vor den Zielen einfuegen: Ziel-Knoten ruecken eine Spalte nach rechts. */
+ const handleAddColumn = async () => {
+ const newCount = (roadmap.columnCount || 3) + 1
+ if (newCount > 50) return
+ const goals = nodes.filter((n) => n.kind === NODE_KIND.GOAL)
+ for (const goal of goals) {
+ await updateNode(goal.$id, { column: newCount - 1 })
+ }
+ const result = await updateRoadmap(roadmap.$id, { columnCount: newCount })
+ if (result.success) setRoadmap(result.data)
+ else window.alert(result.error)
+ }
+
+ return (
+
+
+
+ {readOnly && Abgeschlossen }
+ {roadmap.woid && (
+
+ Ticket {roadmap.woid}
+
+ )}
+ {!readOnly && (
+
+ Spalte
+
+ )}
+ navigate('/roadmaps')}> Zurueck
+
+ }
+ />
+
+ {ticketClosedButActive && (
+
+
+
+
+ Das Projektticket wurde manuell geschlossen, der Plan ist aber erst zu {progress.percent}% fertig.
+ Statusaenderungen werden weiterhin im Ticketverlauf dokumentiert.
+
+
+
+ )}
+
+
+
+
+
+ {selectedNode && (
+ setSelectedNode(null)}
+ onSave={handleSaveNode}
+ onSetStatus={handleSetStatus}
+ onDelete={handleDeleteNode}
+ onDisconnect={handleDisconnect}
+ />
+ )}
+
+
+ )
+}
diff --git a/src/pages/RoadmapsPage.jsx b/src/pages/RoadmapsPage.jsx
index 5e60492..dbe876d 100644
--- a/src/pages/RoadmapsPage.jsx
+++ b/src/pages/RoadmapsPage.jsx
@@ -108,7 +108,7 @@ export default function RoadmapsPage() {
{roadmap.status === ROADMAP_STATUS.COMPLETED
- ?
Abgeschlossen
+ ?
Abgeschlossen
: progress &&
}
navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen