333 lines
13 KiB
React
333 lines
13 KiB
React
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>
|
||
)
|
||
}
|