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 = (
+ <>
+
+
+
+ Ticket
+ Status
+ Zuständig
+ Termin
+ Zeit
+ Erstellt
+
+
+ {rows}
+
+
+
+ {countText(workorders.length, Boolean(state.search))}
+ {loading && · Wird geladen … }
+ {capped && · Zeit nur für die ersten {TIME_MAX_ROWS} Zeilen berechnet }
+
+
Neueste zuerst
+
+ {hasMore && (
+
+ Mehr laden
+
+ )}
+ >
+ )
}
return (
@@ -80,86 +211,60 @@ export default function TicketsPage() {
- setShowAdvanced((s) => !s)}> Filter
- setShowCreateModal(true)}> Neues Ticket
- >
- }
+ actions={Neues Ticket }
/>
-
-
+ { if (id !== state.tab) setFilter({ tab: id }) }}
+ />
- {showAdvanced && (
-
-
- setTypeId(e.target.value)}>
- {TYPE_FILTERS.map((filter) => (
- {filter.label}
- ))}
-
-
- setUrgentOnly(e.target.checked)} />
- Nur dringende
-
-
-
-
- Status
- setGroups(DEFAULT_STATUS_GROUPS)} style={linkButton}>Standard
- setGroups(ALL_GROUPS)} style={linkButton}>Alle
-
-
- {STATUS_GROUPS.map((group) => (
-
- toggleGroup(group.id)} />
- {group.label}
-
- ))}
-
-
-
- )}
-
-
- {loading ? (
-
- ) : error ? (
- Fehler: {error}
- ) : workorders.length === 0 ? (
-
- setShowCreateModal(true)}> Neues Ticket}
- />
-
- ) : (
-
- {workorders.map((ticket) => (
-
+
- )}
+
+
+
+ setFilter({ urgentOnly: e.target.checked })}
+ />
+ Nur dringende
+
+
- {/* Bei einer Suche kommen alle Treffer auf einmal */}
- {!filters.search && workorders.length > 0 && workorders.length >= limit && (
-
- Mehr laden
-
- )}
+ {content}
- setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
+ setShowCreate(false)}
+ onCreate={handleCreate}
+ customers={customers}
+ employees={employees}
+ />
)
}