FEAT: Normalize Anke B./Anke Busch contact, and save other infinite scroll layout updates

This commit is contained in:
2026-06-13 08:11:17 +02:00
parent d189da954f
commit f3f4534392
5 changed files with 84 additions and 105 deletions

View File

@@ -125,3 +125,27 @@ export function useInfiniteList<T>(
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload } return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
} }
/**
* Wires an IntersectionObserver to a sentinel element that triggers loadMore() as
* it scrolls into view. Render the returned ref on a small element shown only while
* `list.hasMore`, e.g. `{list.hasMore && <div ref={sentinelRef} />}`. Re-subscribes
* when the load state changes so it keeps paging while the sentinel stays in view.
*/
export function useInfiniteSentinel<T>(list: InfiniteListState<T>) {
const ref = useRef<HTMLDivElement | null>(null)
const { hasMore, loading, loadingMore, loadMore } = list
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) loadMore()
},
{ rootMargin: '300px 0px' },
)
io.observe(el)
return () => io.disconnect()
}, [hasMore, loading, loadingMore, loadMore])
return ref
}

View File

@@ -13,7 +13,8 @@ import {
syncRequests, syncRequests,
type RequestStatus, type RequestStatus,
} from '../api/requests' } from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi' import { useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDateTime } from '../format/labels' import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel' import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css' import './anfragen.css'
@@ -31,19 +32,20 @@ export function StatusBadge({ status }: { status: RequestStatus }) {
export default function AnfragenPage() { export default function AnfragenPage() {
const t = de.pages.anfragen const t = de.pages.anfragen
const [status, setStatus] = useState<RequestStatus | ''>('') const [status, setStatus] = useState<RequestStatus | ''>('')
const [page, setPage] = useState(1)
const [notice, setNotice] = useState<string | null>(null) const [notice, setNotice] = useState<string | null>(null)
const requests = useApi( const requests = useInfiniteList(
() => (page) =>
listRequests({ listRequests({
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
orderBy: 'receivedAt desc', orderBy: 'receivedAt desc,id',
filter: status === '' ? undefined : `status==${status}`, filter: status === '' ? undefined : `status==${status}`,
}), }),
[page, status], `${status}`,
) )
const sentinelRef = useInfiniteSentinel(requests)
const { items, total, loading } = requests
const sync = useMutation(() => syncRequests()) const sync = useMutation(() => syncRequests())
async function onSync() { async function onSync() {
@@ -63,7 +65,7 @@ export default function AnfragenPage() {
<header className="page-head"> <header className="page-head">
<div> <div>
<h2>{t.title}</h2> <h2>{t.title}</h2>
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>} {!loading && <p className="muted">{t.countText(total)}</p>}
</div> </div>
<div className="head-actions"> <div className="head-actions">
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}> <button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
@@ -78,16 +80,13 @@ export default function AnfragenPage() {
{/* Status-Filter */} {/* Status-Filter */}
<FilterPanel <FilterPanel
activeCount={status !== '' ? 1 : 0} activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }} onReset={() => setStatus('')}
> >
<label className="field"> <label className="field">
<span>{t.detail.statusLabel}</span> <span>{t.detail.statusLabel}</span>
<select <select
value={status} value={status}
onChange={(e) => { onChange={(e) => setStatus(e.target.value as RequestStatus | '')}
setStatus(e.target.value as RequestStatus | '')
setPage(1)
}}
> >
<option value="">{t.filterAll}</option> <option value="">{t.filterAll}</option>
{REQUEST_STATUSES.map((s) => ( {REQUEST_STATUSES.map((s) => (
@@ -99,7 +98,7 @@ export default function AnfragenPage() {
</label> </label>
</FilterPanel> </FilterPanel>
{requests.loading && <p className="muted">{de.common.loading}</p>} {loading && <p className="muted">{de.common.loading}</p>}
{requests.error && ( {requests.error && (
<div className="alert alert--error"> <div className="alert alert--error">
<span>{requests.error}</span> <span>{requests.error}</span>
@@ -109,13 +108,13 @@ export default function AnfragenPage() {
</div> </div>
)} )}
{requests.data && requests.data.items.length === 0 && ( {!loading && items.length === 0 && (
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p> <p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
)} )}
{requests.data && requests.data.items.length > 0 && ( {items.length > 0 && (
<ul className="card-list"> <ul className="card-list">
{requests.data.items.map((r) => ( {items.map((r) => (
<li key={r.id}> <li key={r.id}>
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card"> <Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span> <span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
@@ -130,24 +129,12 @@ export default function AnfragenPage() {
</ul> </ul>
)} )}
{requests.data && requests.data.totalCount > PAGE_SIZE && ( {/* Infinite-scroll sentinel + "loading more" indicator. */}
<nav className="pager" aria-label="Seitennavigation"> {requests.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}> {requests.loadingMore && (
{de.common.previous} <p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
</button> {de.common.loading}
<span> </p>
{de.common.page} {page} {de.common.of}{' '}
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
</span>
<button
type="button"
className="btn"
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
)} )}
</section> </section>
) )

View File

@@ -1,35 +1,34 @@
/** FEAT-2: Becken-Liste — Gridify-Namenssuche + Paging, Link auf Detailseite. */ /** FEAT-2: Becken-Liste — Gridify-Namenssuche + Infinite Scroll, Link auf Detailseite. */
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { listEnclosuresPaged } from '../api/enclosures' import { listEnclosuresPaged } from '../api/enclosures'
import { condition, type GridifyQuery } from '../api/gridify' import { condition } from '../api/gridify'
import { useApi } from '../hooks/useApi' import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
export default function BeckenPage() { export default function BeckenPage() {
const t = de.pages.becken const t = de.pages.becken
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const filter = search.trim() const filter = search.trim()
? condition({ field: 'name', op: 'contains', value: search.trim() }) ? condition({ field: 'name', op: 'contains', value: search.trim() })
: undefined : undefined
const query: GridifyQuery = { filter, orderBy: 'name', page, pageSize: PAGE_SIZE } // Infinite scroll; trailing `id` keeps OFFSET pagination stable (no repeats/omits).
const list = useInfiniteList(
const enclosures = useApi(() => listEnclosuresPaged(query), [filter, page]) (page) => listEnclosuresPaged({ filter, orderBy: 'name,id', page, pageSize: PAGE_SIZE }),
`${filter ?? ''}`,
const total = enclosures.data?.totalCount ?? 0 )
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const sentinelRef = useInfiniteSentinel(list)
const items = enclosures.data?.items ?? [] const { items, total, loading } = list
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
<div> <div>
<h2>{t.title}</h2> <h2>{t.title}</h2>
{enclosures.data && ( {!loading && (
<p className="muted"> <p className="muted">
{total} {t.countLabel} {total} {t.countLabel}
</p> </p>
@@ -46,27 +45,22 @@ export default function BeckenPage() {
className="input" className="input"
placeholder={t.searchPlaceholder} placeholder={t.searchPlaceholder}
value={search} value={search}
onChange={(e) => { onChange={(e) => setSearch(e.target.value)}
setSearch(e.target.value)
setPage(1)
}}
aria-label={t.fields.name} aria-label={t.fields.name}
/> />
</div> </div>
{enclosures.loading && <p className="muted">{de.common.loading}</p>} {loading && <p className="muted">{de.common.loading}</p>}
{enclosures.error && ( {list.error && (
<div className="alert alert--error"> <div className="alert alert--error">
<span>{enclosures.error}</span> <span>{list.error}</span>
<button type="button" className="btn" onClick={enclosures.reload}> <button type="button" className="btn" onClick={list.reload}>
{de.common.retry} {de.common.retry}
</button> </button>
</div> </div>
)} )}
{!enclosures.loading && !enclosures.error && items.length === 0 && ( {!loading && !list.error && items.length === 0 && <p className="muted">{t.empty}</p>}
<p className="muted">{t.empty}</p>
)}
{items.length > 0 && ( {items.length > 0 && (
<ul className="card-list"> <ul className="card-list">
@@ -81,28 +75,12 @@ export default function BeckenPage() {
</ul> </ul>
)} )}
{totalPages > 1 && ( {/* Infinite-scroll sentinel + "loading more" indicator. */}
<nav className="pager" aria-label="Seitennavigation"> {list.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
<button {list.loadingMore && (
type="button" <p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
className="btn" {de.common.loading}
disabled={page <= 1} </p>
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{de.common.previous}
</button>
<span className="muted">
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
{de.common.next}
</button>
</nav>
)} )}
</section> </section>
) )

View File

@@ -5,6 +5,7 @@ import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts' import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
import { listContactsPaged } from '../api/contacts' import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
import './vertragWizard.css' import './vertragWizard.css'
@@ -16,12 +17,14 @@ function formatPrice(price: number): string {
export default function VertraegeListPage() { export default function VertraegeListPage() {
const t = de.pages.vertraege const t = de.pages.vertraege
const [page, setPage] = useState(1)
const contracts = useApi( const contracts = useInfiniteList(
() => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc' }), (page) => listContracts({ page, pageSize: PAGE_SIZE, orderBy: 'createdAt desc,id' }),
[page], 'contracts',
) )
const sentinelRef = useInfiniteSentinel(contracts)
const { items, total: totalCount, loading } = contracts
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), []) const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
const contactName = useMemo( const contactName = useMemo(
() => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])), () => new Map((contacts.data?.items ?? []).map((c) => [c.id, c.name])),
@@ -35,8 +38,8 @@ export default function VertraegeListPage() {
if (result.ok) contracts.reload() if (result.ok) contracts.reload()
} }
if (contracts.loading) return <p className="muted">{de.common.loading}</p> if (loading) return <p className="muted">{de.common.loading}</p>
if (contracts.error || !contracts.data) { if (contracts.error) {
return ( return (
<section className="page"> <section className="page">
<h2>{t.title}</h2> <h2>{t.title}</h2>
@@ -50,9 +53,6 @@ export default function VertraegeListPage() {
) )
} }
const { items, totalCount } = contracts.data
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
return ( return (
<section className="page"> <section className="page">
<header className="page-head"> <header className="page-head">
@@ -98,23 +98,12 @@ export default function VertraegeListPage() {
</ul> </ul>
)} )}
{totalPages > 1 && ( {/* Infinite-scroll sentinel + "loading more" indicator. */}
<nav className="pager" aria-label="Seitennavigation"> {contracts.hasMore && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}> {contracts.loadingMore && (
{de.common.previous} <p className="muted" style={{ textAlign: 'center', padding: '0.75rem 0' }}>
</button> {de.common.loading}
<span> </p>
{de.common.page} {page} {de.common.of} {totalPages}
</span>
<button
type="button"
className="btn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
{de.common.next}
</button>
</nav>
)} )}
</section> </section>
) )

View File

@@ -88,6 +88,7 @@ def get_normalized_contact_name(name):
"hanserenners": "Hanse Renner", "hanserenners": "Hanse Renner",
"blackforestgv": "Black Forest", "blackforestgv": "Black Forest",
"topol": "Topolino", "topol": "Topolino",
"ankeb": "Anke Busch",
} }
if n in norm_map: if n in norm_map: