From d7f9cf75a380195da46458f84cf1ba16f29dc8a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 19:55:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(roadmaps):=20Hooks,=20Routing,=20Sidebar-T?= =?UTF-8?q?ab=20und=20=C3=9Cbersichtsseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/App.jsx | 4 + src/components/Navbar.jsx | 2 + src/components/roadmap/RoadmapProgressBar.jsx | 23 ++ src/hooks/useRoadmapNodes.js | 135 +++++++++++ src/hooks/useRoadmaps.js | 86 +++++++ src/pages/RoadmapsPage.jsx | 221 ++++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 src/components/roadmap/RoadmapProgressBar.jsx create mode 100644 src/hooks/useRoadmapNodes.js create mode 100644 src/hooks/useRoadmaps.js create mode 100644 src/pages/RoadmapsPage.jsx diff --git a/src/App.jsx b/src/App.jsx index e5760af..418f09d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,6 +8,8 @@ import DashboardPage from './pages/DashboardPage' import AssetsPage from './pages/AssetsPage' import PlanboardPage from './pages/PlanboardPage' import ProjectsPage from './pages/ProjectsPage' +import RoadmapsPage from './pages/RoadmapsPage' +import RoadmapDetailPage from './pages/RoadmapDetailPage' import ReportsPage from './pages/ReportsPage' import DocsPage from './pages/DocsPage' import AdminPage from './pages/AdminPage' @@ -53,6 +55,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index a6308fc..cfed9a5 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -9,6 +9,7 @@ import { IconFiles, IconFolders, IconCalendar, + IconRoute, IconLayoutDashboard, IconReport, IconDeviceDesktop, @@ -36,6 +37,7 @@ export default function Sidebar() { links: [ { label: 'Tickets', href: '/tickets', icon: }, { label: 'Planboard', href: '/planboard', icon: }, + { label: 'Roadmaps', href: '/roadmaps', icon: }, { label: 'Projekte', href: '/projects', icon: }, ], }, diff --git a/src/components/roadmap/RoadmapProgressBar.jsx b/src/components/roadmap/RoadmapProgressBar.jsx new file mode 100644 index 0000000..2374767 --- /dev/null +++ b/src/components/roadmap/RoadmapProgressBar.jsx @@ -0,0 +1,23 @@ +/** Fortschrittsbalken fuer Roadmaps: Prozent + "x von y Knoten fertig". */ +export default function RoadmapProgressBar({ progress, compact = false }) { + const { percent, done, total } = progress || { percent: 0, done: 0, total: 0 } + return ( +
+
+ {percent}% + {!compact && {done} von {total} fertig} +
+
+
= 100 ? 'var(--ok, #22c55e)' : 'var(--accent, #3b82f6)', + transition: 'width 0.3s ease', + }} + /> +
+
+ ) +} diff --git a/src/hooks/useRoadmapNodes.js b/src/hooks/useRoadmapNodes.js new file mode 100644 index 0000000..40a654f --- /dev/null +++ b/src/hooks/useRoadmapNodes.js @@ -0,0 +1,135 @@ +import { useState, useCallback } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, ID, isDemoMode } from '../lib/appwrite' +import { + fetchRoadmapNodes, + logNodeStatusWorksheet, + allGoalsDone, + NODE_KIND, + NODE_STATUS, +} from '../lib/roadmaps' + +export function useRoadmapNodes() { + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchNodes = useCallback(async (roadmapId) => { + if (!roadmapId || isDemoMode) return [] + setLoading(true) + setError(null) + try { + const docs = await fetchRoadmapNodes(roadmapId) + setNodes(docs) + return docs + } catch (err) { + setError(err.message) + return [] + } finally { + setLoading(false) + } + }, []) + + const createNode = async (roadmapId, { title, column, row, kind }) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + try { + const now = new Date().toISOString() + const doc = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), { + roadmapId, + title: title || 'Neuer Schritt', + kind: kind || NODE_KIND.STEP, + column, + row, + status: NODE_STATUS.OPEN, + createdAt: now, + updatedAt: now, + }) + setNodes((prev) => [...prev, doc]) + return { success: true, data: doc } + } catch (err) { + return { success: false, error: err.message } + } + } + + const updateNode = async (nodeId, data) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + try { + const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, nodeId, { + ...data, + updatedAt: new Date().toISOString(), + }) + setNodes((prev) => prev.map((n) => (n.$id === nodeId ? doc : n))) + return { success: true, data: doc } + } catch (err) { + return { success: false, error: err.message } + } + } + + /** Knoten loeschen inkl. Bereinigung eingehender Kanten bei den Nachfolgern. */ + const deleteNode = async (node) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + if (node.kind === NODE_KIND.START) { + return { success: false, error: 'Der Start-Knoten kann nicht geloescht werden.' } + } + if (node.kind === NODE_KIND.GOAL && nodes.filter((n) => n.kind === NODE_KIND.GOAL).length <= 1) { + return { success: false, error: 'Ein Plan braucht mindestens ein Ziel.' } + } + try { + const successors = nodes.filter((n) => (n.sourceIds || []).includes(node.$id)) + await Promise.all(successors.map((n) => + databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, n.$id, { + sourceIds: (n.sourceIds || []).filter((id) => id !== node.$id), + updatedAt: new Date().toISOString(), + }) + )) + await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id) + setNodes((prev) => prev + .filter((n) => n.$id !== node.$id) + .map((n) => ((n.sourceIds || []).includes(node.$id) + ? { ...n, sourceIds: n.sourceIds.filter((id) => id !== node.$id) } + : n)) + ) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + /** Kante anlegen: immer vorwaerts (Zielspalte > Quellspalte), keine Duplikate. */ + const connectNodes = async (source, target) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + if (source.$id === target.$id) return { success: false, error: 'Knoten kann nicht mit sich selbst verbunden werden.' } + if (target.column <= source.column) { + return { success: false, error: 'Verbindungen laufen immer vorwaerts (nach rechts).' } + } + if ((target.sourceIds || []).includes(source.$id)) { + return { success: false, error: 'Diese Verbindung existiert bereits.' } + } + return updateNode(target.$id, { sourceIds: [...(target.sourceIds || []), source.$id] }) + } + + const disconnectNodes = async (sourceId, target) => { + return updateNode(target.$id, { sourceIds: (target.sourceIds || []).filter((id) => id !== sourceId) }) + } + + /** Statuswechsel inkl. Worksheet-Log am Projektticket; meldet, ob alle Ziele fertig sind. */ + const setNodeStatus = async (roadmap, node, newStatus, user) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + const oldStatus = node.status || NODE_STATUS.OPEN + if (oldStatus === newStatus) return { success: true, allGoalsDone: false } + const result = await updateNode(node.$id, { status: newStatus }) + if (!result.success) return result + try { + await logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user) + } catch { + /* Verlaufslog ist optional - Statuswechsel bleibt gueltig */ + } + const updatedNodes = nodes.map((n) => (n.$id === node.$id ? { ...n, status: newStatus } : n)) + return { success: true, data: result.data, allGoalsDone: allGoalsDone(updatedNodes) } + } + + return { + nodes, loading, error, setNodes, + fetchNodes, createNode, updateNode, deleteNode, + connectNodes, disconnectNodes, setNodeStatus, + } +} diff --git a/src/hooks/useRoadmaps.js b/src/hooks/useRoadmaps.js new file mode 100644 index 0000000..6068a7a --- /dev/null +++ b/src/hooks/useRoadmaps.js @@ -0,0 +1,86 @@ +import { useState, useCallback } from 'react' +import { databases, DATABASE_ID, COLLECTIONS, Query, isDemoMode } from '../lib/appwrite' +import { + createRoadmapPlan, + deleteRoadmapPlan, + completeRoadmap, + fetchRoadmapByTicketId, +} from '../lib/roadmaps' + +export function useRoadmaps() { + const [roadmaps, setRoadmaps] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchAll = useCallback(async () => { + if (isDemoMode) return [] + setLoading(true) + setError(null) + try { + const response = await databases.listDocuments( + DATABASE_ID, COLLECTIONS.ROADMAPS, + [Query.orderDesc('$createdAt'), Query.limit(500)] + ) + setRoadmaps(response.documents) + return response.documents + } catch (err) { + setError(err.message) + return [] + } finally { + setLoading(false) + } + }, []) + + const fetchById = useCallback(async (roadmapId) => { + if (!roadmapId || isDemoMode) return null + try { + return await databases.getDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId) + } catch { + return null + } + }, []) + + const fetchByTicketId = useCallback(async (ticketId) => { + if (isDemoMode) return null + try { + return await fetchRoadmapByTicketId(ticketId) + } catch { + return null + } + }, []) + + const createRoadmap = async (project, data, user) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + try { + const result = await createRoadmapPlan(project, data, user) + setRoadmaps((prev) => [result.roadmap, ...prev]) + return { success: true, data: result } + } catch (err) { + return { success: false, error: err.message } + } + } + + const deleteRoadmap = async (roadmap, user) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + try { + await deleteRoadmapPlan(roadmap, user) + setRoadmaps((prev) => prev.filter((r) => r.$id !== roadmap.$id)) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + const completePlan = async (roadmap, user) => { + if (isDemoMode) return { success: false, error: 'Demo-Modus' } + try { + const updated = await completeRoadmap(roadmap, user) + setRoadmaps((prev) => prev.map((r) => (r.$id === updated.$id ? updated : r))) + return { success: true, data: updated } + } catch (err) { + return { success: false, error: err.message } + } + } + + return { roadmaps, loading, error, fetchAll, fetchById, fetchByTicketId, createRoadmap, deleteRoadmap, completePlan } +} diff --git a/src/pages/RoadmapsPage.jsx b/src/pages/RoadmapsPage.jsx new file mode 100644 index 0000000..5e60492 --- /dev/null +++ b/src/pages/RoadmapsPage.jsx @@ -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 ( + +
+
+ + {roadmap.title} + +
+ Projekt: {roadmap.projectName || '-'} + {roadmap.customerName ? ` · Kunde: ${roadmap.customerName}` : ''} +
+ {roadmap.woid && ( +
+ + Projektticket WOID {roadmap.woid} +
+ )} +
+
+ {roadmap.status === ROADMAP_STATUS.COMPLETED + ? Abgeschlossen + : progress && } +
+ + {roadmap.status === ROADMAP_STATUS.ACTIVE && ( + + )} +
+
+
+
+ ) + } + + return ( +
+ + Plan erstellen + + } + /> + + {loading && !roadmaps.length ? ( +

Lade Roadmaps…

+ ) : ( + <> + {!activeRoadmaps.length ? ( + } + title="Noch keine aktiven Plaene" + hint="Erstelle zu einem Projekt einen Plan — das zugehoerige Projektticket wird automatisch angelegt." + action={availableProjects.length + ? + : null} + /> + ) : ( +
+ {activeRoadmaps.map(renderRoadmapCard)} +
+ )} + + {completedRoadmaps.length > 0 && ( + <> +

Archiv

+
+ {completedRoadmaps.map(renderRoadmapCard)} +
+ + )} + + )} + + {showCreate && ( +
+ setShowCreate(false)}> +
+

Neuen Plan erstellen

+

+ Zum Plan wird automatisch ein Projektticket erstellt, in dem der Fortschritt getrackt wird. +

+
+ + + + + setForm((f) => ({ ...f, title: e.target.value }))} + /> + + + setForm((f) => ({ ...f, goalTitle: e.target.value }))} + /> + + {error &&

{error}

} +
+ + +
+
+
+
+ )} +
+ ) +}