206 lines
7.5 KiB
React
206 lines
7.5 KiB
React
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>
|
|
)
|
|
}
|