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 Logo from './components/Logo'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import TicketsPage from './pages/TicketsPage'
|
import TicketsPage from './pages/TicketsPage'
|
||||||
|
import TicketPage from './pages/TicketPage'
|
||||||
import PlanboardPage from './pages/PlanboardPage'
|
import PlanboardPage from './pages/PlanboardPage'
|
||||||
import ProjectsPage from './pages/ProjectsPage'
|
import ProjectsPage from './pages/ProjectsPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
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="/login" element={user ? <Navigate to={safeReturnPath(location.state?.from)} replace /> : <LoginPage />} />
|
||||||
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
<Route path="/" element={<Navigate to="/tickets" replace />} />
|
||||||
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
<Route path="/tickets" element={<ProtectedRoute><TicketsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/tickets/:ref/:tab?" element={<ProtectedRoute><TicketPage /></ProtectedRoute>} />
|
||||||
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
|
||||||
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
|
{/* Messenger (Kunden -> Chat); statische Pfade gehen in react-router vor /customers/:id */}
|
||||||
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
|
<Route path="/customers/chat" element={<ProtectedRoute><CustomerChatPage /></ProtectedRoute>} />
|
||||||
@@ -87,10 +89,13 @@ function AppContent() {
|
|||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
}, [location.pathname])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return undefined
|
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)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [menuOpen])
|
}, [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