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:
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -135,6 +135,53 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'PUT') return json(route, 204)
|
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
|
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||||
// generischen /gerbils/:id-Route abfangen.
|
// generischen /gerbils/:id-Route abfangen.
|
||||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* FEAT-2/FEAT-6-Flüsse.
|
* FEAT-2/FEAT-6-Flüsse.
|
||||||
*/
|
*/
|
||||||
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||||
|
import type { InboxRequest } from '../src/api/requests'
|
||||||
|
|
||||||
export interface HealthRecordRow {
|
export interface HealthRecordRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,6 +34,11 @@ export interface MockDb {
|
|||||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||||
healthRecords: HealthRecordRow[]
|
healthRecords: HealthRecordRow[]
|
||||||
weightRecords: WeightRecordRow[]
|
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(
|
function gerbil(
|
||||||
@@ -142,5 +148,63 @@ export function seedDb(): MockDb {
|
|||||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
{ 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: '<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,
|
||||||
|
requests,
|
||||||
|
aiConfigured: true,
|
||||||
|
mailConfigured: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import HilfePage from './pages/HilfePage'
|
|||||||
import VertraegeListPage from './pages/VertraegeListPage'
|
import VertraegeListPage from './pages/VertraegeListPage'
|
||||||
import VertragWizardPage from './pages/VertragWizardPage'
|
import VertragWizardPage from './pages/VertragWizardPage'
|
||||||
import EinstellungenPage from './pages/EinstellungenPage'
|
import EinstellungenPage from './pages/EinstellungenPage'
|
||||||
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -65,6 +67,11 @@ export default function App() {
|
|||||||
<Route path="neu" element={<VertragWizardPage />} />
|
<Route path="neu" element={<VertragWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="einstellungen" element={<EinstellungenPage />} />
|
<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 path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
87
gerbil-manager-web/src/api/requests.ts
Normal file
87
gerbil-manager-web/src/api/requests.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ const SECONDARY: NavItem[] = [
|
|||||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, 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: '📊' },
|
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||||
|
|||||||
@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
if (!y || !m || !d) return iso
|
if (!y || !m || !d) return iso
|
||||||
return `${d}.${m}.${y}`
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
gerbil-manager-web/src/pages/anfragen.css
Normal file
84
gerbil-manager-web/src/pages/anfragen.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export const de = {
|
|||||||
// FEAT-13 (Kelly): Verträge + Einstellungen
|
// FEAT-13 (Kelly): Verträge + Einstellungen
|
||||||
contracts: 'Verträge',
|
contracts: 'Verträge',
|
||||||
settings: 'Einstellungen',
|
settings: 'Einstellungen',
|
||||||
|
// INBOX-1 (Kelly): Anfragen-Posteingang
|
||||||
|
requests: 'Anfragen',
|
||||||
openMenu: 'Menü öffnen',
|
openMenu: 'Menü öffnen',
|
||||||
closeMenu: 'Menü schließen',
|
closeMenu: 'Menü schließen',
|
||||||
mainNavigation: 'Hauptnavigation',
|
mainNavigation: 'Hauptnavigation',
|
||||||
@@ -455,6 +457,61 @@ export const de = {
|
|||||||
saved: 'Gespeichert.',
|
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) ──
|
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
||||||
kontakte: {
|
kontakte: {
|
||||||
title: 'Kontakte',
|
title: 'Kontakte',
|
||||||
|
|||||||
Reference in New Issue
Block a user