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:
ClaudeandClaude Opus 5.5 committed 2026-09-27 19:39:25 +00:00
1 parent 50d237a932
commit 7b31cb5368
16 files changed
+11 -1527

No files matched your search

+1 -3
View File
@@ -8,7 +8,6 @@ import {
IconReportMoney,
IconFolders,
IconCalendar,
IconRoute,
IconSettings,
IconLogout,
IconLayoutSidebarLeftCollapse,
@@ -26,11 +25,10 @@ export default function Sidebar() {
navigate('/login')
}
// Eine flache Liste: fuer ein kleines Team reichen acht Eintraege ohne Gruppen
// Eine flache Liste: fuer ein kleines Team reichen sieben Eintraege ohne Gruppen
const 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 /> },
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
+8 -18
View File
@@ -3,13 +3,12 @@ import { Link } from 'react-router-dom'
import { IconFolders } from '@tabler/icons-react'
import { StatusPill } from './ui/StatusPill'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { fetchRoadmapByTicketId } from '../lib/roadmaps'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
/**
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugehoerige Projekt,
* alle anderen Tickets desselben Kunden (klickbar) und den Link zum aktiven Plan.
* Rendert nichts, wenn das Ticket keinen Projektbezug hat.
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugewiesene Projekt
* und alle anderen Tickets desselben Kunden (klickbar).
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
*/
export default function ProjectContextPanel({ ticket }) {
const { fetchByTicketId } = useWebsiteProjects()
@@ -19,16 +18,12 @@ export default function ProjectContextPanel({ ticket }) {
let cancelled = false
async function load() {
try {
// Projektbezug: Roadmap dieses Tickets oder direkt zugewiesenes Projekt
const [roadmap, assignedProjects] = await Promise.all([
fetchRoadmapByTicketId(ticket.$id).catch(() => null),
fetchByTicketId(ticket.$id),
])
const projectName = roadmap?.projectName || assignedProjects[0]?.projectName || ''
if (!roadmap && !assignedProjects.length) {
const assignedProjects = await fetchByTicketId(ticket.$id)
if (!assignedProjects.length) {
if (!cancelled) setContext(null)
return
}
const projectName = assignedProjects[0].projectName || ''
let otherTickets = []
if (ticket.customerId) {
@@ -39,7 +34,7 @@ export default function ProjectContextPanel({ ticket }) {
])
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
}
if (!cancelled) setContext({ roadmap, projectName, otherTickets })
if (!cancelled) setContext({ projectName, otherTickets })
} catch {
if (!cancelled) setContext(null)
}
@@ -49,7 +44,7 @@ export default function ProjectContextPanel({ ticket }) {
}, [ticket.$id, ticket.customerId, fetchByTicketId])
if (!context) return null
const { roadmap, projectName, otherTickets } = context
const { projectName, otherTickets } = context
return (
<div className="ui-card pad" style={{ marginTop: 16 }}>
@@ -57,11 +52,6 @@ export default function ProjectContextPanel({ ticket }) {
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<IconFolders size={18} /> Projekt: {projectName || '-'}
</h5>
{roadmap && (
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">
Plan: {roadmap.title}
</Link>
)}
</div>
{!ticket.customerId ? (
-2
View File
@@ -10,7 +10,6 @@ import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import WorksheetSummary from './WorksheetSummary'
import TicketProjects from './TicketProjects'
import TicketRoadmapPanel from './TicketRoadmapPanel'
import ProjectContextPanel from './ProjectContextPanel'
import InvoicePanel from './InvoicePanel'
import Tabs from './ui/Tabs'
@@ -121,7 +120,6 @@ export default function TicketCard({ ticket, onUpdate }) {
</p>
</div>
<ProjectContextPanel ticket={ticket} />
<TicketRoadmapPanel ticket={ticket} />
<TicketProjects ticket={ticket} />
</div>
)}
-94
View File
@@ -1,94 +0,0 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { IconRoute, IconAlertTriangle } from '@tabler/icons-react'
import RoadmapProgressBar from './roadmap/RoadmapProgressBar'
import {
fetchRoadmapByTicketId,
fetchRoadmapNodes,
computeProgress,
isNodeOverdue,
NODE_KIND,
NODE_STATUS,
ROADMAP_STATUS,
} from '../lib/roadmaps'
/**
* Roadmap-Tracking im Projektticket: Fortschritt, Mini-Uebersicht und Link zum Board.
* Rendert nichts, wenn zum Ticket keine Roadmap existiert.
*/
export default function TicketRoadmapPanel({ ticket }) {
const [roadmap, setRoadmap] = useState(null)
const [nodes, setNodes] = useState([])
const [loaded, setLoaded] = useState(false)
useEffect(() => {
let cancelled = false
async function load() {
try {
const doc = await fetchRoadmapByTicketId(ticket.$id)
if (cancelled) return
setRoadmap(doc)
if (doc) setNodes(await fetchRoadmapNodes(doc.$id))
} catch {
/* Panel ist optional */
} finally {
if (!cancelled) setLoaded(true)
}
}
load()
return () => { cancelled = true }
}, [ticket.$id])
if (!loaded || !roadmap) return null
const progress = computeProgress(nodes)
const relevant = nodes.filter((n) => n.kind !== NODE_KIND.START)
const counts = {
open: relevant.filter((n) => (n.status || NODE_STATUS.OPEN) === NODE_STATUS.OPEN).length,
inProgress: relevant.filter((n) => n.status === NODE_STATUS.IN_PROGRESS).length,
done: relevant.filter((n) => n.status === NODE_STATUS.DONE).length,
}
const nextDue = relevant
.filter((n) => n.deadline && n.status !== NODE_STATUS.DONE)
.sort((a, b) => a.deadline.localeCompare(b.deadline))
.slice(0, 3)
const closedButActive = ticket.status === 'Closed' && roadmap.status === ROADMAP_STATUS.ACTIVE
return (
<div className="ui-card pad" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 10 }}>
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<IconRoute size={18} /> Roadmap: {roadmap.title}
</h5>
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">Zur Roadmap</Link>
</div>
<RoadmapProgressBar progress={progress} />
<div className="muted" style={{ display: 'flex', gap: 14, fontSize: 13, marginTop: 10, flexWrap: 'wrap' }}>
<span>Offen: {counts.open}</span>
<span>In Arbeit: {counts.inProgress}</span>
<span>Fertig: {counts.done}</span>
{roadmap.status === ROADMAP_STATUS.COMPLETED && <span style={{ color: '#34d399' }}>Plan abgeschlossen</span>}
</div>
{nextDue.length > 0 && (
<div style={{ marginTop: 10, fontSize: 13 }}>
<span className="faint">Naechste Deadlines: </span>
{nextDue.map((n, i) => (
<span key={n.$id} style={isNodeOverdue(n) ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
{i > 0 && ' · '}{n.title} ({n.deadline.split('-').reverse().join('.')})
</span>
))}
</div>
)}
{closedButActive && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: 13, color: '#fbbf24' }}>
<IconAlertTriangle size={16} style={{ flexShrink: 0 }} />
Ticket ist geschlossen, aber der Plan ist erst zu {progress.percent}% fertig.
</div>
)}
</div>
)
}
-166
View File
@@ -1,166 +0,0 @@
import { useEffect, useState } from 'react'
import { IconTrash, IconX } from '@tabler/icons-react'
import { Card, Button, Field } from '../ui'
import { useEmployees } from '../../hooks/useEmployees'
import { NODE_KIND, NODE_STATUS, NODE_STATUS_LABELS } from '../../lib/roadmaps'
const STATUS_ORDER = [NODE_STATUS.OPEN, NODE_STATUS.IN_PROGRESS, NODE_STATUS.DONE]
/**
* Seitliches Detail-Panel fuer den ausgewaehlten Knoten:
* Titel, Status, Zustaendiger, Deadline, Notizen, eingehende Verbindungen.
*/
export default function NodeDetailPanel({
node,
nodes,
readOnly,
onClose,
onSave,
onSetStatus,
onDelete,
onDisconnect,
}) {
const { employees } = useEmployees()
const [form, setForm] = useState({ title: '', assignedTo: '', deadline: '', notes: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setForm({
title: node.title || '',
assignedTo: node.assignedTo || '',
deadline: node.deadline || '',
notes: node.notes || '',
})
setError('')
}, [node.$id]) // eslint-disable-line react-hooks/exhaustive-deps
const isStart = node.kind === NODE_KIND.START
const incoming = (node.sourceIds || [])
.map((id) => nodes.find((n) => n.$id === id))
.filter(Boolean)
const handleSave = async () => {
if (!form.title.trim()) {
setError('Titel darf nicht leer sein.')
return
}
setSaving(true)
setError('')
const employee = employees.find((e) => e.userId === form.assignedTo)
const result = await onSave(node, {
title: form.title.trim(),
assignedTo: form.assignedTo,
assignedName: employee?.displayName || '',
deadline: form.deadline,
notes: form.notes,
})
setSaving(false)
if (!result.success) setError(result.error)
}
return (
<Card
title={isStart ? 'Start-Knoten' : node.kind === NODE_KIND.GOAL ? 'Ziel-Knoten' : 'Schritt'}
actions={<Button variant="ghost" onClick={onClose}><IconX size={16} /></Button>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Field label="Titel">
<input
type="text"
value={form.title}
maxLength={128}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
/>
</Field>
{!isStart && (
<Field label="Status">
<div style={{ display: 'flex', gap: 6 }}>
{STATUS_ORDER.map((status) => (
<Button
key={status}
variant={node.status === status ? 'primary' : 'default'}
disabled={readOnly}
onClick={() => onSetStatus(node, status)}
>
{NODE_STATUS_LABELS[status]}
</Button>
))}
</div>
</Field>
)}
{!isStart && (
<Field label="Zustaendig">
<select
value={form.assignedTo}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, assignedTo: e.target.value }))}
>
<option value="">Niemand</option>
{employees.map((e) => (
<option key={e.userId} value={e.userId}>{e.displayName}</option>
))}
</select>
</Field>
)}
{!isStart && (
<Field label="Deadline">
<input
type="date"
value={form.deadline}
disabled={readOnly}
onChange={(e) => setForm((f) => ({ ...f, deadline: e.target.value }))}
/>
</Field>
)}
<Field label="Notizen">
<textarea
rows={4}
value={form.notes}
maxLength={2000}
disabled={readOnly}
placeholder="Details, Anforderungen, Links…"
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</Field>
{incoming.length > 0 && (
<Field label="Eingehende Verbindungen">
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{incoming.map((source) => (
<div key={source.$id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
<span className="muted">{source.title}</span>
{!readOnly && (
<Button variant="ghost" title="Verbindung loesen" onClick={() => onDisconnect(source.$id, node)}>
<IconX size={14} />
</Button>
)}
</div>
))}
</div>
</Field>
)}
{error && <p style={{ color: 'var(--danger, #ef4444)', fontSize: 13 }}>{error}</p>}
{!readOnly && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8 }}>
{!isStart ? (
<Button variant="ghost" onClick={() => onDelete(node)} title="Knoten loeschen">
<IconTrash size={15} /> Loeschen
</Button>
) : <span />}
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Speichere…' : 'Speichern'}
</Button>
</div>
)}
</div>
</Card>
)
}
-168
View File
@@ -1,168 +0,0 @@
import { useLayoutEffect, useRef, useState, useCallback } from 'react'
import { IconPlus } from '@tabler/icons-react'
import RoadmapNodeCard from './RoadmapNodeCard'
import { NODE_KIND } from '../../lib/roadmaps'
const COLUMN_WIDTH = 230
const COLUMN_GAP = 48
/**
* Steckbrett: Spalten von links (Start) nach rechts (Ziele), Knoten vertikal
* gestapelt, Kanten als SVG-Overlay. Verbinden per Klick-Modus:
* Connect-Handle am Quellknoten -> gueltige Ziele (Spalte weiter rechts) anklicken.
*/
export default function RoadmapBoard({
roadmap,
nodes,
selectedNode,
readOnly,
onSelectNode,
onAddNode,
onConnect,
}) {
const containerRef = useRef(null)
const nodeRefs = useRef({})
const [edges, setEdges] = useState([])
const [connectSource, setConnectSource] = useState(null)
const columnCount = Math.max(roadmap.columnCount || 3, 2)
const columns = Array.from({ length: columnCount }, (_, c) =>
nodes.filter((n) => n.column === c).sort((a, b) => (a.row || 0) - (b.row || 0))
)
const setNodeRef = useCallback((id) => (el) => {
if (el) nodeRefs.current[id] = el
else delete nodeRefs.current[id]
}, [])
// Kanten-Geometrie nach jedem Layout neu berechnen (Anker relativ zum Board)
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
const compute = () => {
const base = container.getBoundingClientRect()
const next = []
for (const node of nodes) {
for (const sourceId of node.sourceIds || []) {
const sourceEl = nodeRefs.current[sourceId]
const targetEl = nodeRefs.current[node.$id]
if (!sourceEl || !targetEl) continue
const s = sourceEl.getBoundingClientRect()
const t = targetEl.getBoundingClientRect()
next.push({
id: `${sourceId}-${node.$id}`,
x1: s.right - base.left,
y1: s.top + s.height / 2 - base.top,
x2: t.left - base.left,
y2: t.top + t.height / 2 - base.top,
})
}
}
setEdges(next)
}
compute()
const observer = new ResizeObserver(compute)
observer.observe(container)
return () => observer.disconnect()
}, [nodes, columnCount])
const handleStartConnect = (node) => setConnectSource(node)
const handleSelect = async (node) => {
if (connectSource) {
if (node.column > connectSource.column) {
await onConnect(connectSource, node)
}
setConnectSource(null)
return
}
onSelectNode(node)
}
const columnLabel = (c) => {
if (c === 0) return 'Start'
if (c === columnCount - 1) return 'Ziele'
return `Phase ${c}`
}
return (
<div style={{ overflowX: 'auto', paddingBottom: 8 }}>
{connectSource && (
<div
className="muted"
style={{ fontSize: 13, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 10 }}
>
Verbinden von <strong>{connectSource.title}</strong>: Zielknoten rechts davon anklicken.
<button type="button" className="ui-btn" onClick={() => setConnectSource(null)}>Abbrechen</button>
</div>
)}
<div
ref={containerRef}
style={{
position: 'relative',
display: 'grid',
gridTemplateColumns: `repeat(${columnCount}, ${COLUMN_WIDTH}px)`,
gap: `16px ${COLUMN_GAP}px`,
alignItems: 'start',
minWidth: columnCount * (COLUMN_WIDTH + COLUMN_GAP),
padding: '8px 4px',
}}
>
<svg
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }}
>
<defs>
<marker id="roadmap-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="var(--text-muted, #94a3b8)" />
</marker>
</defs>
{edges.map((e) => {
const dx = Math.max((e.x2 - e.x1) / 2, 24)
return (
<path
key={e.id}
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
fill="none"
stroke="var(--text-muted, #94a3b8)"
strokeWidth="1.5"
markerEnd="url(#roadmap-arrow)"
opacity="0.7"
/>
)
})}
</svg>
{columns.map((columnNodes, c) => (
<div key={c} style={{ display: 'flex', flexDirection: 'column', gap: 14, zIndex: 1 }}>
<div className="faint" style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
{columnLabel(c)}
</div>
{columnNodes.map((node) => (
<RoadmapNodeCard
key={node.$id}
node={node}
selected={selectedNode?.$id === node.$id}
readOnly={readOnly}
connectSource={connectSource}
isValidTarget={Boolean(connectSource) && node.column > connectSource.column}
onSelect={handleSelect}
onStartConnect={handleStartConnect}
nodeRef={setNodeRef(node.$id)}
/>
))}
{!readOnly && c > 0 && (
<button
type="button"
className="ui-btn"
style={{ borderStyle: 'dashed', fontSize: 12 }}
onClick={() => onAddNode(c, c === columnCount - 1 ? NODE_KIND.GOAL : NODE_KIND.STEP, columnNodes.length)}
>
<IconPlus size={14} /> {c === columnCount - 1 ? 'Ziel' : 'Schritt'}
</button>
)}
</div>
))}
</div>
</div>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { IconFlag, IconPlayerPlay, IconLink } from '@tabler/icons-react'
import { NODE_KIND, NODE_STATUS, isNodeOverdue } from '../../lib/roadmaps'
const STATUS_COLORS = {
[NODE_STATUS.OPEN]: 'var(--text-muted, #94a3b8)',
[NODE_STATUS.IN_PROGRESS]: '#fbbf24',
[NODE_STATUS.DONE]: '#34d399',
}
/**
* Knoten-Kachel im Steckbrett.
* connectMode: Board wartet auf Zielauswahl; isValidTarget markiert erlaubte Ziele.
*/
export default function RoadmapNodeCard({
node,
selected,
readOnly,
connectSource,
isValidTarget,
onSelect,
onStartConnect,
nodeRef,
}) {
const overdue = isNodeOverdue(node)
const isStart = node.kind === NODE_KIND.START
const isGoal = node.kind === NODE_KIND.GOAL
const connecting = Boolean(connectSource)
const isConnectSource = connectSource?.$id === node.$id
const borderColor = isConnectSource
? 'var(--accent, #3b82f6)'
: connecting && isValidTarget
? '#34d399'
: selected
? 'var(--accent, #3b82f6)'
: overdue
? 'var(--danger, #ef4444)'
: 'var(--border)'
return (
<div
ref={nodeRef}
onClick={() => onSelect(node)}
style={{
position: 'relative',
background: isGoal ? 'rgba(59,130,246,0.10)' : 'var(--surface-2, rgba(45,55,72,0.65))',
border: `2px solid ${borderColor}`,
borderRadius: 10,
padding: '10px 12px',
cursor: 'pointer',
opacity: connecting && !isValidTarget && !isConnectSource ? 0.45 : 1,
transition: 'border-color 0.15s ease, opacity 0.15s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isStart && <IconPlayerPlay size={15} style={{ flexShrink: 0 }} />}
{isGoal && <IconFlag size={15} style={{ flexShrink: 0, color: '#93c5fd' }} />}
{!isStart && (
<span
title={`Status: ${node.status || 'open'}`}
style={{
width: 10, height: 10, borderRadius: 5, flexShrink: 0,
background: STATUS_COLORS[node.status] || STATUS_COLORS.open,
}}
/>
)}
<span style={{ fontWeight: 600, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.title}
</span>
</div>
{(node.assignedName || node.deadline) && (
<div style={{ display: 'flex', gap: 8, marginTop: 6, fontSize: 11 }} className="faint">
{node.assignedName && <span>{node.assignedName}</span>}
{node.deadline && (
<span style={overdue ? { color: 'var(--danger, #ef4444)', fontWeight: 700 } : undefined}>
bis {node.deadline.split('-').reverse().join('.')}
</span>
)}
</div>
)}
{!readOnly && !isGoal && !connecting && (
<button
type="button"
title="Verbindung von hier starten"
onClick={(e) => { e.stopPropagation(); onStartConnect(node) }}
style={{
position: 'absolute', right: -11, top: '50%', transform: 'translateY(-50%)',
width: 22, height: 22, borderRadius: 11,
border: '1px solid var(--border)', background: 'var(--surface, #1e293b)',
color: 'var(--text-muted)', display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'crosshair', padding: 0,
}}
>
<IconLink size={12} />
</button>
)}
</div>
)
}
@@ -1,23 +0,0 @@
/** 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>
)
}