Files
tickte-system/src/pages/TicketPage.jsx
T

333 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 StundenkontoLeiste from '../components/customer/StundenkontoLeiste'
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 { einrichtungAbrechnung, nachtragPlanFelder } from '../lib/planVorgabe'
import { einrichtungText } from '../lib/stundenkontoView'
import { worksheetSums } from '../lib/worksheetView'
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}</p>
<div className="mt-2">{back}</div>
</div>
)
}
return <TicketView key={ticket.$id} ticket={ticket} updateWorkorder={updateWorkorder} tab={activeTab} />
}
// Eine gelesene Uhrzeit oder Spanne ("09:30" bzw. "09:30–10:30") bricht nicht in sich um, nur davor am Komma.
// Unlesbare Altwerte (Rohtext) brechen weiter frei um.
const TIME_RANGE = /^\d{2}:\d{2}(–\d{2}:\d{2})?$/
function AppointmentText({ text }) {
const at = text.lastIndexOf(', ')
if (at === -1 || !TIME_RANGE.test(text.slice(at + 2))) return text
return (
<>
{text.slice(0, at)}, <span className="nowrap">{text.slice(at + 2)}</span>
</>
)
}
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)
// Hinweis des Arbeitsblatt-Flusses (z. B. "Zugesagt" ohne Kundenart); jedes neue Blatt setzt ihn neu
const [hinweis, setHinweis] = useState('')
// 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
)
// Einrichtungs-Ticket: 'abo' | 'projekt' | '' (V9); bei 'abo' zeigt die Seite das Stundenkonto des Kunden
const einrichtung = einrichtungAbrechnung(ticket)
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),
getCustomer: (id) => databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id),
})
setHinweis(result?.hinweis || '')
// 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 ? <AppointmentText text={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>
{hinweis && <p className="msg warn mb-2" role="alert">{hinweis}</p>}
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'arbeitsblaetter', label: 'Arbeitsblätter', count: worksheetCount },
]}
active={tab}
onChange={(id) => { if (id !== tab) goToTab(id) }}
/>
{einrichtung && (
<>
<p className="muted mb-2">{einrichtungText(ticket)}</p>
{einrichtung === 'abo' && (
<StundenkontoLeiste customerId={ticket.customerId} reloadKey={`${worksheets.length}:${worksheetSums(worksheets).aboMinutes}`} />
)}
</>
)}
{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, nachtragPlanFelder({ worksheet: ws, ticket, aboHoursAllowed }))}
employeesState={employeesState}
/>
)}
</div>
)
}