feat(roadmaps): Ticket-Integration — Roadmap-Panel + Projekt-Kontextbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
97
src/components/ProjectContextPanel.jsx
Normal file
97
src/components/ProjectContextPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="ui-card pad" style={{ marginTop: 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 }}>
|
||||||
|
<IconFolders size={18} /> Projekt: {projectName || '-'}
|
||||||
|
</h5>
|
||||||
|
{roadmap && (
|
||||||
|
<Link to={`/roadmaps/${roadmap.$id}`} className="ui-btn">
|
||||||
|
Plan: {roadmap.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!ticket.customerId ? (
|
||||||
|
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||||
|
Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.
|
||||||
|
</p>
|
||||||
|
) : !otherTickets.length ? (
|
||||||
|
<p className="faint" style={{ fontSize: 13, margin: 0 }}>
|
||||||
|
Keine weiteren Tickets zu diesem Kunden/Projekt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{otherTickets.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.$id}
|
||||||
|
to={`/tickets?woid=${t.woid}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
|
||||||
|
padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="muted" style={{ minWidth: 48, fontWeight: 700 }}>{t.woid}</span>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.topic || t.title || '-'}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={t.status} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import WorksheetList from './WorksheetList'
|
|||||||
import WorksheetStats from './WorksheetStats'
|
import WorksheetStats from './WorksheetStats'
|
||||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
import TicketProjectsModal from './TicketProjectsModal'
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
|
import TicketRoadmapPanel from './TicketRoadmapPanel'
|
||||||
|
import ProjectContextPanel from './ProjectContextPanel'
|
||||||
import InvoicePanel from './InvoicePanel'
|
import InvoicePanel from './InvoicePanel'
|
||||||
import DocumentsPanel from './DocumentsPanel'
|
import DocumentsPanel from './DocumentsPanel'
|
||||||
import Tabs from './ui/Tabs'
|
import Tabs from './ui/Tabs'
|
||||||
@@ -80,7 +82,7 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
<div className="tcard-body">
|
<div className="tcard-body">
|
||||||
<div className="tcard-row1">
|
<div className="tcard-row1">
|
||||||
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
|
<span className="tcard-customer">{ticket.customerName || 'Unbekannt'}</span>
|
||||||
{ticket.customerLocation && <span className="tcard-loc"><EFBFBD> {ticket.customerLocation}</span>}
|
{ticket.customerLocation && <span className="tcard-loc"><EFBFBD> {ticket.customerLocation}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
<div className="tcard-topic">{ticket.topic || ticket.title || 'Kein Betreff'}</div>
|
||||||
<div className="tcard-badges">
|
<div className="tcard-badges">
|
||||||
@@ -118,11 +120,14 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
|
|
||||||
<div className="ticket-tab-content">
|
<div className="ticket-tab-content">
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
<div className="ui-card pad">
|
<div>
|
||||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
<div className="ui-card pad">
|
||||||
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Ticket-Beschreibung</h5>
|
||||||
{ticket.details || 'Keine Details vorhanden.'}
|
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)', lineHeight: 1.7, margin: 0 }}>
|
||||||
</p>
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProjectContextPanel ticket={ticket} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -148,6 +153,7 @@ export default function TicketCard({ ticket, onUpdate }) {
|
|||||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||||
{activeTab === 'project' && (
|
{activeTab === 'project' && (
|
||||||
<div>
|
<div>
|
||||||
|
<TicketRoadmapPanel ticket={ticket} />
|
||||||
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
<button className="ui-btn ui-btn-primary" style={{ marginBottom: 12 }} onClick={() => setShowProjectsModal(true)}>
|
||||||
<FaPen /> Projekte zuweisen / bearbeiten
|
<FaPen /> Projekte zuweisen / bearbeiten
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
94
src/components/TicketRoadmapPanel.jsx
Normal file
94
src/components/TicketRoadmapPanel.jsx
Normal file
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user