diff --git a/gerbil-manager-web/e2e/anfragen.spec.ts b/gerbil-manager-web/e2e/anfragen.spec.ts new file mode 100644 index 0000000..6e58d73 --- /dev/null +++ b/gerbil-manager-web/e2e/anfragen.spec.ts @@ -0,0 +1,87 @@ +/** + * INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl. + * AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis). + * Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock. + */ +import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures' + +const ta = de.pages.anfragen +const td = ta.detail + +test.describe('Anfragen', () => { + test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => { + skipUnlessMock() + await gotoSection(page, de.nav.requests) + await expect(page.getByRole('heading', { name: ta.title })).toBeVisible() + + // Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.) + const cards = page.locator('.anfrage-card') + await expect(cards).toHaveCount(3) + await expect(cards.nth(0)).toContainText('Anna Albrecht') + await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse + + // Status-Badge auf der Karte + await expect(cards.nth(0)).toContainText(ta.statusLabels.New) + + // Filter: nur Beantwortet + await page.getByLabel(td.statusLabel).selectOption('Answered') + await expect(cards).toHaveCount(1) + await expect(cards.first()).toContainText('Danke!') + }) + + test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => { + skipUnlessMock() + mockDb!.mailConfigured = false + await page.goto('/anfragen') + await page.getByRole('button', { name: ta.sync }).click() + await expect(page.getByText(ta.mailNotConfigured)).toBeVisible() + }) + + test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => { + skipUnlessMock() + await page.goto('/anfragen/req-anna') + await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible() + await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible() + + await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' }) + await expect(page.locator('.anfrage-badge--assigned')).toBeVisible() + expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier') + + // Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt + acceptNextDialog(page) + await page.getByRole('button', { name: td.abandon }).click() + await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible() + expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier') + }) + + test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => { + skipUnlessMock() + await page.goto('/anfragen/req-anna') + + await page.getByRole('button', { name: td.draftButton }).click() + const textarea = page.locator('.anfrage-reply__text') + await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/) + + // Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis + await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!') + acceptNextDialog(page) + await page.getByRole('button', { name: td.send }).click() + await expect(page.getByText(td.sent)).toBeVisible() + await expect(page.locator('.anfrage-badge--answered')).toBeVisible() + }) + + test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => { + skipUnlessMock() + mockDb!.aiConfigured = false + await page.goto('/anfragen/req-anna') + await page.getByRole('button', { name: td.draftButton }).click() + await expect(page.getByText(td.aiKeyMissing)).toBeVisible() + }) + + test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => { + skipUnlessMock() + await page.goto('/anfragen/req-anna') + await page.getByRole('button', { name: td.send }).click() + await expect(page.getByText(td.sendEmptyBody)).toBeVisible() + }) +}) diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 9d22412..9ff6dea 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -135,6 +135,53 @@ export async function installMockApi(page: Page): Promise { if (method === 'PUT') return json(route, 204) } + // ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ── + if (path === '/requests/sync' && method === 'POST') { + return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' }) + } + let rm = path.match(/^\/requests\/([^/]+)\/draft$/) + if (rm && method === 'POST') { + if (!db.aiConfigured) { + return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' }) + } + const r = db.requests.find((x) => x.id === rm![1]) + if (!r) return json(route, 404, { title: 'Not Found' }) + r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße` + return json(route, 200, r) + } + rm = path.match(/^\/requests\/([^/]+)\/send$/) + if (rm && method === 'POST') { + if (!db.mailConfigured) { + return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' }) + } + const r = db.requests.find((x) => x.id === rm![1]) + if (!r) return json(route, 404, { title: 'Not Found' }) + r.status = 'Answered' + r.answeredAt = '2026-06-06T10:00:00Z' + return json(route, 200, r) + } + rm = path.match(/^\/requests(?:\/([^/]+))?$/) + if (rm) { + const reqId = rm[1] ? decodeURIComponent(rm[1]) : null + if (!reqId && method === 'GET') { + return json(route, 200, pagedResponse(db.requests as unknown as Row[], url)) + } + const r = db.requests.find((x) => x.id === reqId) + if (!r) return json(route, 404, { title: 'Not Found' }) + if (method === 'GET') return json(route, 200, r) + if (method === 'PUT') { + // Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen. + const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null } + r.assignedContactId = body.assignedContactId ?? null + if (body.status) { + r.status = body.status as typeof r.status + if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z' + } + return json(route, 204) + } + return json(route, 405) + } + // SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der // generischen /gerbils/:id-Route abfangen. if (path === '/gerbils/breeders' && method === 'GET') { diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 5b143f1..c48c024 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -6,6 +6,7 @@ * FEAT-2/FEAT-6-Flüsse. */ import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types' +import type { InboxRequest } from '../src/api/requests' export interface HealthRecordRow { id: string @@ -33,6 +34,11 @@ export interface MockDb { colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[] healthRecords: HealthRecordRow[] weightRecords: WeightRecordRow[] + // INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks + // (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler. + requests: InboxRequest[] + aiConfigured: boolean + mailConfigured: boolean } function gerbil( @@ -142,5 +148,63 @@ export function seedDb(): MockDb { { id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null }, ] - return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords } + // INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar) + const requests: InboxRequest[] = [ + { + id: 'req-anna', + gmailMessageId: '', + threadId: 'thr-1', + fromAddress: 'anna@example.de', + fromName: 'Anna Albrecht', + subject: 'Anfrage: Pärchen zur Abgabe?', + bodyText: + 'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna', + receivedAt: '2026-06-05T18:30:00Z', + status: 'New', + assignedContactId: null, + draftReply: null, + answeredAt: null, + }, + { + id: 'req-ben', + gmailMessageId: '', + threadId: 'thr-2', + fromAddress: 'ben@example.org', + fromName: 'Ben Berger', + subject: 'Frage zur Haltung', + bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?', + receivedAt: '2026-06-04T09:00:00Z', + status: 'InProgress', + assignedContactId: null, + draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …', + answeredAt: null, + }, + { + id: 'req-clara', + gmailMessageId: '', + threadId: null, + fromAddress: 'clara@example.com', + fromName: null, + subject: 'Danke!', + bodyText: 'Die beiden sind super angekommen — danke nochmal!', + receivedAt: '2026-06-01T12:00:00Z', + status: 'Answered', + assignedContactId: 'con-meier', + draftReply: null, + answeredAt: '2026-06-02T08:00:00Z', + }, + ] + + return { + gerbils, + litters, + enclosures, + contacts, + colorVarieties, + healthRecords, + weightRecords, + requests, + aiConfigured: true, + mailConfigured: true, + } } diff --git a/gerbil-manager-web/src/App.tsx b/gerbil-manager-web/src/App.tsx index c5b2f8e..2fbabf8 100644 --- a/gerbil-manager-web/src/App.tsx +++ b/gerbil-manager-web/src/App.tsx @@ -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() { } /> } /> + {/* INBOX-1: Anfragen-Posteingang */} + + } /> + } /> + } /> diff --git a/gerbil-manager-web/src/api/requests.ts b/gerbil-manager-web/src/api/requests.ts new file mode 100644 index 0000000..6a52186 --- /dev/null +++ b/gerbil-manager-web/src/api/requests.ts @@ -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> { + return api.get>(`${BASE}${toQueryString(query)}`) +} + +export function getRequest(id: string): Promise { + return api.get(`${BASE}/${id}`) +} + +export function triageRequest(id: string, triage: RequestTriage): Promise { + return api.put(`${BASE}/${id}`, triage) +} + +export function syncRequests(): Promise { + return api.post(`${BASE}/sync`, {}) +} + +/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */ +export function draftReply(id: string): Promise { + return api.post(`${BASE}/${id}/draft`, {}) +} + +/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */ +export function sendReply(id: string, body: string): Promise { + return api.post(`${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 +} diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 05d13fe..6a5f7cd 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -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: '📄' }, diff --git a/gerbil-manager-web/src/format/labels.ts b/gerbil-manager-web/src/format/labels.ts index 051a03d..81c0b10 100644 --- a/gerbil-manager-web/src/format/labels.ts +++ b/gerbil-manager-web/src/format/labels.ts @@ -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', + }) +} diff --git a/gerbil-manager-web/src/pages/AnfrageDetailPage.tsx b/gerbil-manager-web/src/pages/AnfrageDetailPage.tsx new file mode 100644 index 0000000..dd77431 --- /dev/null +++ b/gerbil-manager-web/src/pages/AnfrageDetailPage.tsx @@ -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(null) + if (request.data && replyInitFor !== request.data.id) { + setReplyInitFor(request.data.id) + setReply(request.data.draftReply ?? '') + } + + const [notice, setNotice] = useState(null) + const [hint, setHint] = useState(null) + + /** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */ + const [override, setOverride] = useState(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

{de.common.loading}

+ if (request.error || !current) { + return ( +
+

{request.error ?? td.notFound}

+ + {td.back} + +
+ ) + } + + const r = current + const contactItems = contacts.data?.items ?? [] + const mutationError = triage.error ?? draft.error ?? send.error + + return ( +
+
+
+

{r.subject ?? td.noSubject}

+ +
+
+ + {td.back} + +
+
+ +
+
+
{td.from}
+
{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}
+
+
+
{td.receivedAt}
+
{formatDateTime(r.receivedAt)}
+
+ {r.answeredAt && ( +
+
{td.answeredAt}
+
{formatDateTime(r.answeredAt)}
+
+ )} +
+ +

{td.message}

+
{r.bodyText?.trim() ? r.bodyText : td.noBody}
+ +

{td.triage}

+ {mutationError &&
{mutationError}
} +
+ + + + + {r.status !== 'Abandoned' && ( + + )} +
+ +

{td.reply}

+ {notice &&
{notice}
} + {hint &&
{hint}
} +
+