diff --git a/aos-shots/v10-estimate-detail.png b/aos-shots/v10-estimate-detail.png new file mode 100644 index 0000000..ce82532 Binary files /dev/null and b/aos-shots/v10-estimate-detail.png differ diff --git a/aos-shots/v10-estimates.png b/aos-shots/v10-estimates.png new file mode 100644 index 0000000..6f98f9f Binary files /dev/null and b/aos-shots/v10-estimates.png differ diff --git a/aos-shots/v10-support-summarized.png b/aos-shots/v10-support-summarized.png new file mode 100644 index 0000000..0817d2c Binary files /dev/null and b/aos-shots/v10-support-summarized.png differ diff --git a/aos-shots/v10-support.png b/aos-shots/v10-support.png new file mode 100644 index 0000000..0817d2c Binary files /dev/null and b/aos-shots/v10-support.png differ diff --git a/aos-verify8.sh b/aos-verify8.sh new file mode 100644 index 0000000..570e3f5 --- /dev/null +++ b/aos-verify8.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Verify Support Tickets (AI) + Estimates modules +set -u +cd /home/z/my-project +BASE=http://127.0.0.1:3000 +SHOTS=/home/z/my-project/aos-shots +mkdir -p "$SHOTS" + +echo "=== Start server ===" +pkill -f "next dev" 2>/dev/null; pkill -f "tee dev.log" 2>/dev/null; sleep 2 +bun run dev > dev.log 2>&1 & +SRV=$! +for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done +echo " ready after ${i}s"; sleep 2 + +echo "=== API tests ===" +echo -n " /api/aos/tickets -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/tickets" +echo -n " /api/aos/estimates -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/estimates" +curl -s "$BASE/api/aos/tickets" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' tickets: {len(d[\"tickets\"])}, stats: {d[\"stats\"]}')" 2>/dev/null +curl -s "$BASE/api/aos/estimates" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' estimates: {len(d[\"estimates\"])}, winRate: {d[\"stats\"][\"winRate\"]}%')" 2>/dev/null + +echo "" +echo "=== AI ticket summarize API ===" +TKT_ID=$(curl -s "$BASE/api/aos/tickets" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tickets'][0]['id'])" 2>/dev/null) +echo " ticket id: $TKT_ID" +SUM=$(curl -s -m 70 -X POST "$BASE/api/aos/tickets/$TKT_ID/summarize") +echo " summary (first 200 chars): $(echo "$SUM" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('summary','')[:200])" 2>/dev/null)" + +echo "" +echo "=== AI ticket reply suggestion API ===" +REP=$(curl -s -m 70 -X POST "$BASE/api/aos/tickets/$TKT_ID/reply" -H 'content-type: application/json' -d '{"tone":"polite"}') +echo " reply (first 250 chars): $(echo "$REP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('suggestion','')[:250])" 2>/dev/null)" + +echo "" +echo "=== Browser: Support view ===" +agent-browser open "$BASE/" >/dev/null 2>&1 +agent-browser wait --load networkidle >/dev/null 2>&1 || true +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser set viewport 1440 900 >/dev/null 2>&1 +agent-browser find text "Support" click >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " Support H1: $H1" +agent-browser screenshot --full "$SHOTS/v10-support.png" >/dev/null 2>&1 && echo " shot OK" + +echo "=== Click AI summarize button ===" +agent-browser find text "Zusammenfassen" click >/dev/null 2>&1 +agent-browser wait 10000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v10-support-summarized.png" >/dev/null 2>&1 && echo " summarized shot OK" +# check if AI summary appeared +SUM_UI=$(agent-browser eval "document.querySelector('[class*=bg-muted\\/20]')?.innerText?.slice(0,150) || 'no summary'" 2>/dev/null | tail -1 | tr -d '"') +echo " AI summary in UI: $(echo "$SUM_UI" | head -c 120)" + +echo "" +echo "=== Browser: Estimates view ===" +agent-browser find text "Angebote" click >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " Estimates H1: $H1" +agent-browser screenshot --full "$SHOTS/v10-estimates.png" >/dev/null 2>&1 && echo " shot OK" + +echo "=== Click first estimate → dialog ===" +agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +DIALOG=$(agent-browser eval "document.querySelector('[role=dialog]')?.textContent?.slice(0,120) || 'closed'" 2>/dev/null | tail -1 | tr -d '"') +echo " dialog: $(echo "$DIALOG" | head -c 120)" +agent-browser screenshot --full "$SHOTS/v10-estimate-detail.png" >/dev/null 2>&1 && echo " detail shot OK" +agent-browser press Escape >/dev/null 2>&1; agent-browser wait 500 >/dev/null 2>&1 + +echo "" +echo "=== Test accept + convert APIs ===" +# find a sent estimate +EST_ID=$(curl -s "$BASE/api/aos/estimates" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for e in d['estimates']: + if e['status']=='sent': + print(e['id']); break +" 2>/dev/null) +echo " estimate to accept: $EST_ID" +if [ -n "$EST_ID" ]; then + ACC=$(curl -s -X PATCH "$BASE/api/aos/estimates/$EST_ID/accept" -H 'content-type: application/json' -d '{"acceptedBy":"Test"}') + echo " accept: $(echo "$ACC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"estimate\"][\"status\"]}, projectId={d.get(\"projectId\",\"none\")}')" 2>/dev/null)" + echo " converting to invoice…" + CONV=$(curl -s -X POST "$BASE/api/aos/estimates/$EST_ID/convert") + echo " convert: $(echo "$CONV" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'invoice={d[\"invoice\"][\"number\"]} = {d[\"invoice\"][\"grossCents\"]/100:.2f}€')" 2>/dev/null)" +fi + +echo "" +echo "=== Errors ===" +agent-browser errors 2>&1 | head -8 +echo "--- console last 5 ---" +agent-browser console 2>&1 | tail -5 + +echo "" +agent-browser close >/dev/null 2>&1 +kill $SRV 2>/dev/null +echo "=== dev.log errors ===" +grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -8 +echo "=== DONE ===" +ls -la "$SHOTS"/v10-* 2>/dev/null | tail -6 diff --git a/db/custom.db b/db/custom.db index 5c05649..0bf3e04 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2158b8e..d95a8e7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -337,3 +337,79 @@ model Expense { updatedAt DateTime @updatedAt } +// --------------------------------------------------------------------------- +// Support Tickets (PRD §6.7 + PerfEx Support Tickets + AI Integration) +// --------------------------------------------------------------------------- + +model Ticket { + id String @id @default(cuid()) + number String @unique // gapless, e.g. TKT-2026-0001 + subject String + status String @default("open") // open|pending|resolved|closed + priority String @default("normal") // low|normal|high|urgent + categoryId String // bug|anfrage|aenderung|konto|rechnung|hosting + companyId String + projectId String? + licenseId String? + // SLA + slaResponseHours Int @default(8) // first response target + slaResolveHours Int @default(48) // resolution target + slaRespondedAt DateTime? + slaResolvedAt DateTime? + // AI + aiSummary String? // LLM-generated summary of the conversation + // assignee + assignee String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages TicketMessage[] +} + +model TicketMessage { + id String @id @default(cuid()) + ticketId String + ticket Ticket @relation(fields: [ticketId], references: [id]) + fromType String // customer|staff|system + fromName String + body String + createdAt DateTime @default(now()) +} + +// --------------------------------------------------------------------------- +// Estimates / Angebote (PRD §6.5 sales-docs — quotes that convert to invoice) +// --------------------------------------------------------------------------- + +model Estimate { + id String @id @default(cuid()) + number String @unique // gapless, e.g. AN-2026-0001 + companyId String + dealId String? // originating deal + projectId String? // creates project when accepted + subject String + status String @default("draft") // draft|sent|accepted|rejected|expired + validUntil DateTime? + issueDate DateTime @default(now()) + acceptedAt DateTime? + acceptedBy String? + notes String? + netCents Int @default(0) + taxBp Int @default(1900) + grossCents Int @default(0) + // AI + aiDraftFromBrief String? // flag: was this drafted from a brief via AI + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lines EstimateLine[] +} + +model EstimateLine { + id String @id @default(cuid()) + estimateId String + estimate Estimate @relation(fields: [estimateId], references: [id]) + description String + quantity Int @default(1) + unitCents Int + totalCents Int +} + + diff --git a/src/app/api/aos/estimates/[id]/accept/route.ts b/src/app/api/aos/estimates/[id]/accept/route.ts new file mode 100644 index 0000000..c657fae --- /dev/null +++ b/src/app/api/aos/estimates/[id]/accept/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// PATCH /api/aos/estimates/[id]/accept — customer accepts the quote. +// Domain event: creates a Project (from the quote) + updates Deal stage → won. +// (PRD §5.4 events, §6.5 "accepted quote seeds project budget") +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const body = (await req.json().catch(() => ({}))) as { acceptedBy?: string } + + const estimate = await db.estimate.findUnique({ where: { id }, include: { lines: true } }) + if (!estimate) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (estimate.status !== 'sent') return NextResponse.json({ error: 'only sent estimates can be accepted' }, { status: 409 }) + + // 1. create a project from the quote (if not already linked) + let projectId = estimate.projectId + if (!projectId) { + const project = await db.project.create({ + data: { + companyId: estimate.companyId, + name: estimate.subject, + type: 'website', + status: 'planning', + budgetType: 'fixed', + fixedPriceCents: estimate.netCents, + budgetHours: 0, + spentHours: 0, + startDate: new Date(), + progress: 0, + }, + }) + projectId = project.id + } + + // 2. mark estimate accepted + const updated = await db.estimate.update({ + where: { id }, + data: { + status: 'accepted', + acceptedAt: new Date(), + acceptedBy: body.acceptedBy ?? 'Kunde (via Portal)', + projectId, + }, + }) + + // 3. audit (domain event estimate.accepted → project created) + await db.auditLog.create({ + data: { + actor: 'sven@nordlicht.digital', + action: 'estimate.accept', + entity: 'Estimate', + entityId: id, + before: JSON.stringify({ status: 'sent' }), + after: JSON.stringify({ status: 'accepted', projectId, acceptedBy: updated.acceptedBy }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ estimate: updated, projectId }) +} diff --git a/src/app/api/aos/estimates/[id]/convert/route.ts b/src/app/api/aos/estimates/[id]/convert/route.ts new file mode 100644 index 0000000..19972d6 --- /dev/null +++ b/src/app/api/aos/estimates/[id]/convert/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// POST /api/aos/estimates/[id]/convert — convert an accepted estimate into an invoice. +// (PerfEx "Converting Estimate to Invoice") +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const estimate = await db.estimate.findUnique({ where: { id }, include: { lines: true } }) + if (!estimate) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (estimate.status !== 'accepted') return NextResponse.json({ error: 'only accepted estimates can be converted' }, { status: 409 }) + + // gapless invoice number + const year = new Date().getFullYear() + const count = await db.invoice.count({ where: { number: { startsWith: `RE-${year}-` } } }) + const number = `RE-${year}-${String(count + 1).padStart(4, '0')}` + + const dueDate = new Date() + dueDate.setDate(dueDate.getDate() + 14) + + const invoice = await db.invoice.create({ + data: { + number, + companyId: estimate.companyId, + status: 'draft', + type: 'final', + issueDate: new Date(), + dueDate, + netCents: estimate.netCents, + taxBp: estimate.taxBp, + grossCents: estimate.grossCents, + zugferd: false, + xrechnung: false, + datevExported: false, + projectId: estimate.projectId, + }, + }) + + // copy lines + for (const l of estimate.lines) { + await db.invoiceLine.create({ + data: { + invoiceId: invoice.id, + description: l.description, + quantity: l.quantity, + unitCents: l.unitCents, + totalCents: l.totalCents, + }, + }) + } + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'estimate.convert:invoice', + entity: 'Estimate', + entityId: id, + after: JSON.stringify({ invoiceId: invoice.id, invoiceNumber: number, grossCents: invoice.grossCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ invoice, estimateId: id }) +} diff --git a/src/app/api/aos/estimates/route.ts b/src/app/api/aos/estimates/route.ts new file mode 100644 index 0000000..8e7be50 --- /dev/null +++ b/src/app/api/aos/estimates/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/estimates — quotes list with company + lines +export async function GET() { + const estimates = await db.estimate.findMany({ + orderBy: { issueDate: 'desc' }, + include: { lines: true }, + }) + + const companyIds = [...new Set(estimates.map((e) => e.companyId))] + const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }) + const companyMap = new Map(companies.map((c) => [c.id, c.name])) + + const enriched = estimates.map((e) => ({ ...e, companyName: companyMap.get(e.companyId) ?? '—' })) + + const stats = { + total: estimates.length, + draft: estimates.filter((e) => e.status === 'draft').length, + sent: estimates.filter((e) => e.status === 'sent').length, + accepted: estimates.filter((e) => e.status === 'accepted').length, + rejected: estimates.filter((e) => e.status === 'rejected').length, + acceptedValueCents: estimates.filter((e) => e.status === 'accepted').reduce((s, e) => s + e.grossCents, 0), + pendingValueCents: estimates.filter((e) => e.status === 'sent').reduce((s, e) => s + e.grossCents, 0), + winRate: estimates.filter((e) => e.status === 'accepted' || e.status === 'rejected').length > 0 + ? Math.round((estimates.filter((e) => e.status === 'accepted').length / estimates.filter((e) => e.status === 'accepted' || e.status === 'rejected').length) * 100) + : 0, + } + + return NextResponse.json({ estimates: enriched, stats }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 50ebab7..7be9eeb 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -15,6 +15,10 @@ export async function POST() { try { // wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order) const { db } = await import('@/lib/db') + await db.estimateLine.deleteMany() + await db.estimate.deleteMany() + await db.ticketMessage.deleteMany() + await db.ticket.deleteMany() await db.expense.deleteMany() await db.contract.deleteMany() await db.invoiceLine.deleteMany() diff --git a/src/app/api/aos/tickets/[id]/reply/route.ts b/src/app/api/aos/tickets/[id]/reply/route.ts new file mode 100644 index 0000000..e33532c --- /dev/null +++ b/src/app/api/aos/tickets/[id]/reply/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import ZAI from 'z-ai-web-dev-sdk' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +// POST /api/aos/tickets/[id]/reply +// AI reply suggestion (PerfEx AI feature). +// Suggests a response based on the customer's last message + ticket history. +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const body = (await req.json().catch(() => ({}))) as { tone?: 'polite' | 'formal' | 'friendly' } + const tone = body.tone ?? 'polite' + + const ticket = await db.ticket.findUnique({ + where: { id }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!ticket) return NextResponse.json({ error: 'not found' }, { status: 404 }) + const company = ticket.companyId ? await db.company.findUnique({ where: { id: ticket.companyId }, select: { name: true } }) : null + + const conversation = ticket.messages + .map((m) => `[${m.fromType.toUpperCase()}] ${m.fromName}:\n${m.body}`) + .join('\n\n---\n\n') + + const toneMap: Record = { + polite: 'höflich und professionell', + formal: 'sehr formell (Sie-Form, geschäftlich distanziert)', + friendly: 'freundlich und nahbar, aber professionell', + } + + const systemPrompt = `Du bist ein Support-Mitarbeiter einer deutschen Software-Agentur (Nordlicht Digital GmbH). +Verfasse einen Antwort-Entwurf für das letzte Kunden-Ticket. Der Entwurf: +- Ist ${toneMap[tone]}. +- Bezieht sich auf den konkreten Sachverhalt aus dem Verlauf. +- Ist maximal 5 Sätze lang. +- Endet mit "Mit freundlichen Grüßen\nTom Becker — Nordlicht Digital". +- Schreibt KEINE automatische Handlung fest (z.B. keine "Wir haben bereits…" wenn nicht wahr). +Schreibe auf Deutsch.` + + const userPrompt = `Ticket: ${ticket.number} — ${ticket.subject} +Kunde: ${company?.name ?? '—'} +Priorität: ${ticket.priority} +Kategorie: ${ticket.categoryId} + +Verlauf: +${conversation}` + + try { + const zai = await ZAI.create() + const res = await zai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.5, + max_tokens: 350, + }) + + const suggestion = res.choices?.[0]?.message?.content ?? '' + + await db.auditLog.create({ + data: { + actor: 'system:llm', + action: 'ai.ticket:reply-suggest', + entity: 'Ticket', + entityId: id, + after: JSON.stringify({ model: 'glm', tone, length: suggestion.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ suggestion, ticketId: id }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/tickets/[id]/summarize/route.ts b/src/app/api/aos/tickets/[id]/summarize/route.ts new file mode 100644 index 0000000..3651056 --- /dev/null +++ b/src/app/api/aos/tickets/[id]/summarize/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import ZAI from 'z-ai-web-dev-sdk' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +// POST /api/aos/tickets/[id]/summarize +// AI ticket summarization (PerfEx AI feature). +// Generates a concise summary of the full conversation thread. +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const ticket = await db.ticket.findUnique({ + where: { id }, + include: { messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!ticket) return NextResponse.json({ error: 'not found' }, { status: 404 }) + + const conversation = ticket.messages + .map((m) => `[${m.fromType.toUpperCase()}] ${m.fromName} (${new Date(m.createdAt).toLocaleString('de-DE')}):\n${m.body}`) + .join('\n\n---\n\n') + + const systemPrompt = `Du bist ein Support-Assistent für eine deutsche Software-Agentur. Fasse ein Support-Ticket prägnant zusammen (max. 4 Sätze): +1. Was ist das Kernproblem / die Anfrage? +2. Was wurde bisher unternommen? +3. Was ist der aktuelle Status / nächste Schritt? +Schreibe auf Deutsch, sachlich, keine Einleitungssätze.` + + const userPrompt = `Ticket: ${ticket.number} — ${ticket.subject} +Kunde: ${ticket.companyId} +Priorität: ${ticket.priority} + +Verlauf: +${conversation}` + + try { + const zai = await ZAI.create() + const res = await zai.chat.completions.create({ + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.3, + max_tokens: 300, + }) + + const summary = res.choices?.[0]?.message?.content ?? '' + + // persist + audit + await db.ticket.update({ where: { id }, data: { aiSummary: summary } }) + await db.auditLog.create({ + data: { + actor: 'system:llm', + action: 'ai.ticket:summarize', + entity: 'Ticket', + entityId: id, + after: JSON.stringify({ model: 'glm', length: summary.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ summary, ticketId: id }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/tickets/route.ts b/src/app/api/aos/tickets/route.ts new file mode 100644 index 0000000..b088481 --- /dev/null +++ b/src/app/api/aos/tickets/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/tickets — ticket inbox with SLA status +export async function GET() { + const tickets = await db.ticket.findMany({ + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + include: { messages: { orderBy: { createdAt: 'asc' } } }, + }) + + const companyIds = [...new Set(tickets.map((t) => t.companyId))] + const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }) + const companyMap = new Map(companies.map((c) => [c.id, c.name])) + + const enriched = tickets.map((t) => { + const lastMsg = t.messages[t.messages.length - 1] + const ageHours = (Date.now() - t.createdAt.getTime()) / 3600000 + const slaBreached = !t.slaRespondedAt && ageHours > t.slaResponseHours + const resolveBreached = !t.slaResolvedAt && ageHours > t.slaResolveHours + return { + ...t, + companyName: companyMap.get(t.companyId) ?? '—', + lastMessage: lastMsg ? { fromName: lastMsg.fromName, fromType: lastMsg.fromType, body: lastMsg.body.slice(0, 120), createdAt: lastMsg.createdAt } : null, + messageCount: t.messages.length, + sla: { + responseHours: t.slaResponseHours, + resolveHours: t.slaResolveHours, + respondedAt: t.slaRespondedAt, + resolvedAt: t.slaResolvedAt, + responseBreached: slaBreached, + resolveBreached: resolveBreached, + ageHours: Math.round(ageHours * 10) / 10, + }, + } + }) + + const stats = { + total: tickets.length, + open: tickets.filter((t) => t.status === 'open').length, + pending: tickets.filter((t) => t.status === 'pending').length, + resolved: tickets.filter((t) => t.status === 'resolved').length, + closed: tickets.filter((t) => t.status === 'closed').length, + urgent: tickets.filter((t) => t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved').length, + slaBreached: enriched.filter((t) => t.sla.responseBreached || t.sla.resolveBreached).length, + unresponded: tickets.filter((t) => !t.slaRespondedAt && t.status === 'open').length, + } + + return NextResponse.json({ tickets: enriched, stats }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 3ab15bd..3e50c33 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -10,7 +10,9 @@ import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' import { DashboardView } from './views/dashboard' import { CrmView } from './views/crm' import { LeadsView } from './views/leads' +import { EstimatesView } from './views/estimates' import { ProjectsView } from './views/projects' +import { SupportView } from './views/support' import { LicensesView } from './views/licenses' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' @@ -45,7 +47,9 @@ export function AppShell() { {view === 'dashboard' && } {view === 'crm' && } {view === 'leads' && } + {view === 'estimates' && } {view === 'projects' && } + {view === 'support' && } {view === 'licenses' && } {view === 'contracts' && } {view === 'expenses' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 5295223..ff18d43 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -16,6 +16,8 @@ import { ArrowRight, FileSignature, Wallet, + FileText, + LifeBuoy, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -33,7 +35,9 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' }, { id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' }, { id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' }, + { id: 'estimates', label: 'Angebote', icon: FileText, hint: 'Quotes & Conversion' }, { id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' }, + { id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 23866df..8747356 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -14,6 +14,8 @@ import { ScrollText, FileSignature, Wallet, + FileText, + LifeBuoy, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { cn } from '@/lib/utils' @@ -33,11 +35,15 @@ const NAV: { items: [ { id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' }, { id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' }, + { id: 'estimates', label: 'Angebote', icon: FileText, permission: 'estimates:read' }, ], }, { group: 'Lieferung', - items: [{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }], + items: [ + { id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }, + { id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' }, + ], }, { group: 'Betrieb', diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index cc3a77b..4417835 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -19,7 +19,9 @@ const TITLES: Record = { dashboard: { title: 'Dashboard', crumb: 'Übersicht' }, crm: { title: 'CRM', crumb: 'Vertrieb / CRM' }, leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' }, + estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' }, projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' }, + support: { title: 'Support', crumb: 'Lieferung / Tickets' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, diff --git a/src/components/aos/views/estimates.tsx b/src/components/aos/views/estimates.tsx new file mode 100644 index 0000000..0b4837d --- /dev/null +++ b/src/components/aos/views/estimates.tsx @@ -0,0 +1,368 @@ +'use client' + +import { useState } from 'react' +import { + FileText, + Plus, + Check, + X, + ArrowRight, + Loader2, + TrendingUp, + Percent, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Separator } from '@/components/ui/separator' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { PageHeader } from '../primitives' +import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +interface EstimateLine { id: string; description: string; quantity: number; unitCents: number; totalCents: number } +interface Estimate { + id: string + number: string + companyId: string + companyName: string + subject: string + status: string + validUntil: string | null + issueDate: string + acceptedAt: string | null + acceptedBy: string | null + notes: string | null + netCents: number + taxBp: number + grossCents: number + lines: EstimateLine[] +} +interface EstimatesData { + estimates: Estimate[] + stats: { + total: number + draft: number + sent: number + accepted: number + rejected: number + acceptedValueCents: number + pendingValueCents: number + winRate: number + } +} + +const STATUS_META: Record = { + draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' }, + sent: { label: 'Versendet', cls: 'bg-foreground/50 text-background' }, + accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' }, + rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' }, + expired: { label: 'Abgelaufen', cls: 'bg-muted text-muted-foreground' }, +} + +export function EstimatesView() { + const { data, loading, refetch } = useFetch('/api/aos/estimates') + const [selected, setSelected] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + const [action, setAction] = useState<'accept' | 'convert' | null>(null) + const [busy, setBusy] = useState(false) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { estimates, stats } = data + + const openDetail = (e: Estimate) => { + setSelected(e) + setDialogOpen(true) + } + + const accept = async (estimate: Estimate) => { + setBusy(true) + const t = toast.loading('Angebot wird akzeptiert (Projekt wird angelegt)…') + try { + const r = await fetch(`/api/aos/estimates/${estimate.id}/accept`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ acceptedBy: estimate.companyName + ' (Portal)' }), + }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || 'accept failed') + } + const data = await r.json() + toast.success(`Akzeptiert — Projekt angelegt`, { id: t }) + setDialogOpen(false) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Akzeptanz fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + const convert = async (estimate: Estimate) => { + setBusy(true) + const t = toast.loading('Rechnung wird aus Angebot erstellt…') + try { + const r = await fetch(`/api/aos/estimates/${estimate.id}/convert`, { method: 'POST' }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || 'convert failed') + } + const data = await r.json() + toast.success(`Rechnung ${data.invoice.number} erstellt (${formatEuro(data.invoice.grossCents)})`, { id: t }) + setDialogOpen(false) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Umwandlung fehlgeschlagen', { id: t }) + } finally { + setBusy(false) + } + } + + return ( +
+ + + Angebot + + } + /> + +
+ +
+

Offen (versendet)

+ +
+

{stats.sent}

+

{formatEuroCompact(stats.pendingValueCents)} Volumen

+
+ +
+

Akzeptiert

+ +
+

{stats.accepted}

+

{formatEuroCompact(stats.acceptedValueCents)} Volumen

+
+ +
+

Win-Rate

+ +
+

{stats.winRate}%

+
+ +
+

Gesamt

+ +
+

{stats.total}

+
+
+ + + + Angebote ({estimates.length}) + + +
+ + + + + + + + + + + + + + + {estimates.map((e) => { + const st = STATUS_META[e.status] ?? STATUS_META.draft + const valid = e.validUntil ? daysUntil(e.validUntil) : null + const expiring = valid !== null && valid < 0 && e.status === 'sent' + return ( + openDetail(e)}> + + + + + + + + + + ) + })} + +
Nr.BetreffKundeDatumGültig bisBruttoStatusAktion
{e.number}{e.subject}{e.companyName}{formatDate(e.issueDate)} + {e.validUntil ? ( + + {formatDate(e.validUntil)} + {valid !== null && e.status === 'sent' && ( + + {valid < 0 ? `(${Math.abs(valid)} d abgelaufen)` : `(${valid} d)`} + + )} + + ) : '—'} + {formatEuro(e.grossCents)} + {st.label} + e2.stopPropagation()}> + {e.status === 'sent' ? ( + + ) : e.status === 'accepted' ? ( + + ) : ( + — + )} +
+
+
+
+ + {/* Detail dialog */} + + + {selected && (() => { + const st = STATUS_META[selected.status] ?? STATUS_META.draft + return ( + <> + +
+
+ {selected.number} + {selected.subject} +
+ {st.label} +
+
+ +
+ {/* Meta */} +
+ + + + {selected.acceptedAt && } + {selected.acceptedBy && } +
+ + {/* Lines */} +
+
+ Positionen ({selected.lines.length}) +
+
+ {selected.lines.map((l) => ( +
+
+

{l.description}

+

{l.quantity} × {formatEuro(l.unitCents)}

+
+

{formatEuro(l.totalCents)}

+
+ ))} +
+ +
+
+ Netto + {formatEuro(selected.netCents)} +
+
+ USt ({(selected.taxBp / 100).toFixed(0)} %) + {formatEuro(selected.grossCents - selected.netCents)} +
+ +
+ Brutto + {formatEuro(selected.grossCents)} +
+
+
+ + {selected.notes && ( +
+

Notizen

+

{selected.notes}

+
+ )} + + {/* Chain indicator */} +
+

Lead → Deal → Angebot → Projekt → Rechnung

+

+ {selected.status === 'accepted' && selected.projectId + ? '✓ Akzeptiert — Projekt wurde aus diesem Angebot angelegt.' + : selected.status === 'sent' + ? 'Wartet auf Kunden-Akzeptanz via Portal.' + : selected.status === 'rejected' + ? 'Abgelehnt — ggf. neues, reduziertes Angebot erstellen.' + : 'Entwurf — noch nicht versendet.'} +

+
+
+ + + + {selected.status === 'sent' && ( + + )} + {selected.status === 'accepted' && ( + + )} + + + ) + })()} +
+
+
+ ) +} + +function Meta({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} diff --git a/src/components/aos/views/settings.tsx b/src/components/aos/views/settings.tsx index ef84643..abe4a75 100644 --- a/src/components/aos/views/settings.tsx +++ b/src/components/aos/views/settings.tsx @@ -40,9 +40,10 @@ const MODULE_META: Record = { + open: { label: 'Offen', cls: 'bg-foreground text-background' }, + pending: { label: 'Wartend', cls: 'bg-foreground/50 text-background' }, + resolved: { label: 'Gelöst', cls: 'bg-muted text-foreground' }, + closed: { label: 'Geschlossen', cls: 'bg-muted text-muted-foreground' }, +} + +const PRIORITY_META: Record = { + low: { label: 'Niedrig', cls: 'border-border' }, + normal: { label: 'Normal', cls: 'border-foreground/30' }, + high: { label: 'Hoch', cls: 'border-foreground/50' }, + urgent: { label: 'Dringend', cls: 'border-foreground bg-foreground/5' }, +} + +const CATEGORY_LABELS: Record = { + bug: 'Bug', + anfrage: 'Anfrage', + aenderung: 'Änderung', + konto: 'Konto', + rechnung: 'Rechnung', + hosting: 'Hosting', +} + +export function SupportView() { + const { data, loading, refetch } = useFetch('/api/aos/tickets') + const [selectedId, setSelectedId] = useState(null) + const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null) + const [replyDraft, setReplyDraft] = useState('') + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { tickets, stats } = data + const selected = tickets.find((t) => t.id === selectedId) ?? tickets[0] ?? null + + const summarize = async (ticket: Ticket) => { + setAiBusy('summary') + const t = toast.loading('AI fasst das Ticket zusammen…') + try { + const r = await fetch(`/api/aos/tickets/${ticket.id}/summarize`, { method: 'POST' }) + if (!r.ok) throw new Error('summarize failed') + toast.success('Zusammenfassung generiert', { id: t }) + refetch() + } catch { + toast.error('Zusammenfassung fehlgeschlagen', { id: t }) + } finally { + setAiBusy(null) + } + } + + const suggestReply = async (ticket: Ticket, tone: 'polite' | 'formal' | 'friendly' = 'polite') => { + setAiBusy('reply') + const t = toast.loading('AI schlägt eine Antwort vor…') + try { + const r = await fetch(`/api/aos/tickets/${ticket.id}/reply`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tone }), + }) + if (!r.ok) throw new Error('reply failed') + const data = await r.json() + setReplyDraft(data.suggestion) + toast.success('Antwort-Entwurf generiert ( bitte vor Versand prüfen)', { id: t }) + } catch { + toast.error('Antwort-Vorschlag fehlgeschlagen', { id: t }) + } finally { + setAiBusy(null) + } + } + + return ( +
+ + + Ticket + + } + /> + +
+ +
+

Offen / Wartend

+ +
+

{stats.open + stats.pending}

+
+ +
+

Dringend

+ +
+

{stats.urgent}

+
+ +
+

SLA-Verletzung

+ +
+

{stats.slaBreached}

+
+ +
+

Unbeantwortet

+ +
+

{stats.unresponded}

+
+
+ +
+ {/* Ticket list */} + + + Inbox ({tickets.length}) + + +
+ {tickets.map((t) => { + const st = STATUS_META[t.status] ?? STATUS_META.open + const pr = PRIORITY_META[t.priority] ?? PRIORITY_META.normal + return ( + + ) + })} +
+
+
+ + {/* Conversation detail */} +
+ {selected ? ( + + {/* Header */} + +
+
+
+ {selected.number} + {CATEGORY_LABELS[selected.categoryId] ?? selected.categoryId} +
+

{selected.subject}

+

{selected.companyName}{selected.assignee && ` · ${selected.assignee}`}

+
+ + {(STATUS_META[selected.status] ?? STATUS_META.open).label} + +
+ + {/* SLA bar */} +
+ + + SLA Antwort: {selected.sla.responseHours} h + {selected.sla.respondedAt ? ( + + ) : selected.sla.responseBreached ? ( + verletzt ({selected.sla.ageHours} h) + ) : ( + {selected.sla.ageHours}/{selected.sla.responseHours} h + )} + + + SLA Lösung: {selected.sla.resolveHours} h + {selected.sla.resolvedAt ? ( + + ) : selected.sla.resolveBreached ? ( + verletzt + ) : ( + {selected.sla.ageHours}/{selected.sla.resolveHours} h + )} + +
+
+ + {/* AI summary */} + {selected.aiSummary && ( +
+
+ + AI-Zusammenfassung +
+

{selected.aiSummary}

+
+ )} + + {/* Conversation thread */} + + + {selected.messages.map((m) => { + const isStaff = m.fromType === 'staff' + const isSystem = m.fromType === 'system' + if (isSystem) { + return ( +
+ + {m.body} + +
+ ) + } + return ( +
+
+
+ {m.fromName} + {isStaff ? 'Agentur' : 'Kunde'} +
+

{m.body}

+

{formatDateTime(m.createdAt)}

+
+
+ ) + })} +
+
+ + {/* Reply + AI actions */} +
+
+ + +
+