feat(roadmaps): Steckbrett-Board mit SVG-Kanten, Detail-Panel und Abschluss-Automatik
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
166
src/components/roadmap/NodeDetailPanel.jsx
Normal file
166
src/components/roadmap/NodeDetailPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<Card
|
||||||
|
title={isStart ? 'Start-Knoten' : node.kind === NODE_KIND.GOAL ? 'Ziel-Knoten' : 'Schritt'}
|
||||||
|
actions={<Button variant="ghost" onClick={onClose}><IconX size={16} /></Button>}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<Field label="Titel">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Status">
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{STATUS_ORDER.map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
variant={node.status === status ? 'primary' : 'default'}
|
||||||
|
disabled={readOnly}
|
||||||
|
onClick={() => onSetStatus(node, status)}
|
||||||
|
>
|
||||||
|
{NODE_STATUS_LABELS[status]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Zustaendig">
|
||||||
|
<select
|
||||||
|
value={form.assignedTo}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, assignedTo: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Niemand</option>
|
||||||
|
{employees.map((e) => (
|
||||||
|
<option key={e.userId} value={e.userId}>{e.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isStart && (
|
||||||
|
<Field label="Deadline">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.deadline}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, deadline: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Notizen">
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.notes}
|
||||||
|
maxLength={2000}
|
||||||
|
disabled={readOnly}
|
||||||
|
placeholder="Details, Anforderungen, Links…"
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{incoming.length > 0 && (
|
||||||
|
<Field label="Eingehende Verbindungen">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{incoming.map((source) => (
|
||||||
|
<div key={source.$id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||||
|
<span className="muted">{source.title}</span>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="ghost" title="Verbindung loesen" onClick={() => onDisconnect(source.$id, node)}>
|
||||||
|
<IconX size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
|
||||||
|
{!isStart ? (
|
||||||
|
<Button variant="ghost" onClick={() => onDelete(node)} title="Knoten loeschen">
|
||||||
|
<IconTrash size={15} /> Loeschen
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Speichere…' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/components/roadmap/RoadmapBoard.jsx
Normal file
168
src/components/roadmap/RoadmapBoard.jsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ overflowX: 'auto', paddingBottom: 8 }}>
|
||||||
|
{connectSource && (
|
||||||
|
<div
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 13, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
Verbinden von <strong>{connectSource.title}</strong>: Zielknoten rechts davon anklicken.
|
||||||
|
<button type="button" className="ui-btn" onClick={() => setConnectSource(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columnCount}, ${COLUMN_WIDTH}px)`,
|
||||||
|
gap: `16px ${COLUMN_GAP}px`,
|
||||||
|
alignItems: 'start',
|
||||||
|
minWidth: columnCount * (COLUMN_WIDTH + COLUMN_GAP),
|
||||||
|
padding: '8px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker id="roadmap-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||||
|
<path d="M0,0 L8,4 L0,8 Z" fill="var(--text-muted, #94a3b8)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const dx = Math.max((e.x2 - e.x1) / 2, 24)
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={e.id}
|
||||||
|
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--text-muted, #94a3b8)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
markerEnd="url(#roadmap-arrow)"
|
||||||
|
opacity="0.7"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{columns.map((columnNodes, c) => (
|
||||||
|
<div key={c} style={{ display: 'flex', flexDirection: 'column', gap: 14, zIndex: 1 }}>
|
||||||
|
<div className="faint" style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{columnLabel(c)}
|
||||||
|
</div>
|
||||||
|
{columnNodes.map((node) => (
|
||||||
|
<RoadmapNodeCard
|
||||||
|
key={node.$id}
|
||||||
|
node={node}
|
||||||
|
selected={selectedNode?.$id === node.$id}
|
||||||
|
readOnly={readOnly}
|
||||||
|
connectSource={connectSource}
|
||||||
|
isValidTarget={Boolean(connectSource) && node.column > connectSource.column}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onStartConnect={handleStartConnect}
|
||||||
|
nodeRef={setNodeRef(node.$id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!readOnly && c > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn"
|
||||||
|
style={{ borderStyle: 'dashed', fontSize: 12 }}
|
||||||
|
onClick={() => onAddNode(c, c === columnCount - 1 ? NODE_KIND.GOAL : NODE_KIND.STEP, columnNodes.length)}
|
||||||
|
>
|
||||||
|
<IconPlus size={14} /> {c === columnCount - 1 ? 'Ziel' : 'Schritt'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/components/roadmap/RoadmapNodeCard.jsx
Normal file
101
src/components/roadmap/RoadmapNodeCard.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={nodeRef}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{isStart && <IconPlayerPlay size={15} style={{ flexShrink: 0 }} />}
|
||||||
|
{isGoal && <IconFlag size={15} style={{ flexShrink: 0, color: '#93c5fd' }} />}
|
||||||
|
{!isStart && (
|
||||||
|
<span
|
||||||
|
title={`Status: ${node.status || 'open'}`}
|
||||||
|
style={{
|
||||||
|
width: 10, height: 10, borderRadius: 5, flexShrink: 0,
|
||||||
|
background: STATUS_COLORS[node.status] || STATUS_COLORS.open,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{node.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(node.assignedName || node.deadline) && (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 6, fontSize: 11 }} className="faint">
|
||||||
|
{node.assignedName && <span>{node.assignedName}</span>}
|
||||||
|
{node.deadline && (
|
||||||
|
<span style={overdue ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
|
||||||
|
bis {node.deadline.split('-').reverse().join('.')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && !isGoal && !connecting && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Verbindung von hier starten"
|
||||||
|
onClick={(e) => { 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconLink size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) => {
|
const deleteRoadmap = async (roadmap, user) => {
|
||||||
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
if (isDemoMode) return { success: false, error: 'Demo-Modus' }
|
||||||
try {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
205
src/pages/RoadmapDetailPage.jsx
Normal file
205
src/pages/RoadmapDetailPage.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader title="Roadmap nicht gefunden" />
|
||||||
|
<Button onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zur Uebersicht</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!roadmap) {
|
||||||
|
return <div className="page"><p className="muted">Lade Roadmap…</p></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
title={roadmap.title}
|
||||||
|
subtitle={`Projekt: ${roadmap.projectName || '-'}${roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}`}
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2 items-center" style={{ flexWrap: 'wrap' }}>
|
||||||
|
<RoadmapProgressBar progress={progress} compact />
|
||||||
|
{readOnly && <Badge tone="ok">Abgeschlossen</Badge>}
|
||||||
|
{roadmap.woid && (
|
||||||
|
<Link to={`/tickets?woid=${roadmap.woid}`}>
|
||||||
|
<Button variant="ghost"><IconTicket size={16} /> Ticket {roadmap.woid}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<Button variant="ghost" onClick={handleAddColumn} title="Neue Phase vor den Zielen einfuegen">
|
||||||
|
<IconColumnInsertRight size={16} /> Spalte
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/roadmaps')}><IconArrowLeft size={16} /> Zurueck</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ticketClosedButActive && (
|
||||||
|
<Card style={{ marginBottom: 16, borderColor: 'rgba(245,158,11,0.5)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13 }}>
|
||||||
|
<IconAlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||||
|
<span>
|
||||||
|
Das Projektticket wurde manuell geschlossen, der Plan ist aber erst zu {progress.percent}% fertig.
|
||||||
|
Statusaenderungen werden weiterhin im Ticketverlauf dokumentiert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selectedNode ? 'minmax(0, 1fr) 320px' : '1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<Card pad={false} bodyStyle={{ padding: 12 }} style={{ padding: 12 }}>
|
||||||
|
<RoadmapBoard
|
||||||
|
roadmap={roadmap}
|
||||||
|
nodes={nodes}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onSelectNode={setSelectedNode}
|
||||||
|
onAddNode={handleAddNode}
|
||||||
|
onConnect={handleConnect}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
{selectedNode && (
|
||||||
|
<NodeDetailPanel
|
||||||
|
node={selectedNode}
|
||||||
|
nodes={nodes}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
onSave={handleSaveNode}
|
||||||
|
onSetStatus={handleSetStatus}
|
||||||
|
onDelete={handleDeleteNode}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ export default function RoadmapsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
{roadmap.status === ROADMAP_STATUS.COMPLETED
|
{roadmap.status === ROADMAP_STATUS.COMPLETED
|
||||||
? <Badge tone="success">Abgeschlossen</Badge>
|
? <Badge tone="ok">Abgeschlossen</Badge>
|
||||||
: progress && <RoadmapProgress progress={progress} />}
|
: progress && <RoadmapProgress progress={progress} />}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<Button variant="ghost" onClick={() => navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen</Button>
|
<Button variant="ghost" onClick={() => navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user