import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { PageHeader, Tabs, Button, EmptyState, StatusPill, UrgentPill } from '../components/ui' import CreateTicketModal from '../components/CreateTicketModal' import { useCustomers } from '../hooks/useCustomers' import { useEmployees } from '../hooks/useEmployees' import { useTicketCounts } from '../hooks/useTicketCounts' import { useTicketMinutes } from '../hooks/useTicketMinutes' import { useWorkorders } from '../hooks/useWorkorders' import { elapsedLabel } from '../lib/elapsed' import { LIST_TABS, listFilters, nextLimit, parseListParams, serializeListParams, withFilter, woidRedirectTarget, } from '../lib/ticketListUrl' import { isRowActivation } from '../lib/rowClick' import { ticketPath, ticketRef } from '../lib/ticketRoutes' import { TYPE_FILTERS, ticketTypeLabel } from '../lib/ticketStatus' import { TIME_MAX_ROWS, minutesCellText } from '../lib/ticketTimes' import { appointmentText, assigneeLabel, ticketNumber } from '../lib/ticketView' // Pause nach dem letzten Tastendruck, bis der Suchtext in die Adresse übernommen wird const SEARCH_DELAY_MS = 300 // Altlinks aus den Leads (Liste mit Suchteil woid=123) führen zur Ticketseite; dann läuft keine Abfrage der Liste export default function TicketsPage() { const [searchParams] = useSearchParams() const target = woidRedirectTarget(searchParams) if (target) return return } function countText(n, searching) { if (searching) return n === 1 ? '1 Treffer' : `${n} Treffer` return n === 1 ? '1 Ticket' : `${n} Tickets` } function TicketList() { const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() const state = useMemo(() => parseListParams(searchParams), [searchParams]) const filters = useMemo(() => listFilters(state), [state]) // Wohin "Zurück" auf der Ticketseite führt: dieselbe Liste mit denselben Filtern const listSearch = location.search // Jede Änderung geht in die Adresse und ersetzt den Eintrag (der Zurück-Knopf springt nicht durch Filterschritte) const write = (change) => { setSearchParams((prev) => serializeListParams(change(parseListParams(prev))), { replace: true }) } const setFilter = (patch) => write((current) => withFilter(current, patch)) const loadMore = () => write((current) => ({ ...current, limit: nextLimit(current) })) // Der Timer der Suche ruft die Schreib-Funktion des jüngsten Renders (setSearchParams liest die Adresse aus seinem Render) const writeRef = useRef(write) writeRef.current = write // Suche: lokaler Text, Übernahme in die Adresse nach Tipp-Pause oder mit Enter. // appliedRef merkt den zuletzt übernommenen (bzw. zuletzt aus der Adresse gelesenen) Suchtext: // eigene Übernahmen laufen nicht in den Text zurück (sonst wären Leerzeichen am Ende weg), // nur eine Änderung von außen (Zurück-Knopf, Link in der Seitenleiste) setzt den Text neu. const [searchText, setSearchText] = useState(state.search) const appliedRef = useRef(state.search) const commitSearch = useCallback((text) => { const next = text.trim() if (next === appliedRef.current) return appliedRef.current = next writeRef.current((current) => withFilter(current, { search: next })) }, []) useEffect(() => { if (state.search === appliedRef.current) return appliedRef.current = state.search setSearchText(state.search) }, [state.search]) useEffect(() => { if (searchText.trim() === appliedRef.current) return const timer = setTimeout(() => commitSearch(searchText), SEARCH_DELAY_MS) return () => clearTimeout(timer) }, [searchText, commitSearch]) const { workorders, loading, error, createWorkorder } = useWorkorders(filters) const { counts } = useTicketCounts({ typeId: state.typeId, urgentOnly: state.urgentOnly }) const { customers } = useCustomers() // Einmal laden: die Liste zeigt Namen, der Dialog "Neues Ticket" braucht dieselben Mitarbeiter const { employees } = useEmployees() const { byWoid, capped, version } = useTicketMinutes(workorders) // Mit welchem Limit wurden die gezeigten Zeilen geladen? Nach "Mehr laden" ist state.limit schon größer, // die Zeilen sind es erst mit der Antwort: der Knopf bleibt bis dahin stehen (und ist gesperrt). // Im Ruhezustand heißt das: Zeilen >= state.limit und state.limit unter der Obergrenze von nextLimit. const [rowsFor, setRowsFor] = useState({ rows: workorders, limit: state.limit }) if (rowsFor.rows !== workorders) setRowsFor({ rows: workorders, limit: state.limit }) const hasMore = !state.search && nextLimit({ limit: rowsFor.limit }) > rowsFor.limit && workorders.length >= rowsFor.limit const [showCreate, setShowCreate] = useState(false) const openCreate = () => setShowCreate(true) const handleCreate = async (data) => { const result = await createWorkorder(data) if (result.success) { setShowCreate(false) navigate(ticketPath(ticketRef(result.data)), { state: { listSearch } }) } return result } // Zähler: bei Suche aus, bei Fehler "–", vor der ersten Antwort fehlt die Zahl const tabs = LIST_TABS.map((tab) => { const count = state.search || !(tab.id in counts) ? undefined : counts[tab.id] === null ? '–' : counts[tab.id] return { id: tab.id, label: tab.label, count } }) // Die Art steht in der Zeile, solange der Filter keine einzelne Art festlegt const showType = state.typeId === 'orders' || state.typeId === 'all' // byWoid ist eine an Ort und Stelle ergänzte Map: erst `version` zeigt eine Änderung an const rows = useMemo( () => workorders.map((ticket, index) => { const path = ticketPath(ticketRef(ticket)) const assignee = assigneeLabel(ticket, employees) return ( { if (isRowActivation(e)) navigate(path, { state: { listSearch } }) }} >
{ticketNumber(ticket)} {ticket.topic || ticket.title || 'Kein Betreff'}
{ticket.customerName || 'Unbekannt'} {ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''} {showType && ticket.type ? ` · ${ticketTypeLabel(ticket.type)}` : ''}
{assignee.none ? {assignee.text} : assignee.text} {appointmentText(ticket) || '–'} {minutesCellText(ticket, index, byWoid)} {elapsedLabel(ticket)} ) }), [workorders, employees, byWoid, version, listSearch, navigate, showType] ) let content // Ladezustand, solange keine Zeilen da sind (erstes Laden und Filterwechsel auf eine leere Liste); mit Zeilen bleiben die alten stehen if (loading && workorders.length === 0) { content =
Wird geladen …
} else if (error) { content =

Fehler: {error}

} else if (workorders.length === 0) { content = ( Neues Ticket} /> ) } else { content = ( <> {rows}
Ticket Status Zuständig Termin Zeit Erstellt
{countText(workorders.length, Boolean(state.search))} {loading && · Wird geladen …} {capped && · Zeit nur für die ersten {TIME_MAX_ROWS} Zeilen berechnet}
Neueste zuerst
{hasMore && (
)} ) } return (
Neues Ticket} /> { if (id !== state.tab) setFilter({ tab: id }) }} />
{ e.preventDefault() commitSearch(searchText) }} > setSearchText(e.target.value)} /> {content} setShowCreate(false)} onCreate={handleCreate} customers={customers} employees={employees} />
) }