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
@@ -27,7 +27,7 @@ Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`).
|
|||||||
| `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) |
|
| `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) |
|
||||||
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
|
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
|
||||||
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
|
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
|
||||||
| `roadmaps`, `roadmapNodes` | Projektpläne | Plan mit Schritten, verknüpft mit einem Ticket |
|
| `roadmaps`, `roadmapNodes` | alte Projektpläne | wird von WOMS nicht mehr gelesen (Plan mit Schritten, verknüpft mit einem Ticket) |
|
||||||
| `config` | alte Einstellungen | wird von WOMS nicht mehr gelesen (Prioritäten, alte Listen) |
|
| `config` | alte Einstellungen | wird von WOMS nicht mehr gelesen (Prioritäten, alte Listen) |
|
||||||
|
|
||||||
Zusammenspiel mit anderen Systemen:
|
Zusammenspiel mit anderen Systemen:
|
||||||
|
|||||||
+1
-5
@@ -6,8 +6,6 @@ import LoginPage from './pages/LoginPage'
|
|||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
import RoadmapsPage from './pages/RoadmapsPage'
|
|
||||||
import RoadmapDetailPage from './pages/RoadmapDetailPage'
|
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
import CustomersPage from './pages/CustomersPage'
|
import CustomersPage from './pages/CustomersPage'
|
||||||
import LeadsPage from './pages/LeadsPage'
|
import LeadsPage from './pages/LeadsPage'
|
||||||
@@ -47,10 +45,8 @@ function AppRoutes() {
|
|||||||
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
|
||||||
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
|
||||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></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="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||||
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs) landen bei den Tickets */}
|
{/* Alte Links (z. B. die entfernten Seiten Assets, Reports, Docs, Roadmaps) landen bei den Tickets */}
|
||||||
<Route path="*" element={<Navigate to="/tickets" replace />} />
|
<Route path="*" element={<Navigate to="/tickets" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
IconReportMoney,
|
IconReportMoney,
|
||||||
IconFolders,
|
IconFolders,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconRoute,
|
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconLayoutSidebarLeftCollapse,
|
IconLayoutSidebarLeftCollapse,
|
||||||
@@ -26,11 +25,10 @@ export default function Sidebar() {
|
|||||||
navigate('/login')
|
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 = [
|
const links = [
|
||||||
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
{ label: 'Tickets', href: '/tickets', icon: <IconTicket /> },
|
||||||
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
{ label: 'Planboard', href: '/planboard', icon: <IconCalendar /> },
|
||||||
{ label: 'Roadmaps', href: '/roadmaps', icon: <IconRoute /> },
|
|
||||||
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
{ label: 'Projekte', href: '/projects', icon: <IconFolders /> },
|
||||||
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
|
||||||
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
|
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow /> },
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import { Link } from 'react-router-dom'
|
|||||||
import { IconFolders } from '@tabler/icons-react'
|
import { IconFolders } from '@tabler/icons-react'
|
||||||
import { StatusPill } from './ui/StatusPill'
|
import { StatusPill } from './ui/StatusPill'
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { fetchRoadmapByTicketId } from '../lib/roadmaps'
|
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugehoerige Projekt,
|
* Projekt-Kontextbox im Ticket: zeigt automatisch das zugewiesene Projekt
|
||||||
* alle anderen Tickets desselben Kunden (klickbar) und den Link zum aktiven Plan.
|
* und alle anderen Tickets desselben Kunden (klickbar).
|
||||||
* Rendert nichts, wenn das Ticket keinen Projektbezug hat.
|
* Rendert nichts, wenn dem Ticket kein Projekt zugewiesen ist.
|
||||||
*/
|
*/
|
||||||
export default function ProjectContextPanel({ ticket }) {
|
export default function ProjectContextPanel({ ticket }) {
|
||||||
const { fetchByTicketId } = useWebsiteProjects()
|
const { fetchByTicketId } = useWebsiteProjects()
|
||||||
@@ -19,16 +18,12 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
// Projektbezug: Roadmap dieses Tickets oder direkt zugewiesenes Projekt
|
const assignedProjects = await fetchByTicketId(ticket.$id)
|
||||||
const [roadmap, assignedProjects] = await Promise.all([
|
if (!assignedProjects.length) {
|
||||||
fetchRoadmapByTicketId(ticket.$id).catch(() => null),
|
|
||||||
fetchByTicketId(ticket.$id),
|
|
||||||
])
|
|
||||||
const projectName = roadmap?.projectName || assignedProjects[0]?.projectName || ''
|
|
||||||
if (!roadmap && !assignedProjects.length) {
|
|
||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const projectName = assignedProjects[0].projectName || ''
|
||||||
|
|
||||||
let otherTickets = []
|
let otherTickets = []
|
||||||
if (ticket.customerId) {
|
if (ticket.customerId) {
|
||||||
@@ -39,7 +34,7 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
])
|
])
|
||||||
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
|
||||||
}
|
}
|
||||||
if (!cancelled) setContext({ roadmap, projectName, otherTickets })
|
if (!cancelled) setContext({ projectName, otherTickets })
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setContext(null)
|
if (!cancelled) setContext(null)
|
||||||
}
|
}
|
||||||
@@ -49,7 +44,7 @@ export default function ProjectContextPanel({ ticket }) {
|
|||||||
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
}, [ticket.$id, ticket.customerId, fetchByTicketId])
|
||||||
|
|
||||||
if (!context) return null
|
if (!context) return null
|
||||||
const { roadmap, projectName, otherTickets } = context
|
const { projectName, otherTickets } = context
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ui-card pad" style={{ marginTop: 16 }}>
|
<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 }}>
|
<h5 style={{ fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<IconFolders size={18} /> Projekt: {projectName || '-'}
|
<IconFolders size={18} /> Projekt: {projectName || '-'}
|
||||||
</h5>
|
</h5>
|
||||||
{roadmap && (
|
|
||||||
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">
|
|
||||||
Plan: {roadmap.title}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!ticket.customerId ? (
|
{!ticket.customerId ? (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import WorksheetList from './WorksheetList'
|
|||||||
import WorksheetTimesModal from './WorksheetTimesModal'
|
import WorksheetTimesModal from './WorksheetTimesModal'
|
||||||
import WorksheetSummary from './WorksheetSummary'
|
import WorksheetSummary from './WorksheetSummary'
|
||||||
import TicketProjects from './TicketProjects'
|
import TicketProjects from './TicketProjects'
|
||||||
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
|
||||||
import ProjectContextPanel from './ProjectContextPanel'
|
import ProjectContextPanel from './ProjectContextPanel'
|
||||||
import InvoicePanel from './InvoicePanel'
|
import InvoicePanel from './InvoicePanel'
|
||||||
import Tabs from './ui/Tabs'
|
import Tabs from './ui/Tabs'
|
||||||
@@ -121,7 +120,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProjectContextPanel ticket={ticket} />
|
<ProjectContextPanel ticket={ticket} />
|
||||||
<TicketRoadmapPanel ticket={ticket} />
|
|
||||||
<TicketProjects ticket={ticket} />
|
<TicketProjects ticket={ticket} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react'
|
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, ID } 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) 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 }) => {
|
|
||||||
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) => {
|
|
||||||
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 (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 (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) => {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react'
|
|
||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } 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 () => {
|
|
||||||
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) return null
|
|
||||||
try {
|
|
||||||
return await databases.getDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchByTicketId = useCallback(async (ticketId) => {
|
|
||||||
try {
|
|
||||||
return await fetchRoadmapByTicketId(ticketId)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const createRoadmap = async (project, data, user) => {
|
|
||||||
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 updateRoadmap = async (roadmapId, data) => {
|
|
||||||
try {
|
|
||||||
const doc = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmapId, {
|
|
||||||
...data,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
setRoadmaps((prev) => prev.map((r) => (r.$id === roadmapId ? doc : r)))
|
|
||||||
return { success: true, data: doc }
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err.message }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteRoadmap = async (roadmap, user) => {
|
|
||||||
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) => {
|
|
||||||
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, updateRoadmap, deleteRoadmap, completePlan }
|
|
||||||
}
|
|
||||||
@@ -35,12 +35,8 @@ export const COLLECTIONS = {
|
|||||||
LEADS: 'leads',
|
LEADS: 'leads',
|
||||||
LEAD_SETTINGS: 'leadSettings',
|
LEAD_SETTINGS: 'leadSettings',
|
||||||
LEAD_PIPELINE: 'leadPipeline',
|
LEAD_PIPELINE: 'leadPipeline',
|
||||||
ROADMAPS: 'roadmaps',
|
|
||||||
ROADMAP_NODES: 'roadmapNodes',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROJECT_TICKET_TYPE = 'Project'
|
|
||||||
|
|
||||||
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
|
/** Prüft ob vermutlich eine Session existiert (cookieFallback oder Same-Origin-Cookie). */
|
||||||
export function hasAppwriteSession() {
|
export function hasAppwriteSession() {
|
||||||
if (typeof window === 'undefined' || !projectId) return false
|
if (typeof window === 'undefined' || !projectId) return false
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS, PROJECT_TICKET_TYPE, Query, ID } from './appwrite'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roadmaps: projektspezifische Plaene als Steckbrett (Spalten-Raster).
|
|
||||||
* Ein Dokument pro Knoten; Kanten als sourceIds (Vorgaenger) am Zielknoten.
|
|
||||||
* Genau 1 aktiver Plan pro Projekt (Pruefung hier, nicht per Index erzwingbar).
|
|
||||||
* Jeder Plan erzeugt automatisch ein Projektticket (Vorbild: createAcquisitionTicket in leads.js).
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const ROADMAP_STATUS = { ACTIVE: 'active', COMPLETED: 'completed' }
|
|
||||||
export const NODE_STATUS = { OPEN: 'open', IN_PROGRESS: 'in_progress', DONE: 'done' }
|
|
||||||
export const NODE_KIND = { START: 'start', STEP: 'step', GOAL: 'goal' }
|
|
||||||
|
|
||||||
export const NODE_STATUS_LABELS = {
|
|
||||||
open: 'Offen',
|
|
||||||
in_progress: 'In Arbeit',
|
|
||||||
done: 'Fertig',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fortschritt in Prozent: fertige Knoten / alle Knoten ausser Start. */
|
|
||||||
export function computeProgress(nodes) {
|
|
||||||
const relevant = (nodes || []).filter((n) => n.kind !== NODE_KIND.START)
|
|
||||||
if (!relevant.length) return { percent: 0, done: 0, total: 0 }
|
|
||||||
const done = relevant.filter((n) => n.status === NODE_STATUS.DONE).length
|
|
||||||
return { percent: Math.round((done / relevant.length) * 100), done, total: relevant.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allGoalsDone(nodes) {
|
|
||||||
const goals = (nodes || []).filter((n) => n.kind === NODE_KIND.GOAL)
|
|
||||||
return goals.length > 0 && goals.every((n) => n.status === NODE_STATUS.DONE)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNodeOverdue(node) {
|
|
||||||
if (!node?.deadline || node.status === NODE_STATUS.DONE) return false
|
|
||||||
return node.deadline < new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Aktiver Plan eines Projekts (oder null). */
|
|
||||||
export async function fetchActiveRoadmapByProject(projectId) {
|
|
||||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
|
|
||||||
Query.equal('projectId', projectId),
|
|
||||||
Query.equal('status', ROADMAP_STATUS.ACTIVE),
|
|
||||||
Query.limit(1),
|
|
||||||
])
|
|
||||||
return res.documents[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRoadmapByTicketId(ticketId) {
|
|
||||||
if (!ticketId) return null
|
|
||||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAPS, [
|
|
||||||
Query.equal('ticketId', ticketId),
|
|
||||||
Query.limit(1),
|
|
||||||
])
|
|
||||||
return res.documents[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchRoadmapNodes(roadmapId) {
|
|
||||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, [
|
|
||||||
Query.equal('roadmapId', roadmapId),
|
|
||||||
Query.limit(500),
|
|
||||||
])
|
|
||||||
return res.documents
|
|
||||||
}
|
|
||||||
|
|
||||||
async function nextWoid() {
|
|
||||||
try {
|
|
||||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
|
||||||
Query.orderDesc('$createdAt'),
|
|
||||||
Query.limit(200),
|
|
||||||
])
|
|
||||||
const nums = res.documents.map((d) => parseInt(d.woid)).filter((n) => !isNaN(n) && n > 0)
|
|
||||||
return String((nums.length ? Math.max(...nums) : 9999) + 1)
|
|
||||||
} catch {
|
|
||||||
return String(10000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function nextWsid() {
|
|
||||||
try {
|
|
||||||
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
|
|
||||||
Query.orderDesc('wsid'),
|
|
||||||
Query.limit(1),
|
|
||||||
])
|
|
||||||
const highest = parseInt(res.documents[0]?.wsid)
|
|
||||||
return String(isNaN(highest) ? 100000 : highest + 1)
|
|
||||||
} catch {
|
|
||||||
return String(100000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Automatisches Projektticket fuer einen neuen Plan. */
|
|
||||||
async function createRoadmapTicket(roadmap, project, user) {
|
|
||||||
const woid = await nextWoid()
|
|
||||||
const data = {
|
|
||||||
topic: `Projekt: ${project.projectName}`,
|
|
||||||
status: 'Open',
|
|
||||||
priority: 2,
|
|
||||||
woid,
|
|
||||||
type: PROJECT_TICKET_TYPE,
|
|
||||||
systemType: 'n/a',
|
|
||||||
serviceType: 'Remote',
|
|
||||||
assignedTo: user?.$id || '',
|
|
||||||
details:
|
|
||||||
`Automatisch erstelltes Projektticket fuer die Roadmap "${roadmap.title}" ` +
|
|
||||||
`zum Projekt "${project.projectName}".\n\n` +
|
|
||||||
`Der Planfortschritt wird hier getrackt: Jede Statusaenderung eines Roadmap-Knotens ` +
|
|
||||||
`erzeugt einen Verlaufseintrag (Worksheet). Sind alle Ziel-Knoten fertig, wird das ` +
|
|
||||||
`Ticket geschlossen und der Plan archiviert.`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
if (project.customerId) {
|
|
||||||
data.customerId = project.customerId
|
|
||||||
if (roadmap.customerName) data.customerName = roadmap.customerName
|
|
||||||
}
|
|
||||||
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legt einen neuen Plan an: Roadmap-Dokument + Start-Knoten + Ziel-Knoten + Auto-Ticket.
|
|
||||||
* Wirft, wenn das Projekt bereits einen aktiven Plan hat.
|
|
||||||
*/
|
|
||||||
export async function createRoadmapPlan(project, { title, goalTitle }, user) {
|
|
||||||
const existing = await fetchActiveRoadmapByProject(project.$id)
|
|
||||||
if (existing) {
|
|
||||||
throw new Error(`Das Projekt "${project.projectName}" hat bereits einen aktiven Plan.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let customerName = ''
|
|
||||||
if (project.customerId) {
|
|
||||||
try {
|
|
||||||
const customer = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, project.customerId)
|
|
||||||
customerName = customer.name || ''
|
|
||||||
} catch {
|
|
||||||
/* Kunde optional */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const columnCount = 3
|
|
||||||
const roadmap = await databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, ID.unique(), {
|
|
||||||
projectId: project.$id,
|
|
||||||
projectName: project.projectName || '',
|
|
||||||
customerId: project.customerId || '',
|
|
||||||
customerName,
|
|
||||||
title: title || `Plan: ${project.projectName}`,
|
|
||||||
status: ROADMAP_STATUS.ACTIVE,
|
|
||||||
columnCount,
|
|
||||||
createdBy: user?.$id || '',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
|
|
||||||
roadmapId: roadmap.$id,
|
|
||||||
title: 'Start',
|
|
||||||
kind: NODE_KIND.START,
|
|
||||||
column: 0,
|
|
||||||
row: 0,
|
|
||||||
status: NODE_STATUS.OPEN,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}),
|
|
||||||
databases.createDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, ID.unique(), {
|
|
||||||
roadmapId: roadmap.$id,
|
|
||||||
title: goalTitle || 'Ziel',
|
|
||||||
kind: NODE_KIND.GOAL,
|
|
||||||
column: columnCount - 1,
|
|
||||||
row: 0,
|
|
||||||
status: NODE_STATUS.OPEN,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
let ticket = null
|
|
||||||
try {
|
|
||||||
ticket = await createRoadmapTicket(roadmap, project, user)
|
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
|
|
||||||
ticketId: ticket.$id,
|
|
||||||
woid: ticket.woid,
|
|
||||||
})
|
|
||||||
roadmap.ticketId = ticket.$id
|
|
||||||
roadmap.woid = ticket.woid
|
|
||||||
} catch {
|
|
||||||
/* Ticket ist optional - Plan bleibt bestehen */
|
|
||||||
}
|
|
||||||
return { roadmap, ticket }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verlaufseintrag (Worksheet-Kommentar) am Projektticket, ohne den Ticket-Status zu aendern. */
|
|
||||||
export async function logRoadmapWorksheet(roadmap, detailsText, user) {
|
|
||||||
if (!roadmap?.ticketId) return null
|
|
||||||
let ticket
|
|
||||||
try {
|
|
||||||
ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
|
||||||
} catch {
|
|
||||||
return null // Ticket geloescht -> kein Log
|
|
||||||
}
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
const wsid = await nextWsid()
|
|
||||||
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
|
|
||||||
wsid,
|
|
||||||
woid: ticket.woid || roadmap.woid || '',
|
|
||||||
workorderId: ticket.$id,
|
|
||||||
employeeId: user?.$id || 'system',
|
|
||||||
employeeName: user?.name || user?.email || 'System',
|
|
||||||
serviceType: 'Remote',
|
|
||||||
oldStatus: ticket.status,
|
|
||||||
newStatus: ticket.status,
|
|
||||||
totalTime: 0,
|
|
||||||
startDate: today,
|
|
||||||
endDate: today,
|
|
||||||
details: detailsText,
|
|
||||||
isComment: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Knoten-Statuswechsel im Ticketverlauf dokumentieren. */
|
|
||||||
export async function logNodeStatusWorksheet(roadmap, node, oldStatus, newStatus, user) {
|
|
||||||
const from = NODE_STATUS_LABELS[oldStatus] || oldStatus || '-'
|
|
||||||
const to = NODE_STATUS_LABELS[newStatus] || newStatus
|
|
||||||
return logRoadmapWorksheet(roadmap, `Roadmap-Knoten "${node.title}": ${from} -> ${to}`, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Plan abschliessen: Roadmap archivieren, Ticket schliessen (falls noch offen)
|
|
||||||
* und den Abschluss im Verlauf dokumentieren.
|
|
||||||
*/
|
|
||||||
export async function completeRoadmap(roadmap, user) {
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id, {
|
|
||||||
status: ROADMAP_STATUS.COMPLETED,
|
|
||||||
completedAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
if (roadmap.ticketId) {
|
|
||||||
try {
|
|
||||||
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
|
||||||
if (ticket.status !== 'Closed') {
|
|
||||||
const wsid = await nextWsid()
|
|
||||||
const today = now.slice(0, 10)
|
|
||||||
await databases.createDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, ID.unique(), {
|
|
||||||
wsid,
|
|
||||||
woid: ticket.woid || '',
|
|
||||||
workorderId: ticket.$id,
|
|
||||||
employeeId: user?.$id || 'system',
|
|
||||||
employeeName: user?.name || user?.email || 'System',
|
|
||||||
serviceType: 'Remote',
|
|
||||||
oldStatus: ticket.status,
|
|
||||||
newStatus: 'Closed',
|
|
||||||
totalTime: 0,
|
|
||||||
startDate: today,
|
|
||||||
endDate: today,
|
|
||||||
details: `Roadmap "${roadmap.title}" abgeschlossen: alle Ziel-Knoten fertig. Ticket automatisch geschlossen.`,
|
|
||||||
isComment: true,
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Closed' })
|
|
||||||
} else {
|
|
||||||
await logRoadmapWorksheet({ ...roadmap, status: ROADMAP_STATUS.COMPLETED }, `Roadmap "${roadmap.title}" abgeschlossen (Ticket war bereits geschlossen).`, user)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Ticket fehlt -> nur archivieren */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktiven Plan loeschen (destruktiv): alle Knoten + Roadmap entfernen,
|
|
||||||
* Auto-Ticket auf Cancelled setzen. Abgeschlossene Plaene werden nie geloescht.
|
|
||||||
*/
|
|
||||||
export async function deleteRoadmapPlan(roadmap, user) {
|
|
||||||
const nodes = await fetchRoadmapNodes(roadmap.$id)
|
|
||||||
await logRoadmapWorksheet(roadmap, `Roadmap "${roadmap.title}" geloescht. Ticket wird abgebrochen.`, user)
|
|
||||||
for (const node of nodes) {
|
|
||||||
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAP_NODES, node.$id)
|
|
||||||
}
|
|
||||||
await databases.deleteDocument(DATABASE_ID, COLLECTIONS.ROADMAPS, roadmap.$id)
|
|
||||||
if (roadmap.ticketId) {
|
|
||||||
try {
|
|
||||||
const ticket = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, roadmap.ticketId)
|
|
||||||
if (ticket.status !== 'Closed' && ticket.status !== 'Cancelled') {
|
|
||||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ticket.$id, { status: 'Cancelled' })
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Ticket fehlt -> nichts zu tun */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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