INBOX-1: Anfragen-Posteingang UI - Liste (Status-Filter, Sync-Button mit MailNotConfigured-Hinweis), Detail (Triage/Zuordnen/Verwerfen, KI-Entwurf mit AiKeyMissing-Hinweis, bestaetigter Versand) + nav + 6 e2e-Specs (Mock um /api/requests erweitert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
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 [page, setPage] = useState(1)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const requests = useApi(
|
||||
() =>
|
||||
listRequests({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
orderBy: 'receivedAt desc',
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
[page, status],
|
||||
)
|
||||
|
||||
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>
|
||||
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</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 */}
|
||||
<div className="filters">
|
||||
<label className="field">
|
||||
<span>{t.detail.statusLabel}</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as RequestStatus | '')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="">{t.filterAll}</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t.statusLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{requests.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>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length === 0 && (
|
||||
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{requests.data.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>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user