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:
1 parent
200d68f20d
commit
b544b83da0
2 files changed
+303
-2
No files matched your search
+7
-2
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user