Zwei Probleme bei der großen Rennmausliste (und allen Infinite-Scroll-Listen) nach Zurück-Navigation behoben: 1) Event- statt Timer-basiert: Die Scroll-Wiederherstellung hängt sich jetzt per ResizeObserver an die Höhenänderung der Seite (Liste ist wieder da) und springt dann zur gemerkten Position — statt auf einen festen Timer zu warten. Bricht bei eigener Scroll-Eingabe ab; Sicherheitsnetz nach 15 s. 2) Infinite-Scroll baut Höhe wieder auf: useInfiniteList merkt sich (optionaler restoreKey) die geladene Seitenzahl je Query und lädt sie nach einer Zurück- Navigation wieder nach — sonst war die Liste nur 1 Seite hoch und die Position unerreichbar. Aktiviert für Rennmäuse, Kontakte, Würfe, Becken, Anfragen, Verträge. tsc/eslint/vitest grün; e2e (Listen + Navigation, 41) grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
/**
|
|
* INBOX-1: Anfragen-Posteingang — Liste mit Status-Filter (Gridify) und
|
|
* manuellem Gmail-Abruf („Anfragen abrufen“, POST /api/requests/sync).
|
|
* Solange Gmail unkonfiguriert ist, antwortet der Sync mit MailNotConfigured
|
|
* → freundlicher deutscher Hinweis statt Fehler.
|
|
*/
|
|
import { useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { de } from '../strings/de'
|
|
import {
|
|
REQUEST_STATUSES,
|
|
listRequests,
|
|
syncRequests,
|
|
type RequestStatus,
|
|
} from '../api/requests'
|
|
import { useMutation } from '../hooks/useApi'
|
|
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
|
|
import { formatDateTime } from '../format/labels'
|
|
import { FilterPanel } from '../components/FilterPanel'
|
|
import './anfragen.css'
|
|
|
|
const PAGE_SIZE = 20
|
|
|
|
export function StatusBadge({ status }: { status: RequestStatus }) {
|
|
return (
|
|
<span className={`badge anfrage-badge anfrage-badge--${status.toLowerCase()}`}>
|
|
{de.pages.anfragen.statusLabels[status]}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export default function AnfragenPage() {
|
|
const t = de.pages.anfragen
|
|
const [status, setStatus] = useState<RequestStatus | ''>('')
|
|
const [notice, setNotice] = useState<string | null>(null)
|
|
|
|
const requests = useInfiniteList(
|
|
(page) =>
|
|
listRequests({
|
|
page,
|
|
pageSize: PAGE_SIZE,
|
|
orderBy: 'receivedAt desc,id',
|
|
filter: status === '' ? undefined : `status==${status}`,
|
|
}),
|
|
`${status}`,
|
|
'anfragen',
|
|
)
|
|
const sentinelRef = useInfiniteSentinel(requests)
|
|
const { items, total, loading } = requests
|
|
|
|
const sync = useMutation(() => syncRequests())
|
|
async function onSync() {
|
|
setNotice(null)
|
|
const result = await sync.run()
|
|
if (!result.ok) return // sync.error treibt den Alert
|
|
if (result.value.error === 'MailNotConfigured') setNotice(t.mailNotConfigured)
|
|
else if (result.value.error === 'MailAuthFailed') setNotice(t.mailAuthFailed)
|
|
else {
|
|
setNotice(t.syncImported(result.value.imported))
|
|
if (result.value.imported > 0) requests.reload()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="page">
|
|
<header className="page-head">
|
|
<div>
|
|
<h2>{t.title}</h2>
|
|
{!loading && <p className="muted">{t.countText(total)}</p>}
|
|
</div>
|
|
<div className="head-actions">
|
|
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
|
{sync.pending ? t.syncing : t.sync}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{notice && <div className="alert">{notice}</div>}
|
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
|
|
|
{/* Status-Filter */}
|
|
<FilterPanel
|
|
activeCount={status !== '' ? 1 : 0}
|
|
onReset={() => setStatus('')}
|
|
>
|
|
<label className="field">
|
|
<span>{t.detail.statusLabel}</span>
|
|
<select
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
|
|
>
|
|
<option value="">{t.filterAll}</option>
|
|
{REQUEST_STATUSES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{t.statusLabels[s]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</FilterPanel>
|
|
|
|
{loading && <p className="muted">{de.common.loading}</p>}
|
|
{requests.error && (
|
|
<div className="alert alert--error">
|
|
<span>{requests.error}</span>
|
|
<button type="button" className="btn" onClick={requests.reload}>
|
|
{de.common.retry}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && items.length === 0 && (
|
|
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
|
)}
|
|
|
|
{items.length > 0 && (
|
|
<ul className="card-list">
|
|
{items.map((r) => (
|
|
<li key={r.id}>
|
|
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
|
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
|
<StatusBadge status={r.status} />
|
|
<span className="anfrage-card__subject">
|
|
{r.subject ?? de.pages.anfragen.detail.noSubject}
|
|
</span>
|
|
<span className="gerbil-card__meta">{formatDateTime(r.receivedAt)}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Infinite-scroll sentinel + "loading more" indicator. */}
|
|
{requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
|
|
{requests.loadingMore && (
|
|
<p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
|
|
{de.common.loading}
|
|
</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|