diff --git a/src/pages/TicketsPage.jsx b/src/pages/TicketsPage.jsx index 8430d4c..f3c5da5 100644 --- a/src/pages/TicketsPage.jsx +++ b/src/pages/TicketsPage.jsx @@ -1,78 +1,209 @@ -import { useEffect, useState } from 'react' -import { useSearchParams } from 'react-router-dom' -import { FaAngleDown, FaSpinner, FaPlus, FaSliders } from 'react-icons/fa6' -import { useWorkorders } from '../hooks/useWorkorders' -import { useCustomers } from '../hooks/useCustomers' -import TicketCard from '../components/TicketCard' +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 { PageHeader, Card, Button, EmptyState } from '../components/ui' -import { STATUS_GROUPS, DEFAULT_STATUS_GROUPS, statusValuesFor, TYPE_FILTERS } from '../lib/ticketStatus' +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 } from '../lib/ticketStatus' +import { TIME_MAX_ROWS, minutesCellText } from '../lib/ticketTimes' +import { appointmentText, assigneeLabel, ticketNumber } from '../lib/ticketView' -const ALL_GROUPS = STATUS_GROUPS.map((group) => group.id) -const linkButton = { fontSize: 12, background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0 } +// Pause nach dem letzten Tastendruck, bis der Suchtext in die Adresse übernommen wird +const SEARCH_DELAY_MS = 300 -// Aus Suchtext, Art, Status-Gruppen und "Nur dringende" die Abfrage fuer useWorkorders bauen. -// Sind alle oder keine Status-Gruppen gewaehlt, wird nicht nach Status gefiltert. -function toFilters({ search, typeId, groups, urgentOnly }) { - const typeFilter = TYPE_FILTERS.find((filter) => filter.id === typeId) || TYPE_FILTERS[0] - const allGroups = groups.length === 0 || groups.length === ALL_GROUPS.length - return { - status: allGroups ? [] : statusValuesFor(groups), - type: typeFilter.types || [], - excludeType: typeFilter.excludeType, - priority: urgentOnly ? [3, 4] : [], - search: search.trim() || undefined, - woid: undefined, - } +// Altlinks aus den Leads (/tickets?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 } -const DEFAULT_SELECTION = { search: '', typeId: 'orders', groups: DEFAULT_STATUS_GROUPS, urgentOnly: false } +function countText(n, searching) { + if (searching) return n === 1 ? '1 Treffer' : `${n} Treffer` + return n === 1 ? '1 Ticket' : `${n} Tickets` +} -export default function TicketsPage() { - const [limit, setLimit] = useState(10) - const [filters, setFilters] = useState({ ...toFilters(DEFAULT_SELECTION), limit: 10 }) - const [searchText, setSearchText] = useState('') - const [typeId, setTypeId] = useState(DEFAULT_SELECTION.typeId) - const [groups, setGroups] = useState(DEFAULT_SELECTION.groups) - const [urgentOnly, setUrgentOnly] = useState(false) +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 - const toggleGroup = (id) => { - setGroups((prev) => (prev.includes(id) ? prev.filter((g) => g !== id) : [...prev, id])) + // 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 - const { workorders, loading, error, updateWorkorder, createWorkorder } = useWorkorders(filters) - const { customers } = useCustomers() + // 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 [showCreateModal, setShowCreateModal] = useState(false) - const [showAdvanced, setShowAdvanced] = useState(false) - const [searchParams] = useSearchParams() + const commitSearch = useCallback((text) => { + const next = text.trim() + if (next === appliedRef.current) return + appliedRef.current = next + writeRef.current((current) => withFilter(current, { search: next })) + }, []) - // Deep-Link aus den Leads: /tickets?woid=123 zeigt genau dieses Ticket, - // egal welche Art (auch Akquise) und welcher Status useEffect(() => { - const w = searchParams.get('woid') - if (!w) return - setSearchText(w) - setTypeId('all') - setGroups(ALL_GROUPS) - setFilters((prev) => ({ ...prev, status: [], type: [], excludeType: undefined, priority: [], search: undefined, woid: w })) - }, [searchParams]) + if (state.search === appliedRef.current) return + appliedRef.current = state.search + setSearchText(state.search) + }, [state.search]) - const applyFilters = (e) => { - e?.preventDefault() - setFilters((prev) => ({ ...prev, ...toFilters({ search: searchText, typeId, groups, urgentOnly }), limit })) - } + useEffect(() => { + if (searchText.trim() === appliedRef.current) return + const timer = setTimeout(() => commitSearch(searchText), SEARCH_DELAY_MS) + return () => clearTimeout(timer) + }, [searchText, commitSearch]) - const handleUpdate = async (id, data) => { await updateWorkorder(id, data) } + 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) + + // Der Ladehinweis ohne Zeilen gilt nur dem allerersten Laden; danach bleiben die alten Zeilen stehen + const [loaded, setLoaded] = useState(false) + useEffect(() => { + if (!loading) setLoaded(true) + }, [loading]) + + // 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) setShowCreateModal(false) + if (result.success) { + setShowCreate(false) + navigate(ticketPath(ticketRef(result.data)), { state: { listSearch } }) + } return result } - const loadMore = () => { - const next = limit + 10 - setLimit(next) - setFilters((prev) => ({ ...prev, limit: next })) + + // 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 } + }) + + // 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}` : ''} +
+ + + {assignee.none ? {assignee.text} : assignee.text} + {appointmentText(ticket) || '–'} + {minutesCellText(ticket, index, byWoid)} + {elapsedLabel(ticket)} + + ) + }), + [workorders, employees, byWoid, version, listSearch, navigate] + ) + + let content + if (!loaded && loading) { + content =
Wird geladen …
+ } else if (error) { + content =

Fehler: {error}

+ } else if (workorders.length === 0) { + content = ( + Neues Ticket} + /> + ) + } else { + content = ( + <> + + + + + + + + + + + + {rows} +
TicketStatusZuständigTerminZeitErstellt
+
+
+ {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 ( @@ -80,86 +211,60 @@ export default function TicketsPage() { - - - - } + actions={} /> - -
- setSearchText(e.target.value)} - /> - -
+ { if (id !== state.tab) setFilter({ tab: id }) }} + /> - {showAdvanced && ( -
-
- - -
-
-
- Status - - -
-
- {STATUS_GROUPS.map((group) => ( - - ))} -
-
-
- )} -
- - {loading ? ( -
- ) : error ? ( -
Fehler: {error}
- ) : workorders.length === 0 ? ( - - setShowCreateModal(true)}> Neues Ticket} - /> - - ) : ( -
- {workorders.map((ticket) => ( - +
{ + e.preventDefault() + commitSearch(searchText) + }} + > + setSearchText(e.target.value)} + /> + + + + - {/* Bei einer Suche kommen alle Treffer auf einmal */} - {!filters.search && workorders.length > 0 && workorders.length >= limit && ( -
- -
- )} + {content} - setShowCreateModal(false)} onCreate={handleCreate} customers={customers} /> + setShowCreate(false)} + onCreate={handleCreate} + customers={customers} + employees={employees} + />
) }