Merge feature/inbox-1: Anfragen-Posteingang UI (list/triage/assign/draft/send) [god-QA pending]

# Conflicts:
#	gerbil-manager-web/e2e/mock-data.ts
#	gerbil-manager-web/src/App.tsx
This commit is contained in:
2026-06-06 11:29:21 +02:00
11 changed files with 845 additions and 1 deletions

View File

@@ -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()
})
})

View File

@@ -135,6 +135,53 @@ export async function installMockApi(page: Page): Promise<MockDb> {
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') {

View File

@@ -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
@@ -52,6 +53,11 @@ export interface MockDb {
healthRecords: HealthRecordRow[]
weightRecords: WeightRecordRow[]
pages: MockPage[]
// 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(
@@ -190,5 +196,64 @@ export function seedDb(): MockDb {
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
]
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords, pages }
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
const requests: InboxRequest[] = [
{
id: 'req-anna',
gmailMessageId: '<anna-1@mail.example>',
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: '<ben-1@mail.example>',
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: '<clara-1@mail.example>',
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,
pages,
requests,
aiConfigured: true,
mailConfigured: true,
}
}