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:
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user