FEAT: Normalize Anke B./Anke Busch contact, and save other infinite scroll layout updates
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user