Roadmaps aus WOMS entfernt
Menüpunkt, die Seiten /roadmaps und /roadmaps/:id, der Roadmap-Kasten im Ticket und die Plan-Automatik (Auto-Ticket, Knoten-Kommentare, automatischer Abschluss) sind entfernt. Alte Links landen bei den Tickets. Die Projekt-Kontextbox im Ticket bleibt und zeigt das zugewiesene Projekt samt den anderen Tickets des Kunden, nur ohne Plan-Link. Die Daten in roadmaps/roadmapNodes bleiben in Appwrite unangetastet; das Kundenportal liest sie nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50d237a932
commit
7b31cb5368
16 files changed
+11
-1527
No files matched your search
@@ -1,205 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { IconRoute, IconPlus, IconTrash, IconTicket } from '@tabler/icons-react'
|
||||
import { PageHeader, Card, Button, Badge, EmptyState, Field } from '../components/ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useRoadmaps } from '../hooks/useRoadmaps'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { fetchRoadmapNodes, computeProgress, ROADMAP_STATUS } from '../lib/roadmaps'
|
||||
import RoadmapProgress from '../components/roadmap/RoadmapProgressBar'
|
||||
|
||||
export default function RoadmapsPage() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { roadmaps, loading, fetchAll, createRoadmap, deleteRoadmap } = useRoadmaps()
|
||||
const { fetchAllProjects } = useWebsiteProjects()
|
||||
|
||||
const [projects, setProjects] = useState([])
|
||||
const [progressById, setProgressById] = useState({})
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [form, setForm] = useState({ projectId: '', title: '', goalTitle: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const [allProjects, allRoadmaps] = await Promise.all([fetchAllProjects(), fetchAll()])
|
||||
if (cancelled) return
|
||||
setProjects(allProjects.filter((p) => !p.isTemplate && !p.archived))
|
||||
const active = allRoadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
|
||||
const entries = await Promise.all(active.map(async (r) => {
|
||||
try {
|
||||
return [r.$id, computeProgress(await fetchRoadmapNodes(r.$id))]
|
||||
} catch {
|
||||
return [r.$id, null]
|
||||
}
|
||||
}))
|
||||
if (!cancelled) setProgressById(Object.fromEntries(entries.filter(([, p]) => p)))
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
}, [fetchAll, fetchAllProjects])
|
||||
|
||||
const activeRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.ACTIVE)
|
||||
const completedRoadmaps = roadmaps.filter((r) => r.status === ROADMAP_STATUS.COMPLETED)
|
||||
|
||||
// Nur Projekte ohne aktiven Plan anbieten (genau 1 aktiver Plan pro Projekt)
|
||||
const availableProjects = useMemo(() => {
|
||||
const withActivePlan = new Set(activeRoadmaps.map((r) => r.projectId))
|
||||
return projects.filter((p) => !withActivePlan.has(p.$id))
|
||||
}, [projects, activeRoadmaps])
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ projectId: availableProjects[0]?.$id || '', title: '', goalTitle: '' })
|
||||
setError('')
|
||||
setShowCreate(true)
|
||||
}
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
const project = projects.find((p) => p.$id === form.projectId)
|
||||
if (!project) {
|
||||
setError('Bitte ein Projekt auswaehlen.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setError('')
|
||||
const result = await createRoadmap(project, { title: form.title.trim(), goalTitle: form.goalTitle.trim() }, user)
|
||||
setCreating(false)
|
||||
if (!result.success) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
setShowCreate(false)
|
||||
navigate(`/roadmaps/${result.data.roadmap.$id}`)
|
||||
}
|
||||
|
||||
const handleDelete = async (roadmap) => {
|
||||
const ok = window.confirm(
|
||||
`Plan "${roadmap.title}" wirklich loeschen?\n\n` +
|
||||
`Alle Knoten werden entfernt und das Projektticket (WOID ${roadmap.woid || '-'}) wird abgebrochen.`
|
||||
)
|
||||
if (!ok) return
|
||||
const result = await deleteRoadmap(roadmap, user)
|
||||
if (!result.success) window.alert(`Loeschen fehlgeschlagen: ${result.error}`)
|
||||
}
|
||||
|
||||
const renderRoadmapCard = (roadmap) => {
|
||||
const progress = progressById[roadmap.$id]
|
||||
return (
|
||||
<Card key={roadmap.$id}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Link to={`/roadmaps/${roadmap.$id}`} style={{ fontWeight: 700, fontSize: 15 }}>
|
||||
{roadmap.title}
|
||||
</Link>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
|
||||
Projekt: {roadmap.projectName || '-'}
|
||||
{roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''}
|
||||
</div>
|
||||
{roadmap.woid && (
|
||||
<div className="faint" style={{ fontSize: 12, marginTop: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<IconTicket size={14} />
|
||||
<Link to={`/tickets?woid=${roadmap.woid}`}>Projektticket WOID {roadmap.woid}</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{roadmap.status === ROADMAP_STATUS.COMPLETED
|
||||
? <Badge tone="ok">Abgeschlossen</Badge>
|
||||
: progress && <RoadmapProgress progress={progress} />}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Button variant="ghost" onClick={() => navigate(`/roadmaps/${roadmap.$id}`)}>Oeffnen</Button>
|
||||
{roadmap.status === ROADMAP_STATUS.ACTIVE && (
|
||||
<Button variant="ghost" title="Plan loeschen" onClick={() => handleDelete(roadmap)}>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Roadmaps"
|
||||
subtitle="Projektspezifische Plaene — jeder Plan trackt sich automatisch in einem Projektticket"
|
||||
actions={
|
||||
<Button variant="primary" onClick={openCreate} disabled={!availableProjects.length && !loading}>
|
||||
<IconPlus size={16} /> Plan erstellen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && !roadmaps.length ? (
|
||||
<p className="muted">Lade Roadmaps…</p>
|
||||
) : (
|
||||
<>
|
||||
{!activeRoadmaps.length ? (
|
||||
<EmptyState
|
||||
icon={<IconRoute size={40} />}
|
||||
title="Noch keine aktiven Plaene"
|
||||
hint="Erstelle zu einem Projekt einen Plan — das zugehoerige Projektticket wird automatisch angelegt."
|
||||
action={availableProjects.length
|
||||
? <Button variant="primary" onClick={openCreate}><IconPlus size={16} /> Plan erstellen</Button>
|
||||
: null}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{activeRoadmaps.map(renderRoadmapCard)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completedRoadmaps.length > 0 && (
|
||||
<>
|
||||
<h3 style={{ margin: '28px 0 12px' }}>Archiv</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{completedRoadmaps.map(renderRoadmapCard)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="overlay">
|
||||
<span className="overlay-close" onClick={() => setShowCreate(false)}><FaTimes /></span>
|
||||
<div className="overlay-content" style={{ maxWidth: 480 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Neuen Plan erstellen</h2>
|
||||
<p className="muted" style={{ fontSize: 13 }}>
|
||||
Zum Plan wird automatisch ein Projektticket erstellt, in dem der Fortschritt getrackt wird.
|
||||
</p>
|
||||
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<Field label="Projekt *">
|
||||
<select
|
||||
value={form.projectId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, projectId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>Projekt auswaehlen…</option>
|
||||
{availableProjects.map((p) => (
|
||||
<option key={p.$id} value={p.$id}>{p.projectName}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Titel des Plans" hint="Leer lassen fuer 'Plan: <Projektname>'">
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
maxLength={128}
|
||||
placeholder="z.B. Website-Relaunch"
|
||||
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Erstes Ziel" hint="Der Zielknoten ganz rechts — weitere Ziele kannst du im Board hinzufuegen">
|
||||
<input
|
||||
type="text"
|
||||
value={form.goalTitle}
|
||||
maxLength={128}
|
||||
placeholder="z.B. Website live"
|
||||
onChange={(e) => setForm((f) => ({ ...f, goalTitle: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button type="button" variant="ghost" onClick={() => setShowCreate(false)}>Abbrechen</Button>
|
||||
<Button type="submit" variant="primary" disabled={creating}>
|
||||
{creating ? 'Erstelle…' : 'Plan + Ticket erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user