Files
tickte-system/src/pages/TicketsPage.jsx
T
ClaudeandClaude Opus 5.5 5d8130e8ef Tickets: Standardansicht zeigt alle Arten, auch Akquise-Tickets
Bisher startete die Liste mit dem Art-Filter "Auftraege", der Akquise-Tickets
ausschliesst. Neue Kunden bekommen ein Akquise-Ticket, das dann unter Tickets
nicht zu finden war. Standard ist jetzt "Alle Arten"; "Auftraege (ohne Akquise)"
bleibt als Filter waehlbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:42:02 +00:00

270 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Navigate replace to={target} />
return <TicketList />
}
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 (
<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}` : ''}
{showType && ticket.type ? ` · ${ticketTypeLabel(ticket.type)}` : ''}
</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, 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 = <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 und stornierte 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 (
<div className="page">
<PageHeader
title="Tickets"
subtitle="Alle Arbeitsaufträge auf einen Blick"
actions={<Button variant="primary" type="button" onClick={openCreate}>Neues Ticket</Button>}
/>
<Tabs
tabs={tabs}
active={state.tab}
onChange={(id) => { if (id !== state.tab) setFilter({ tab: id }) }}
/>
<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>
))}
</select>
<span className="grow" />
<label className="check">
<input
type="checkbox"
checked={state.urgentOnly}
onChange={(e) => setFilter({ urgentOnly: e.target.checked })}
/>
Nur dringende
</label>
</form>
{content}
<CreateTicketModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
customers={customers}
employees={employees}
/>
</div>
)
}