diff --git a/src/components/ProjectContextPanel.jsx b/src/components/ProjectContextPanel.jsx
new file mode 100644
index 0000000..878b46c
--- /dev/null
+++ b/src/components/ProjectContextPanel.jsx
@@ -0,0 +1,97 @@
+import { useEffect, useState } from 'react'
+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.
+ */
+export default function ProjectContextPanel({ ticket }) {
+ const { fetchByTicketId } = useWebsiteProjects()
+ const [context, setContext] = useState(null)
+
+ useEffect(() => {
+ 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) {
+ if (!cancelled) setContext(null)
+ return
+ }
+
+ let otherTickets = []
+ if (ticket.customerId) {
+ const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
+ Query.equal('customerId', ticket.customerId),
+ Query.orderDesc('$createdAt'),
+ Query.limit(50),
+ ])
+ otherTickets = res.documents.filter((t) => t.$id !== ticket.$id)
+ }
+ if (!cancelled) setContext({ roadmap, projectName, otherTickets })
+ } catch {
+ if (!cancelled) setContext(null)
+ }
+ }
+ load()
+ return () => { cancelled = true }
+ }, [ticket.$id, ticket.customerId, fetchByTicketId])
+
+ if (!context) return null
+ const { roadmap, projectName, otherTickets } = context
+
+ return (
+
+
+
+ Projekt: {projectName || '-'}
+
+ {roadmap && (
+
+ Plan: {roadmap.title}
+
+ )}
+
+
+ {!ticket.customerId ? (
+
+ Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
+
+ ) : !otherTickets.length ? (
+
+ Keine weiteren Tickets zu diesem Kunden/Projekt.
+
+ ) : (
+
+ {otherTickets.map((t) => (
+
+ {t.woid}
+
+ {t.topic || t.title || '-'}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/TicketCard.jsx b/src/components/TicketCard.jsx
index 4926fa7..70fcf65 100644
--- a/src/components/TicketCard.jsx
+++ b/src/components/TicketCard.jsx
@@ -12,6 +12,8 @@ import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
import TicketProjectsModal from './TicketProjectsModal'
+import TicketRoadmapPanel from './TicketRoadmapPanel'
+import ProjectContextPanel from './ProjectContextPanel'
import InvoicePanel from './InvoicePanel'
import DocumentsPanel from './DocumentsPanel'
import Tabs from './ui/Tabs'
@@ -80,7 +82,7 @@ export default function TicketCard({ ticket, onUpdate }) {
{ticket.customerName || 'Unbekannt'}
- {ticket.customerLocation && {ticket.customerLocation}}
+ {ticket.customerLocation && � {ticket.customerLocation}}
{ticket.topic || ticket.title || 'Kein Betreff'}
@@ -118,11 +120,14 @@ export default function TicketCard({ ticket, onUpdate }) {
{activeTab === 'details' && (
-
-
Ticket-Beschreibung
-
- {ticket.details || 'Keine Details vorhanden.'}
-
+
+
+
Ticket-Beschreibung
+
+ {ticket.details || 'Keine Details vorhanden.'}
+
+
+
)}
@@ -148,6 +153,7 @@ export default function TicketCard({ ticket, onUpdate }) {
{activeTab === 'documents' &&
}
{activeTab === 'project' && (
+
diff --git a/src/components/TicketRoadmapPanel.jsx b/src/components/TicketRoadmapPanel.jsx
new file mode 100644
index 0000000..58760b5
--- /dev/null
+++ b/src/components/TicketRoadmapPanel.jsx
@@ -0,0 +1,94 @@
+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 (
+
+
+
+ Roadmap: {roadmap.title}
+
+ Zur Roadmap
+
+
+
+
+
+ Offen: {counts.open}
+ In Arbeit: {counts.inProgress}
+ Fertig: {counts.done}
+ {roadmap.status === ROADMAP_STATUS.COMPLETED && Plan abgeschlossen}
+
+
+ {nextDue.length > 0 && (
+
+ Naechste Deadlines:
+ {nextDue.map((n, i) => (
+
+ {i > 0 && ' · '}{n.title} ({n.deadline.split('-').reverse().join('.')})
+
+ ))}
+
+ )}
+
+ {closedButActive && (
+
+
+ Ticket ist geschlossen, aber der Plan ist erst zu {progress.percent}% fertig.
+
+ )}
+
+ )
+}