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>
270 lines
11 KiB
React
270 lines
11 KiB
React
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>
|
||
)
|
||
}
|