feat(roadmaps): Hooks, Routing, Sidebar-Tab und Übersichtsseite

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5 committed 2026-07-22 19:55:47 +00:00
1 parent 5361800ca9
commit d7f9cf75a3
6 files changed
+471

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
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="success">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>
)
}