Tickets: Liste als Tabelle mit Reitern, Zaehlern, Zeit-Spalte und URL-Zustand; Suche per Tipp-Pause

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 06:02:55 +00:00
1 parent b544b83da0
commit 2ecc0b627a
1 file changed
+236 -131
+236 -131
View File
@@ -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 <Navigate replace to={target} />
return <TicketList />
}
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 (
<tr
key={ticket.$id}
className="row"
onClick={(e) => {
if (isRowActivation(e)) navigate(path, { state: { listSearch } })
}}
>
<td>
<div className="cell-title">
<Link className="row-link" to={path} state={{ listSearch }}>
<span className="id">{ticketNumber(ticket)}</span>
<span>{ticket.topic || ticket.title || 'Kein Betreff'}</span>
</Link>
<UrgentPill priority={ticket.priority} />
</div>
<div className="cell-sub">
{ticket.customerName || 'Unbekannt'}
{ticket.customerLocation ? ` · ${ticket.customerLocation}` : ''}
</div>
</td>
<td><StatusPill status={ticket.status} /></td>
<td className="hide-sm">{assignee.none ? <span className="faint">{assignee.text}</span> : assignee.text}</td>
<td className="hide-md">{appointmentText(ticket) || '–'}</td>
<td className="hide-md right num">{minutesCellText(ticket, index, byWoid)}</td>
<td className="hide-sm right">{elapsedLabel(ticket)}</td>
</tr>
)
}),
[workorders, employees, byWoid, version, listSearch, navigate]
)
let content
if (!loaded && loading) {
content = <div className="loading">Wird geladen …</div>
} else if (error) {
content = <p className="msg err" role="alert">Fehler: {error}</p>
} else if (workorders.length === 0) {
content = (
<EmptyState
title="Keine Tickets"
hint="Keine Tickets passen zu Suche und Filter. Erledigte, stornierte und Akquise-Tickets sind in der Standardansicht ausgeblendet."
action={<Button type="button" onClick={openCreate}>Neues Ticket</Button>}
/>
)
} else {
content = (
<>
<table className="table">
<thead>
<tr>
<th>Ticket</th>
<th>Status</th>
<th className="hide-sm">Zuständig</th>
<th className="hide-md">Termin</th>
<th className="hide-md right num">Zeit</th>
<th className="hide-sm right">Erstellt</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
<div className="table-foot">
<div>
{countText(workorders.length, Boolean(state.search))}
{loading && <span className="faint"> · Wird geladen …</span>}
{capped && <span className="faint"> · Zeit nur für die ersten {TIME_MAX_ROWS} Zeilen berechnet</span>}
</div>
<div>Neueste zuerst</div>
</div>
{hasMore && (
<div className="list-more">
<Button type="button" disabled={loading} onClick={loadMore}>Mehr laden</Button>
</div>
)}
</>
)
}
return (
@@ -80,86 +211,60 @@ export default function TicketsPage() {
<PageHeader
title="Tickets"
subtitle="Alle Arbeitsaufträge auf einen Blick"
actions={
<>
<Button variant="ghost" onClick={() => setShowAdvanced((s) => !s)}><FaSliders /> Filter</Button>
<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>
</>
}
actions={<Button variant="primary" type="button" onClick={openCreate}>Neues Ticket</Button>}
/>
<Card pad className="mb-2" style={{ marginBottom: 16 }}>
<form className="toolbar" onSubmit={applyFilters} style={{ marginBottom: showAdvanced ? 16 : 0 }}>
<input
type="search"
className="form-control search-input"
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<Button variant="primary" type="submit">Anwenden</Button>
</form>
<Tabs
tabs={tabs}
active={state.tab}
onChange={(id) => { if (id !== state.tab) setFilter({ tab: id }) }}
/>
{showAdvanced && (
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 16 }}>
<div className="flex gap-3 wrap" style={{ alignItems: 'center' }}>
<select aria-label="Typ" className="form-control" style={{ maxWidth: 220 }} value={typeId} onChange={(e) => setTypeId(e.target.value)}>
{TYPE_FILTERS.map((filter) => (
<option key={filter.id} value={filter.id}>{filter.label}</option>
))}
</select>
<label className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
<input type="checkbox" checked={urgentOnly} onChange={(e) => setUrgentOnly(e.target.checked)} />
Nur dringende
</label>
</div>
<div style={{ marginTop: 16 }}>
<div className="flex gap-2 wrap status-filter-actions" style={{ alignItems: 'center', marginBottom: 8 }}>
<span className="muted" style={{ fontSize: 13, fontWeight: 600 }}>Status</span>
<button type="button" onClick={() => setGroups(DEFAULT_STATUS_GROUPS)} style={linkButton}>Standard</button>
<button type="button" onClick={() => setGroups(ALL_GROUPS)} style={linkButton}>Alle</button>
</div>
<div className="flex gap-3 wrap status-filter">
{STATUS_GROUPS.map((group) => (
<label key={group.id} className="flex items-center gap-2" style={{ cursor: 'pointer', fontSize: 14 }}>
<input type="checkbox" checked={groups.includes(group.id)} onChange={() => toggleGroup(group.id)} />
{group.label}
</label>
))}
</div>
</div>
</div>
)}
</Card>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<Card><div className="text-red">Fehler: {error}</div></Card>
) : workorders.length === 0 ? (
<Card>
<EmptyState
title="Keine Tickets"
hint="Keine Tickets passen zu Suche und Filter. Erledigte, stornierte und Akquise-Tickets sind in der Standardansicht ausgeblendet."
action={<Button variant="primary" onClick={() => setShowCreateModal(true)}><FaPlus /> Neues Ticket</Button>}
/>
</Card>
) : (
<div className="tickets-list">
{workorders.map((ticket) => (
<TicketCard key={ticket.$id} ticket={ticket} onUpdate={handleUpdate} />
<form
className="toolbar"
onSubmit={(e) => {
e.preventDefault()
commitSearch(searchText)
}}
>
<input
type="search"
className="input search"
aria-label="Tickets durchsuchen"
placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
<select
className="select select-quiet"
aria-label="Art"
value={state.typeId}
onChange={(e) => setFilter({ typeId: e.target.value })}
>
{TYPE_FILTERS.map((filter) => (
<option key={filter.id} value={filter.id}>{filter.label}</option>
))}
</div>
)}
</select>
<span className="grow" />
<label className="check">
<input
type="checkbox"
checked={state.urgentOnly}
onChange={(e) => setFilter({ urgentOnly: e.target.checked })}
/>
Nur dringende
</label>
</form>
{/* Bei einer Suche kommen alle Treffer auf einmal */}
{!filters.search && workorders.length > 0 && workorders.length >= limit && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button variant="ghost" onClick={loadMore}>Mehr laden <FaAngleDown /></Button>
</div>
)}
{content}
<CreateTicketModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onCreate={handleCreate} customers={customers} />
<CreateTicketModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
customers={customers}
employees={employees}
/>
</div>
)
}