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. +
+ )} +
+ ) +}