feat(roadmaps): Hooks, Routing, Sidebar-Tab und Übersichtsseite
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Route path="/assets" element={<ProtectedRoute><AssetsPage /></ProtectedRoute>} />
|
||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||
<Route path="/roadmaps" element={<ProtectedRoute><RoadmapsPage /></ProtectedRoute>} />
|
||||
<Route path="/roadmaps/:id" element={<ProtectedRoute><RoadmapDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
|
||||
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -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: <IconTicket /> },
|
||||
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
|
||||
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||
],
|
||||
},
|
||||
|
||||
23
src/components/roadmap/RoadmapProgressBar.jsx
Normal file
23
src/components/roadmap/RoadmapProgressBar.jsx
Normal file
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: compact ? 120 : 180 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span className="muted">{percent}%</span>
|
||||
{!compact && <span className="faint">{done} von {total} fertig</span>}
|
||||
</div>
|
||||
<div style={{ height: 6, borderRadius: 3, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
background: percent >= 100 ? 'var(--ok, #22c55e)' : 'var(--accent, #3b82f6)',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
src/hooks/useRoadmapNodes.js
Normal file
135
src/hooks/useRoadmapNodes.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
86
src/hooks/useRoadmaps.js
Normal file
86
src/hooks/useRoadmaps.js
Normal file
@@ -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 }
|
||||
}
|
||||
221
src/pages/RoadmapsPage.jsx
Normal file
221
src/pages/RoadmapsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user