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:
1 parent
b544b83da0
commit
2ecc0b627a
1 file changed
+236
-131
+236
-131
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user