5b01aa3d-f0b2-4a70-9e57-f4fd90682497
This commit is contained in:
1 parent
d4c3b0f5ca
commit
34bd721ab1
23 files changed
+1461
-3
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user