Tickets: Ticket als eigene Seite mit Route /tickets/:ref/:tab? (Facts, Reiter, Dialoge verdrahtet)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 06:02:55 +00:00
1 parent 200d68f20d
commit b544b83da0
2 files changed
+303 -2

No files matched your search

+7 -2
View File
@@ -5,6 +5,7 @@ import Sidebar from './components/Navbar'
import Logo from './components/Logo'
import LoginPage from './pages/LoginPage'
import TicketsPage from './pages/TicketsPage'
import TicketPage from './pages/TicketPage'
import PlanboardPage from './pages/PlanboardPage'
import ProjectsPage from './pages/ProjectsPage'
import AdminPage from './pages/AdminPage'
@@ -48,6 +49,7 @@ function AppRoutes() {
<Route path="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
<Route path="/" element={<Navigate to="/tickets" replace />} />
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
<Route path="/tickets/:ref/:tab?" element={<ProtectedRoute><TicketPage /></ProtectedRoute>} />
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
@@ -87,10 +89,13 @@ function AppContent() {
window.scrollTo(0, 0)
}, [location.pathname])
// Esc schliesst die Schublade, nur solange sie offen ist
// Esc schliesst die Schublade, nur solange sie offen ist und nur, wenn kein anderer Baustein die Taste schon behandelt hat
useEffect(() => {
if (!menuOpen) return undefined
const onKey = (e) => { if (e.key === 'Escape') setMenuOpen(false) }
const onKey = (e) => {
if (e.defaultPrevented) return
if (e.key === 'Escape') setMenuOpen(false)
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [menuOpen])
+296
View File
@@ -0,0 +1,296 @@
import { useMemo, useState } from 'react'
import { Link, Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'
import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui'
import UrgentToggle from '../components/UrgentToggle'
import WorksheetSummary from '../components/WorksheetSummary'
import WorksheetList from '../components/WorksheetList'
import ProjectContextPanel from '../components/ProjectContextPanel'
import TicketProjects from '../components/TicketProjects'
import CreateWorksheetModal from '../components/CreateWorksheetModal'
import WorksheetTimesModal from '../components/WorksheetTimesModal'
import OwnSupportModal from '../components/OwnSupportModal'
import { useAuth } from '../context/AuthContext'
import { useEmployees } from '../hooks/useEmployees'
import { useWorkorder } from '../hooks/useWorkorder'
import { useWorksheets } from '../hooks/useWorksheets'
import { useCustomerAbo } from '../hooks/useCustomerAbo'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { elapsedLabel } from '../lib/elapsed'
import { canAddOwnSupport } from '../lib/support'
import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm'
import { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes'
import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus'
import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView'
import { createWorksheetFlow } from '../lib/worksheetFlow'
// Ein Ticket als eigene Seite: /tickets/<woid oder $id> (Details) und /tickets/<ref>/arbeitsblaetter.
// Alle Hooks stehen vor jeder Weiterleitung, sonst bricht der Wechsel /tickets/5/xyz -> /tickets/5 die Hook-Reihenfolge.
export default function TicketPage() {
const { ref, tab } = useParams()
const location = useLocation()
const { ticket, loading, error, notFound, updateWorkorder } = useWorkorder(ref)
const activeTab = resolveTicketTab(tab)
if (activeTab === null) return <Navigate replace to={ticketPath(ref)} state={location.state} />
const back = <Button as={Link} to={listBackPath(location.state)}>Zurück</Button>
if (loading) {
return (
<div className="page">
<div className="loading">Wird geladen …</div>
</div>
)
}
if (notFound) {
return (
<div className="page">
<EmptyState title="Ticket nicht gefunden" action={back} />
</div>
)
}
if (error || !ticket) {
return (
<div className="page">
<p className="msg err" role="alert">Fehler: {error || 'Das Ticket konnte nicht geladen werden.'}</p>
<div className="mt-2">{back}</div>
</div>
)
}
return <TicketView key={ticket.$id} ticket={ticket} updateWorkorder={updateWorkorder} tab={activeTab} />
}
function TicketView({ ticket, updateWorkorder, tab }) {
const location = useLocation()
const navigate = useNavigate()
const { user, employee } = useAuth()
// Einmal laden und an die Dialoge reichen (der Zuständig-Filter und die Unterstützer-Liste teilen sich die Antwort)
const { employees, loading: employeesLoading, error: employeesError } = useEmployees()
const employeesState = useMemo(
() => ({ employees, loading: employeesLoading, error: employeesError }),
[employees, employeesLoading, employeesError]
)
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Werte, die gerade gespeichert werden: das Feld zeigt den neuen Wert und ist gesperrt, bei einem Fehler springt es zurück
const [pending, setPending] = useState({})
const woid = ticket.woid || null
const {
worksheets,
loading: worksheetsLoading,
error: worksheetsError,
createWorksheet,
updateWorksheet,
updateWorksheetTimes,
saveOwnSupport,
} = useWorksheets(woid)
// Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblätter gebraucht werden
const { hoursAllowed: aboHoursAllowed } = useCustomerAbo(
ticket.customerId,
tab === 'arbeitsblaetter' || showCreateWorksheet
)
const isPending = (field) => pending[field] !== undefined
const shown = (field, current) => (isPending(field) ? pending[field] : current)
// Fehler beim Schreiben bleiben still (wie bisher): bei Misserfolg ändert sich das Ticket nicht und das Feld springt zurück
const saveField = async (field, value) => {
if (isPending(field)) return
setPending((prev) => ({ ...prev, [field]: value }))
try {
await updateWorkorder(ticket.$id, { [field]: value })
} finally {
setPending((prev) => {
const next = { ...prev }
delete next[field]
return next
})
}
}
const goToTab = (id) => navigate(ticketPath(ticketRef(ticket), id), { state: location.state })
// Abo-Stunden: Arbeitszeit vom Stundenguthaben des Wartungs-Abos abziehen oder nicht
const togglePlan = (ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheetFlow({
ticket,
worksheetData,
user: currentUser,
createWorksheet,
updateTicket: updateWorkorder,
updateCustomer: (id, data) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, data),
})
// Das neue Blatt soll sichtbar sein
if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter')
return result
}
const number = ticketNumber(ticket)
const customerLabel = ticket.customerName || 'Unbekannt'
const worksheetCount = !woid || worksheetsLoading ? undefined : worksheetsError ? '–' : worksheets.length
const appointment = appointmentText(ticket)
const due = dueText(ticket)
return (
<div className="page">
<PageHeader
crumb={[{ label: 'Tickets', to: listBackPath(location.state) }, { label: number }]}
eyebrow={ticket.type ? `${number} · ${ticketTypeLabel(ticket.type)}` : number}
title={ticket.topic || ticket.title || 'Kein Betreff'}
subtitle={
<>
{ticket.customerId ? (
<Link className="link" to={'/customers/' + ticket.customerId}>{customerLabel}</Link>
) : (
customerLabel
)}
{ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''}
</>
}
actions={
<>
<UrgentToggle
value={shown('priority', ticket.priority)}
onChange={(priority) => saveField('priority', priority)}
disabled={isPending('priority')}
/>
<select
className="select"
aria-label="Status"
value={shown('status', ticket.status || 'Open')}
disabled={isPending('status')}
onChange={(e) => saveField('status', e.target.value)}
>
{statusSelectOptions(ticket.status).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<Button
variant="primary"
type="button"
disabled={!woid}
title={woid ? undefined : 'Für dieses Ticket gibt es keine Ticketnummer'}
onClick={() => setShowCreateWorksheet(true)}
>
Arbeitsblatt hinzufügen
</Button>
</>
}
/>
<div className="facts">
<div className="fact">
<div className="k"><label htmlFor="ticket-assignee">Zuständig</label></div>
<div className="v">
<select
className="select select-quiet"
id="ticket-assignee"
value={shown('assignedTo', ticket.assignedTo || '')}
disabled={employeesLoading || isPending('assignedTo')}
onChange={(e) => saveField('assignedTo', e.target.value)}
>
{assigneeOptions(ticket, employees).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
<div className="fact">
<div className="k">Termin</div>
<div className="v">{appointment || <span className="faint">Kein Termin</span>}</div>
</div>
<div className="fact">
<div className="k">Fällig</div>
<div className="v">{due || '–'}</div>
</div>
<div className="fact">
<div className="k">Einsatz</div>
<div className="v">{serviceTypeLabel(ticket.serviceType) || '–'}</div>
</div>
<div className="fact">
<div className="k">Angefragt von</div>
<div className="v">{ticket.requestedBy || '–'}</div>
</div>
<div className="fact">
<div className="k">Erstellt</div>
<div className="v">{elapsedLabel(ticket)}</div>
</div>
</div>
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'arbeitsblaetter', label: 'Arbeitsblätter', count: worksheetCount },
]}
active={tab}
onChange={(id) => { if (id !== tab) goToTab(id) }}
/>
{tab === 'details' && (
<>
<Card title="Ticket-Beschreibung">
<p className="prose">{ticket.details || 'Keine Details vorhanden.'}</p>
</Card>
<TicketProjects ticket={ticket} />
<ProjectContextPanel ticket={ticket} />
</>
)}
{tab === 'arbeitsblaetter' && (
!woid ? (
<p className="muted">Für dieses Ticket gibt es keine Ticketnummer, Arbeitsblätter sind nicht möglich.</p>
) : worksheetsError ? (
<p className="msg err" role="alert">{worksheetsError}</p>
) : (
<>
<WorksheetSummary worksheets={worksheets} />
<WorksheetList
worksheets={worksheets}
loading={worksheetsLoading}
onTogglePlan={togglePlan}
aboHoursAllowed={aboHoursAllowed}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}
onOwnSupport={setOwnSupportWorksheet}
currentUserId={user?.$id}
/>
</>
)
)}
<CreateWorksheetModal
isOpen={showCreateWorksheet}
onClose={() => setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
aboHoursAllowed={aboHoursAllowed}
employeesState={employeesState}
/>
{ownSupportWorksheet && (
<OwnSupportModal
worksheet={ownSupportWorksheet}
currentUserId={user?.$id}
onClose={() => setOwnSupportWorksheet(null)}
onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)}
/>
)}
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
employeesState={employeesState}
/>
)}
</div>
)
}