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:
2026-06-06 11:23:41 +02:00
parent 36454f3747
commit aaaf5b6fb7
11 changed files with 844 additions and 1 deletions

View File

@@ -22,6 +22,8 @@ import HilfePage from './pages/HilfePage'
import VertraegeListPage from './pages/VertraegeListPage'
import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
export default function App() {
return (
@@ -65,6 +67,11 @@ export default function App() {
<Route path="neu" element={<VertragWizardPage />} />
</Route>
<Route path="einstellungen" element={<EinstellungenPage />} />
{/* INBOX-1: Anfragen-Posteingang */}
<Route path="anfragen">
<Route index element={<AnfragenPage />} />
<Route path=":id" element={<AnfrageDetailPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>

View File

@@ -0,0 +1,87 @@
/**
* INBOX-1: Anfragen-Posteingang (/api/requests, Backend INBOX-0/2/3).
*
* Triage-Hinweis: PUT setzt assignedContactId UNBEDINGT aus dem Input
* (Backend überschreibt mit null, wenn das Feld fehlt) — beim reinen
* Status-Wechsel also IMMER die aktuelle Zuordnung mitsenden.
*/
import { ApiError, api } from './client'
import { toQueryString, type GridifyQuery } from './gridify'
import type { Paged } from './types'
/** C#-Enum RequestStatus — Stringnamen auf dem Draht. */
export type RequestStatus = 'New' | 'InProgress' | 'Assigned' | 'Answered' | 'Abandoned'
export const REQUEST_STATUSES: RequestStatus[] = [
'New',
'InProgress',
'Assigned',
'Answered',
'Abandoned',
]
export interface InboxRequest {
id: string
gmailMessageId: string
threadId: string | null
fromAddress: string
fromName: string | null
subject: string | null
bodyText: string | null
receivedAt: string
status: RequestStatus
assignedContactId: string | null
draftReply: string | null
answeredAt: string | null
}
export interface RequestTriage {
status?: RequestStatus | null
assignedContactId?: string | null
}
/** Ergebnis von POST /api/requests/sync. */
export interface SyncResult {
imported: number
/** null = ok; "MailNotConfigured" | "MailAuthFailed" | … */
error: string | null
}
const BASE = '/api/requests'
export function listRequests(query: GridifyQuery): Promise<Paged<InboxRequest>> {
return api.get<Paged<InboxRequest>>(`${BASE}${toQueryString(query)}`)
}
export function getRequest(id: string): Promise<InboxRequest> {
return api.get<InboxRequest>(`${BASE}/${id}`)
}
export function triageRequest(id: string, triage: RequestTriage): Promise<void> {
return api.put<void>(`${BASE}/${id}`, triage)
}
export function syncRequests(): Promise<SyncResult> {
return api.post<SyncResult>(`${BASE}/sync`, {})
}
/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */
export function draftReply(id: string): Promise<InboxRequest> {
return api.post<InboxRequest>(`${BASE}/${id}/draft`, {})
}
/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */
export function sendReply(id: string, body: string): Promise<InboxRequest> {
return api.post<InboxRequest>(`${BASE}/${id}/send`, { body })
}
/**
* ProblemDetails-Titel aus einem ApiError lesen (Send-Endpunkt meldet
* MailNotConfigured/MailAuthFailed über `title`, nicht `code`).
*/
export function problemTitle(err: unknown): string | null {
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'title' in err.body) {
const title = (err.body as { title: unknown }).title
return typeof title === 'string' ? title : null
}
return null
}

View File

@@ -22,6 +22,8 @@ const SECONDARY: NavItem[] = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
// FEAT-13: Abgabeverträge + Zuchtprofil
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },

View File

@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
if (!y || !m || !d) return iso
return `${d}.${m}.${y}`
}
/** INBOX-1: ISO-Zeitstempel → "TT.MM.JJJJ, HH:MM" (lokale Zeit, de-DE). */
export function formatDateTime(iso: string): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}

View File

@@ -0,0 +1,245 @@
/**
* INBOX-1: Anfrage-Detail — Nachricht lesen, Triage (Status / Verwerfen /
* Abnehmer zuordnen), KI-Antwortentwurf (INBOX-2) und Versand (INBOX-3).
*
* - Triage-PUT überschreibt assignedContactId immer → bei jedem Update wird
* die aktuelle Zuordnung mitgesendet (Backend-Vertrag, siehe api/requests.ts).
* - KI entwirft NUR (human-in-the-loop): Entwurf landet editierbar im
* Textfeld; gesendet wird ausschließlich nach Bestätigung.
* - 503 AiKeyMissing / MailNotConfigured / MailAuthFailed → deutsche Hinweise
* (gleiches Muster wie die Verkaufstext-KI).
*/
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { errorCode } from '../api/client'
import {
REQUEST_STATUSES,
draftReply,
getRequest,
problemTitle,
sendReply,
triageRequest,
type InboxRequest,
type RequestStatus,
} from '../api/requests'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { StatusBadge } from './AnfragenPage'
import { formatDateTime } from '../format/labels'
import './anfragen.css'
export default function AnfrageDetailPage() {
const t = de.pages.anfragen
const td = t.detail
const { id = '' } = useParams()
const request = useApi(() => getRequest(id), [id])
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
// Antwort-Text: lokaler Entwurf; aus draftReply vorbefüllt, sobald geladen.
const [reply, setReply] = useState('')
const [replyInitFor, setReplyInitFor] = useState<string | null>(null)
if (request.data && replyInitFor !== request.data.id) {
setReplyInitFor(request.data.id)
setReply(request.data.draftReply ?? '')
}
const [notice, setNotice] = useState<string | null>(null)
const [hint, setHint] = useState<string | null>(null)
/** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */
const [override, setOverride] = useState<InboxRequest | null>(null)
const current = override && override.id === id ? override : request.data
const triage = useMutation((next: { status?: RequestStatus; assignedContactId: string | null }) =>
triageRequest(id, next),
)
async function applyTriage(next: { status?: RequestStatus; assignedContactId: string | null }) {
setNotice(null)
const result = await triage.run(next)
if (result.ok && current) {
setOverride({
...current,
status: next.status ?? current.status,
assignedContactId: next.assignedContactId,
})
}
}
const draft = useMutation(() => draftReply(id))
async function onDraft() {
setHint(null)
setNotice(null)
const result = await draft.run()
if (result.ok) {
setOverride(result.value)
setReply(result.value.draftReply ?? '')
return
}
const code = errorCode(result.cause)
if (code === 'AiKeyMissing') setHint(td.aiKeyMissing)
else if (code === 'AiUpstreamError') setHint(td.aiUpstreamError)
// sonst: draft.error treibt den Alert
}
const send = useMutation(() => sendReply(id, reply))
async function onSend() {
setHint(null)
setNotice(null)
if (reply.trim() === '') {
setHint(td.sendEmptyBody)
return
}
if (!window.confirm(td.sendConfirm)) return
const result = await send.run()
if (result.ok) {
setOverride(result.value)
setNotice(td.sent)
return
}
const title = problemTitle(result.cause)
if (title === 'MailNotConfigured') setHint(t.mailNotConfigured)
else if (title === 'MailAuthFailed') setHint(t.mailAuthFailed)
}
if (request.loading) return <p className="muted">{de.common.loading}</p>
if (request.error || !current) {
return (
<section className="page">
<p className="muted">{request.error ?? td.notFound}</p>
<Link to="/anfragen" className="btn">
{td.back}
</Link>
</section>
)
}
const r = current
const contactItems = contacts.data?.items ?? []
const mutationError = triage.error ?? draft.error ?? send.error
return (
<section className="page anfrage-detail">
<header className="page-head">
<div>
<h2>{r.subject ?? td.noSubject}</h2>
<StatusBadge status={r.status} />
</div>
<div className="head-actions">
<Link to="/anfragen" className="btn">
{td.back}
</Link>
</div>
</header>
<dl className="def-list">
<div className="def-row">
<dt>{td.from}</dt>
<dd>{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}</dd>
</div>
<div className="def-row">
<dt>{td.receivedAt}</dt>
<dd>{formatDateTime(r.receivedAt)}</dd>
</div>
{r.answeredAt && (
<div className="def-row">
<dt>{td.answeredAt}</dt>
<dd>{formatDateTime(r.answeredAt)}</dd>
</div>
)}
</dl>
<h3>{td.message}</h3>
<div className="anfrage-body">{r.bodyText?.trim() ? r.bodyText : td.noBody}</div>
<h3>{td.triage}</h3>
{mutationError && <div className="alert alert--error">{mutationError}</div>}
<div className="anfrage-triage">
<label className="field">
<span>{td.statusLabel}</span>
<select
value={r.status}
disabled={triage.pending}
onChange={(e) =>
applyTriage({
status: e.target.value as RequestStatus,
assignedContactId: r.assignedContactId,
})
}
>
{REQUEST_STATUSES.map((s) => (
<option key={s} value={s}>
{t.statusLabels[s]}
</option>
))}
</select>
</label>
<label className="field">
<span>{td.assignContact}</span>
<select
value={r.assignedContactId ?? ''}
disabled={triage.pending || contacts.loading}
onChange={(e) => {
const contactId = e.target.value || null
// Zuordnung setzt den Status auf „Zugeordnet“ (Beantwortet bleibt).
applyTriage({
status:
contactId && r.status !== 'Answered' ? 'Assigned' : undefined,
assignedContactId: contactId,
})
}}
>
<option value="">{td.assignNone}</option>
{contactItems.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
{r.status !== 'Abandoned' && (
<button
type="button"
className="btn btn--danger anfrage-abandon"
disabled={triage.pending}
onClick={() => {
if (!window.confirm(td.abandonConfirm)) return
applyTriage({ status: 'Abandoned', assignedContactId: r.assignedContactId })
}}
>
{td.abandon}
</button>
)}
</div>
<h3>{td.reply}</h3>
{notice && <div className="alert">{notice}</div>}
{hint && <div className="alert">{hint}</div>}
<div className="form anfrage-reply">
<textarea
className="anfrage-reply__text"
placeholder={td.replyPlaceholder}
value={reply}
onChange={(e) => setReply(e.target.value)}
/>
<p className="muted anfrage-reply__hint">{td.draftHint}</p>
<div className="form-actions">
<button type="button" className="btn" onClick={onDraft} disabled={draft.pending}>
{draft.pending ? td.drafting : td.draftButton}
</button>
<button
type="button"
className="btn btn--primary"
onClick={onSend}
disabled={send.pending}
>
{send.pending ? td.sending : td.send}
</button>
</div>
</div>
</section>
)
}

View 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>
)
}

View File

@@ -0,0 +1,84 @@
/* INBOX-1: Anfragen-Posteingang — seitenspezifische Stile
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
/* ── Listen-Karte ── */
.anfrage-card {
grid-template-columns: 1fr auto;
}
.anfrage-card__subject {
grid-column: 1 / -1;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Status-Badges (eigene Farben je Status) ── */
.anfrage-badge--new {
background: var(--color-accent);
color: #fff;
}
.anfrage-badge--inprogress {
background: #e7eef6;
color: #3a6ea5;
}
.anfrage-badge--assigned {
background: #efe6f6;
color: #7a4ea5;
}
.anfrage-badge--answered {
background: #e6f2e6;
color: #3a7a3a;
}
.anfrage-badge--abandoned {
background: #ececec;
color: #666;
}
/* ── Detail ── */
.anfrage-body {
white-space: pre-wrap;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 0.9rem 1rem;
margin: 0.5rem 0 1.25rem;
overflow-wrap: anywhere;
}
.anfrage-triage {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 0.5rem 0 1.25rem;
}
.anfrage-triage .field {
min-width: 12rem;
}
.anfrage-abandon {
margin-left: auto;
}
.anfrage-reply {
max-width: 40rem;
}
.anfrage-reply__text {
min-height: 10rem;
}
.anfrage-reply__hint {
margin: 0;
font-size: 0.8rem;
}

View File

@@ -26,6 +26,8 @@ export const de = {
// FEAT-13 (Kelly): Verträge + Einstellungen
contracts: 'Verträge',
settings: 'Einstellungen',
// INBOX-1 (Kelly): Anfragen-Posteingang
requests: 'Anfragen',
openMenu: 'Menü öffnen',
closeMenu: 'Menü schließen',
mainNavigation: 'Hauptnavigation',
@@ -455,6 +457,61 @@ export const de = {
saved: 'Gespeichert.',
},
},
// ── INBOX-1 (Kelly): Anfragen-Posteingang ──
anfragen: {
title: 'Anfragen',
empty: 'Keine Anfragen — hole neue E-Mails mit „Anfragen abrufen“.',
emptyFiltered: 'Keine Anfragen mit diesem Status.',
countText: (n: number) => (n === 1 ? '1 Anfrage' : `${n} Anfragen`),
sync: 'Anfragen abrufen',
syncing: 'Abrufen …',
syncImported: (n: number) =>
n === 0 ? 'Keine neuen Anfragen.' : n === 1 ? '1 neue Anfrage abgerufen.' : `${n} neue Anfragen abgerufen.`,
mailNotConfigured:
'Gmail ist noch nicht eingerichtet — Adresse und App-Passwort folgen in den Einstellungen.',
mailAuthFailed:
'Gmail-Anmeldung fehlgeschlagen — bitte das App-Passwort neu eintragen.',
filterAll: 'Alle',
statusLabels: {
New: 'Neu',
InProgress: 'In Bearbeitung',
Assigned: 'Zugeordnet',
Answered: 'Beantwortet',
Abandoned: 'Verworfen',
},
// Detailansicht
detail: {
back: 'Zurück zur Liste',
notFound: 'Diese Anfrage wurde nicht gefunden.',
from: 'Von',
receivedAt: 'Eingegangen',
answeredAt: 'Beantwortet am',
message: 'Nachricht',
noBody: '(kein Text)',
noSubject: '(kein Betreff)',
// Triage
triage: 'Bearbeitung',
statusLabel: 'Status',
abandon: 'Verwerfen',
abandonConfirm: 'Anfrage wirklich verwerfen?',
assignContact: 'Abnehmer zuordnen',
assignNone: '— kein Abnehmer —',
assignedTo: 'Zugeordneter Abnehmer',
// Antwort
reply: 'Antwort',
replyPlaceholder: 'Antwort schreiben oder mit KI entwerfen …',
draftButton: 'Antwort entwerfen',
drafting: 'Entwurf wird erstellt …',
draftHint: 'Die KI erstellt nur einen Entwurf — gesendet wird erst nach deiner Bestätigung.',
aiKeyMissing: 'KI-Schlüssel fehlt — der Entwurfs-Assistent wird mit dem Schlüssel aktiviert.',
aiUpstreamError: 'Der KI-Dienst hat gerade ein Problem — bitte später erneut versuchen.',
send: 'Antwort senden',
sending: 'Senden …',
sendConfirm: 'Antwort jetzt per E-Mail senden?',
sendEmptyBody: 'Bitte zuerst eine Antwort schreiben.',
sent: 'Antwort gesendet — die Anfrage ist als „Beantwortet“ markiert.',
},
},
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
kontakte: {
title: 'Kontakte',