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
Binary file not shown.
|
After Width: | Height: | Size: 134 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
+101
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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' && <DashboardView />}
|
||||
{view === 'crm' && <CrmView />}
|
||||
{view === 'leads' && <LeadsView />}
|
||||
{view === 'estimates' && <EstimatesView />}
|
||||
{view === 'projects' && <ProjectsView />}
|
||||
{view === 'support' && <SupportView />}
|
||||
{view === 'licenses' && <LicensesView />}
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -19,7 +19,9 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
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' },
|
||||
|
||||
@@ -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<string, { label: string; cls: string }> = {
|
||||
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<EstimatesData>('/api/aos/estimates')
|
||||
const [selected, setSelected] = useState<Estimate | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [action, setAction] = useState<'accept' | 'convert' | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Angebote"
|
||||
description="Quotes mit Positionen, Gültigkeit, Kunden-Akzeptanz via Portal. Akzeptiertes Angebot → Projekt + Rechnung (lead → deal → quote → project → invoice)."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Plus className="size-4" />
|
||||
Angebot
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Offen (versendet)</p>
|
||||
<FileText className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.sent}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(stats.pendingValueCents)} Volumen</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Akzeptiert</p>
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.accepted}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(stats.acceptedValueCents)} Volumen</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Win-Rate</p>
|
||||
<Percent className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.winRate}<span className="text-sm text-muted-foreground">%</span></p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Gesamt</p>
|
||||
<TrendingUp className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Angebote ({estimates.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Nr.</th>
|
||||
<th className="px-4 py-2 font-medium">Betreff</th>
|
||||
<th className="px-4 py-2 font-medium">Kunde</th>
|
||||
<th className="px-4 py-2 font-medium">Datum</th>
|
||||
<th className="px-4 py-2 font-medium">Gültig bis</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Brutto</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{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 (
|
||||
<tr key={e.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => openDetail(e)}>
|
||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
|
||||
<td className="px-4 py-2.5 text-xs font-medium">{e.subject}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.companyName}</td>
|
||||
<td className="px-4 py-2.5 text-xs">{formatDate(e.issueDate)}</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{e.validUntil ? (
|
||||
<span className={cn(expiring && 'text-foreground')}>
|
||||
{formatDate(e.validUntil)}
|
||||
{valid !== null && e.status === 'sent' && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">
|
||||
{valid < 0 ? `(${Math.abs(valid)} d abgelaufen)` : `(${valid} d)`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.grossCents)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5" onClick={(e2) => e2.stopPropagation()}>
|
||||
{e.status === 'sent' ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-[11px]" onClick={() => accept(e)}>
|
||||
<Check className="size-3" />Akzeptieren
|
||||
</Button>
|
||||
) : e.status === 'accepted' ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-[11px]" onClick={() => convert(e)}>
|
||||
<ArrowRight className="size-3" />Zur Rechnung
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
{selected && (() => {
|
||||
const st = STATUS_META[selected.status] ?? STATUS_META.draft
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<DialogTitle className="font-mono">{selected.number}</DialogTitle>
|
||||
<DialogDescription className="mt-1">{selected.subject}</DialogDescription>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-3">
|
||||
<Meta label="Kunde" value={selected.companyName} />
|
||||
<Meta label="Ausgestellt" value={formatDate(selected.issueDate)} />
|
||||
<Meta label="Gültig bis" value={selected.validUntil ? formatDate(selected.validUntil) : '—'} />
|
||||
{selected.acceptedAt && <Meta label="Akzeptiert am" value={formatDate(selected.acceptedAt)} />}
|
||||
{selected.acceptedBy && <Meta label="Akzeptiert von" value={selected.acceptedBy} />}
|
||||
</div>
|
||||
|
||||
{/* Lines */}
|
||||
<div className="rounded-md border border-border">
|
||||
<div className="border-b border-border bg-muted/40 px-3 py-2 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Positionen ({selected.lines.length})
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{selected.lines.map((l) => (
|
||||
<div key={l.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium">{l.description}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{l.quantity} × {formatEuro(l.unitCents)}</p>
|
||||
</div>
|
||||
<p className="shrink-0 text-xs font-semibold tabular-nums">{formatEuro(l.totalCents)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1 px-3 py-2.5 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span className="tabular-nums">{formatEuro(selected.netCents)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">USt ({(selected.taxBp / 100).toFixed(0)} %)</span>
|
||||
<span className="tabular-nums">{formatEuro(selected.grossCents - selected.netCents)}</span>
|
||||
</div>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex items-center justify-between font-semibold">
|
||||
<span>Brutto</span>
|
||||
<span className="tabular-nums">{formatEuro(selected.grossCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.notes && (
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Notizen</p>
|
||||
<p className="mt-1 text-xs">{selected.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chain indicator */}
|
||||
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Lead → Deal → Angebot → Projekt → Rechnung</p>
|
||||
<p className="mt-0.5">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Schließen</Button>
|
||||
{selected.status === 'sent' && (
|
||||
<Button onClick={() => accept(selected)} disabled={busy} className="gap-1.5">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Akzeptieren (→ Projekt)
|
||||
</Button>
|
||||
)}
|
||||
{selected.status === 'accepted' && (
|
||||
<Button onClick={() => convert(selected)} disabled={busy} className="gap-1.5">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRight className="size-3.5" />}
|
||||
Zur Rechnung
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-0.5 text-xs font-medium">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,9 +40,10 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
|
||||
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
|
||||
contracts: { label: 'Verträge', desc: 'Werkvertrag, AVV (Art. 28 DSGVO), NDA, SLA mit E-Signatur.', phase: 'Phase 5' },
|
||||
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
|
||||
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
|
||||
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
|
||||
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
|
||||
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
|
||||
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
|
||||
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LifeBuoy,
|
||||
Plus,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
MessageSquare,
|
||||
} 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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatRelative, formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface TicketMsg { id: string; fromType: string; fromName: string; body: string; createdAt: string }
|
||||
interface Ticket {
|
||||
id: string
|
||||
number: string
|
||||
subject: string
|
||||
status: string
|
||||
priority: string
|
||||
categoryId: string
|
||||
companyName: string
|
||||
assignee: string | null
|
||||
aiSummary: string | null
|
||||
messages: TicketMsg[]
|
||||
sla: {
|
||||
responseHours: number
|
||||
resolveHours: number
|
||||
respondedAt: string | null
|
||||
resolvedAt: string | null
|
||||
responseBreached: boolean
|
||||
resolveBreached: boolean
|
||||
ageHours: number
|
||||
}
|
||||
}
|
||||
interface TicketsData {
|
||||
tickets: Ticket[]
|
||||
stats: {
|
||||
total: number
|
||||
open: number
|
||||
pending: number
|
||||
resolved: number
|
||||
closed: number
|
||||
urgent: number
|
||||
slaBreached: number
|
||||
unresponded: number
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
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<string, { label: string; cls: string }> = {
|
||||
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<string, string> = {
|
||||
bug: 'Bug',
|
||||
anfrage: 'Anfrage',
|
||||
aenderung: 'Änderung',
|
||||
konto: 'Konto',
|
||||
rechnung: 'Rechnung',
|
||||
hosting: 'Hosting',
|
||||
}
|
||||
|
||||
export function SupportView() {
|
||||
const { data, loading, refetch } = useFetch<TicketsData>('/api/aos/tickets')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null)
|
||||
const [replyDraft, setReplyDraft] = useState('')
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Support"
|
||||
description="Ticket-Inbox mit SLA-Timern. AI-Schicht: Ticket-Zusammenfassung und Antwort-Vorschläge (optional, Daten bleiben im Server)."
|
||||
actions={
|
||||
<Button size="sm">
|
||||
<Plus className="size-4" />
|
||||
Ticket
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Offen / Wartend</p>
|
||||
<LifeBuoy className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.open + stats.pending}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Dringend</p>
|
||||
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.urgent}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">SLA-Verletzung</p>
|
||||
<Clock className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.slaBreached}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Unbeantwortet</p>
|
||||
<MessageSquare className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.unresponded}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
||||
{/* Ticket list */}
|
||||
<Card className="lg:col-span-2 overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Inbox ({tickets.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<ScrollArea className="h-[72vh]">
|
||||
<div className="space-y-1.5 p-2">
|
||||
{tickets.map((t) => {
|
||||
const st = STATUS_META[t.status] ?? STATUS_META.open
|
||||
const pr = PRIORITY_META[t.priority] ?? PRIORITY_META.normal
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => { setSelectedId(t.id); setReplyDraft('') }}
|
||||
className={cn(
|
||||
'w-full rounded-md border p-3 text-left transition hover:border-foreground/40',
|
||||
pr.cls,
|
||||
selected?.id === t.id && 'ring-1 ring-foreground/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<code className="text-[10px] font-mono text-muted-foreground">{t.number}</code>
|
||||
{t.priority === 'urgent' && t.status !== 'closed' && t.status !== 'resolved' && (
|
||||
<Badge variant="outline" className="text-[9px]">DRINGEND</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-sm font-medium">{t.subject}</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{t.companyName}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('shrink-0 text-[10px]', st.cls)}>{st.label}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[9px]">{CATEGORY_LABELS[t.categoryId] ?? t.categoryId}</Badge>
|
||||
<span>{t.messages.length} Msgs</span>
|
||||
<span>· {formatRelative(t.messages[t.messages.length - 1]?.createdAt ?? t.createdAt)}</span>
|
||||
{(t.sla.responseBreached || t.sla.resolveBreached) && (
|
||||
<span className="flex items-center gap-0.5 text-foreground">
|
||||
<AlertTriangle className="size-2.5" />SLA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
{/* Conversation detail */}
|
||||
<div className="lg:col-span-3">
|
||||
{selected ? (
|
||||
<Card className="flex h-[72vh] flex-col">
|
||||
{/* Header */}
|
||||
<CardHeader className="border-b border-border pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs">{selected.number}</code>
|
||||
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[selected.categoryId] ?? selected.categoryId}</Badge>
|
||||
</div>
|
||||
<h2 className="mt-1 truncate text-base font-semibold">{selected.subject}</h2>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">{selected.companyName}{selected.assignee && ` · ${selected.assignee}`}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('shrink-0 text-[10px]', (STATUS_META[selected.status] ?? STATUS_META.open).cls)}>
|
||||
{(STATUS_META[selected.status] ?? STATUS_META.open).label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* SLA bar */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/30 p-2 text-[11px]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3 text-muted-foreground" />
|
||||
SLA Antwort: {selected.sla.responseHours} h
|
||||
{selected.sla.respondedAt ? (
|
||||
<CheckCircle2 className="size-3 text-foreground" />
|
||||
) : selected.sla.responseBreached ? (
|
||||
<span className="font-medium text-foreground">verletzt ({selected.sla.ageHours} h)</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{selected.sla.ageHours}/{selected.sla.responseHours} h</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
SLA Lösung: {selected.sla.resolveHours} h
|
||||
{selected.sla.resolvedAt ? (
|
||||
<CheckCircle2 className="size-3 text-foreground" />
|
||||
) : selected.sla.resolveBreached ? (
|
||||
<span className="font-medium text-foreground">verletzt</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{selected.sla.ageHours}/{selected.sla.resolveHours} h</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* AI summary */}
|
||||
{selected.aiSummary && (
|
||||
<div className="border-b border-border bg-muted/20 px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="size-3 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">AI-Zusammenfassung</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-relaxed">{selected.aiSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation thread */}
|
||||
<ScrollArea className="flex-1">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{selected.messages.map((m) => {
|
||||
const isStaff = m.fromType === 'staff'
|
||||
const isSystem = m.fromType === 'system'
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={m.id} className="flex justify-center">
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-[10px] text-muted-foreground">
|
||||
{m.body}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={m.id} className={cn('flex', isStaff ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn('max-w-[80%] rounded-md border p-2.5', isStaff ? 'border-foreground/30 bg-foreground/5' : 'border-border')}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium">{m.fromName}</span>
|
||||
<Badge variant="outline" className="text-[9px]">{isStaff ? 'Agentur' : 'Kunde'}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed">{m.body}</p>
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">{formatDateTime(m.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Reply + AI actions */}
|
||||
<div className="border-t border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => summarize(selected)} disabled={aiBusy !== null}>
|
||||
{aiBusy === 'summary' ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
|
||||
Zusammenfassen
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => suggestReply(selected)} disabled={aiBusy !== null}>
|
||||
{aiBusy === 'reply' ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
|
||||
Antwort vorschlagen
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={replyDraft}
|
||||
onChange={(e) => setReplyDraft(e.target.value)}
|
||||
placeholder="Antwort schreiben oder AI-Vorschlag übernehmen…"
|
||||
className="min-h-[80px] text-xs"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{replyDraft ? `${replyDraft.length} Zeichen` : 'AI-Vorschläge sind Entwürfe — vor Versand prüfen.'}
|
||||
</p>
|
||||
<Button size="sm" className="gap-1.5" disabled={!replyDraft.trim()}>
|
||||
<Send className="size-3.5" />
|
||||
Senden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Wähle ein Ticket.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+162
-1
@@ -34,9 +34,10 @@ async function main() {
|
||||
{ id: 'licenses', enabled: true, version: '1.0.0' },
|
||||
{ id: 'contracts', enabled: true, version: '1.0.0' },
|
||||
{ id: 'expenses', enabled: true, version: '1.0.0' },
|
||||
{ id: 'estimates', enabled: true, version: '1.0.0' },
|
||||
{ id: 'support', enabled: true, version: '1.0.0' },
|
||||
{ id: 'sales-docs', enabled: false, version: '0.9.0' },
|
||||
{ id: 'finance', enabled: true, version: '1.0.0' },
|
||||
{ id: 'support', enabled: false, version: '0.5.0' },
|
||||
{ id: 'reporting', enabled: false, version: '0.5.0' },
|
||||
]
|
||||
for (const m of modules) {
|
||||
@@ -463,6 +464,166 @@ async function main() {
|
||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'expense.record', entity: 'Expense', entityId: 'seed', after: '{"category":"reise","billable":true}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'expense.convert:invoice', entity: 'Expense', entityId: 'seed', after: '{"status":"invoiced"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
|
||||
// --- Support Tickets (with conversation threads) -------------------------
|
||||
const tickets = [
|
||||
{
|
||||
number: 'TKT-2026-0014', subject: 'WooCommerce Checkout lädt nicht nach Update', status: 'open', priority: 'urgent', categoryId: 'bug',
|
||||
company: 'HanseMöbel Manufaktur GmbH', projectId: null, assignee: 'Tom Becker', slaResponseHours: 4, slaResolveHours: 24,
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Frank Hartmann', body: 'Hallo, nach dem WordPress-Update heute Morgen lädt der Checkout nicht mehr. Kunden können nicht bezahlen. Bitte dringend prüfen! Fehlermeldung im Console: "jQuery is not defined".', createdAt: daysFromNow(-0.1) },
|
||||
{ fromType: 'staff', fromName: 'Tom Becker', body: 'Hallo Herr Hartmann, danke für die Meldung. Ich prüfe das sofort und melde mich innerhalb von 4 Stunden mit einer Lösung. Vermutlich ein Plugin-Konflikt durch das WP-Update.', createdAt: daysFromNow(-0.08) },
|
||||
{ fromType: 'customer', fromName: 'Frank Hartmann', body: 'OK, danke. Wir haben aktuell Bestellungen die hängen bleiben.', createdAt: daysFromNow(-0.05) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'TKT-2026-0013', subject: 'Neue Landingpage für Herbstkampagne', status: 'pending', priority: 'normal', categoryId: 'aenderung',
|
||||
company: 'Ostsee Hotel & Spa AG', projectId: projectMap['Website-Redesign Konzept'], assignee: 'Aylin Demir', slaResponseHours: 8, slaResolveHours: 72,
|
||||
slaRespondedAt: daysFromNow(-2),
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Henrik Olsen', body: 'Wir benötigen eine Landingpage für unsere Herbst-Aktionswochen (Oktober/November). Inhalte: Wellness-Pakete, Bilder, Buchungs-CTA. Können Sie das bis nächste Woche umsetzen?', createdAt: daysFromNow(-3) },
|
||||
{ fromType: 'staff', fromName: 'Aylin Demir', body: 'Hallo Herr Olsen, sehr gerne. Wir können das im Rahmen des Redesign-Projekts umsetzen. Ich erstelle ein kurzes Angebot mit 2 Entwürfen. Ist ein Budgetrahmen von 2.500 € i.O.?', createdAt: daysFromNow(-2.5) },
|
||||
{ fromType: 'customer', fromName: 'Henrik Olsen', body: 'Ja, das passt. Bitte auch mobile Optimierung beachten.', createdAt: daysFromNow(-2) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'TKT-2026-0012', subject: 'Rechnung RE-2026-0039 unklar', status: 'open', priority: 'normal', categoryId: 'rechnung',
|
||||
company: 'Nordwind Logistik GmbH', assignee: 'Nora Klein', slaResponseHours: 8, slaResolveHours: 48,
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Petra Hahn', body: 'Hallo, auf der Rechnung RE-2026-0039 sehe ich einen Posten "Abschlagszahlung Projekt" mit 5.400 €. Können Sie mir sagen, auf welche Leistungsphase sich das bezieht?', createdAt: daysFromNow(-0.3) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'TKT-2026-0011', subject: 'Backup-Job fehlgeschlagen (Intranet)', status: 'resolved', priority: 'high', categoryId: 'hosting',
|
||||
company: 'Nordwind Logistik GmbH', projectId: projectMap['Intranet-Portal Q4'], assignee: 'Tom Becker', slaResponseHours: 4, slaResolveHours: 24,
|
||||
slaRespondedAt: daysFromNow(-5), slaResolvedAt: daysFromNow(-4),
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Petra Hahn', body: 'Der nächtliche Backup-Job ist am 20.10. fehlgeschlagen. Log: "Permission denied /var/backups".', createdAt: daysFromNow(-5.5) },
|
||||
{ fromType: 'staff', fromName: 'Tom Becker', body: 'Danke fürs Melden. Ursache: Berechtigungen nach Server-Update zurückgesetzt. Habe die Rechte korrigiert und einen Test-Backup angestoßen — läuft erfolgreich.', createdAt: daysFromNow(-5) },
|
||||
{ fromType: 'customer', fromName: 'Petra Hahn', body: 'Perfekt, danke für die schnelle Lösung!', createdAt: daysFromNow(-4.5) },
|
||||
{ fromType: 'system', fromName: 'AOS System', body: 'Ticket automatisch auf "resolved" gesetzt — keine Antwort des Kunden seit 12 Stunden.', createdAt: daysFromNow(-4) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'TKT-2026-0010', subject: 'Zugangsdaten für neue Redakteurin', status: 'resolved', priority: 'low', categoryId: 'konto',
|
||||
company: 'Bauer Handwerk GmbH', assignee: 'Aylin Demir', slaResponseHours: 8, slaResolveHours: 48,
|
||||
slaRespondedAt: daysFromNow(-8), slaResolvedAt: daysFromNow(-7),
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Sabine Bauer', body: 'Unsere neue Praktikantin Lisa benötigt CMS-Zugang für den Blog. Welche Daten braucht ihr dafür?', createdAt: daysFromNow(-8.5) },
|
||||
{ fromType: 'staff', fromName: 'Aylin Demir', body: 'Hallo Frau Bauer, gerne. Ich brauche: Vor- und Nachname, E-Mail-Adresse. Dann richte ich einen Redakteur-Account ohne Admin-Rechte ein (DSGVO-konform, minimal privilegiert).', createdAt: daysFromNow(-8) },
|
||||
{ fromType: 'customer', fromName: 'Sabine Bauer', body: 'Lisa Müller, lisa@bauer-handwerk.de', createdAt: daysFromNow(-7.5) },
|
||||
{ fromType: 'staff', fromName: 'Aylin Demir', body: 'Erledigt. Zugangsdaten wurden per sicherem Link verschickt (24 h gültig). Bitte beim ersten Login Passwort setzen.', createdAt: daysFromNow(-7) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'TKT-2026-0009', subject: 'DSGVO: Auskunft nach Art. 15', status: 'closed', priority: 'normal', categoryId: 'anfrage',
|
||||
company: 'TechStart UG (haftungsbeschränkt)', assignee: 'Lena Vogt', slaResponseHours: 24, slaResolveHours: 168,
|
||||
slaRespondedAt: daysFromNow(-12), slaResolvedAt: daysFromNow(-3),
|
||||
messages: [
|
||||
{ fromType: 'customer', fromName: 'Jonas Weiß', body: 'Gemäß Art. 15 DSGVO bitte ich um Auskunft über die zu meiner Person gespeicherten Daten.', createdAt: daysFromNow(-14) },
|
||||
{ fromType: 'staff', fromName: 'Lena Vogt', body: 'Hallo Herr Weiß, Ihre Anfrage wurde registriert (Art. 15 DSGVO, Frist 1 Monat). Ich erstelle einen Datenexport und eine Aufstellung aller Verarbeitungsvorgänge.', createdAt: daysFromNow(-12) },
|
||||
{ fromType: 'staff', fromName: 'Lena Vogt', body: 'Der Datenexport liegt vor: CRM-Kontakt, 2 Projekte, 3 Rechnungen, 1 Ticket-Verlauf. Alle Daten wurden im Rahmen der Auftragsabwicklung verarbeitet (Art. 6 Abs. 1 lit. b). Dokument anbei.', createdAt: daysFromNow(-3) },
|
||||
],
|
||||
},
|
||||
]
|
||||
for (const t of tickets) {
|
||||
const { company, messages, ...rest } = t
|
||||
const created = await db.ticket.create({ data: { ...rest, companyId: companyMap[company] } })
|
||||
for (const m of messages) {
|
||||
await db.ticketMessage.create({ data: { ...m, ticketId: created.id } })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Estimates / Angebote ------------------------------------------------
|
||||
const estimates = [
|
||||
{
|
||||
number: 'AN-2026-0018', company: 'Ostsee Hotel & Spa AG', dealId: null, projectId: null,
|
||||
subject: 'Landingpage Herbstkampagne (Wellness-Pakete)', status: 'sent', validUntil: daysFromNow(14),
|
||||
issueDate: daysFromNow(-1), acceptedAt: null, acceptedBy: null,
|
||||
notes: 'Mobil-optimiert, 2 Entwürfe inkl. Revisionsschleife, Buchungs-CTA.',
|
||||
lines: [
|
||||
{ description: 'Konzeption & Wireframe Landingpage', quantity: 1, unitCents: CENTS(450) },
|
||||
{ description: 'Design-Entwürfe (2 Varianten)', quantity: 1, unitCents: CENTS(800) },
|
||||
{ description: 'Responsive Umsetzung (Astro)', quantity: 1, unitCents: CENTS(650) },
|
||||
{ description: 'Buchungs-CTA & Formular-Anbindung', quantity: 1, unitCents: CENTS(350) },
|
||||
{ description: 'Revisionsschleife (1 Runde)', quantity: 1, unitCents: CENTS(250) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'AN-2026-0017', company: 'Bauer Handwerk GmbH', dealId: null, projectId: null,
|
||||
subject: 'Corporate Website Relaunch (Werkvertrag)', status: 'sent', validUntil: daysFromNow(21),
|
||||
issueDate: daysFromNow(-3), acceptedAt: null, acceptedBy: null,
|
||||
notes: 'Inkl. Impressum, Datenschutz, DSGVO-Check, Schulung Redakteure.',
|
||||
lines: [
|
||||
{ description: 'Konzeption & Information Architecture', quantity: 1, unitCents: CENTS(1200) },
|
||||
{ description: 'UI/UX Design (inkl. 2 Revisionsschleifen)', quantity: 1, unitCents: CENTS(2400) },
|
||||
{ description: 'Frontend-Entwicklung (Astro + Tailwind)', quantity: 1, unitCents: CENTS(3200) },
|
||||
{ description: 'CMS-Integration (AOS-CMS, Headless)', quantity: 1, unitCents: CENTS(1500) },
|
||||
{ description: 'DSGVO/Impressum & BFSG-Accessibility', quantity: 1, unitCents: CENTS(500) },
|
||||
{ description: 'Schulung Redakteure (2 × 2 h)', quantity: 2, unitCents: CENTS(200) },
|
||||
{ description: 'Launch-Checkliste & Go-Live', quantity: 1, unitCents: CENTS(800) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'AN-2026-0016', company: 'Müller Bäckerei e.K.', dealId: null, projectId: null,
|
||||
subject: 'WooCommerce Online-Shop (Click & Collect)', status: 'accepted', validUntil: daysFromNow(-2),
|
||||
issueDate: daysFromNow(-18), acceptedAt: daysFromNow(-5), acceptedBy: 'Greta Müller',
|
||||
notes: 'Inkl. Altersprüfung, Click-&-Collect, Anbindung Kassensystem (offen).',
|
||||
lines: [
|
||||
{ description: 'Shop-Konzeption & Produktstruktur', quantity: 1, unitCents: CENTS(800) },
|
||||
{ description: 'WooCommerce Setup & Theme', quantity: 1, unitCents: CENTS(1800) },
|
||||
{ description: 'Click-&-Collect Modul (AOS-Plugin)', quantity: 1, unitCents: CENTS(900) },
|
||||
{ description: 'Zahlungsanbindung (Stripe)', quantity: 1, unitCents: CENTS(600) },
|
||||
{ description: 'Altersprüfung 18+ (juice/sharp)', quantity: 1, unitCents: CENTS(350) },
|
||||
{ description: 'Produktimport (200 Artikel)', quantity: 1, unitCents: CENTS(450) },
|
||||
{ description: 'Schulung & Handbuch', quantity: 1, unitCents: CENTS(300) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'AN-2026-0015', company: 'TechStart UG (haftungsbeschränkt)', dealId: null, projectId: projectMap['SaaS MVP — Auth & Billing'],
|
||||
subject: 'SaaS MVP — Erweiterung Auth & Billing (Phase 2)', status: 'accepted', validUntil: daysFromNow(5),
|
||||
issueDate: daysFromNow(-7), acceptedAt: daysFromNow(-3), acceptedBy: 'Jonas Weiß',
|
||||
notes: 'Aufbauend auf Phase 1. Stripe Billing, Team-Invites, Audit-Log.',
|
||||
lines: [
|
||||
{ description: 'Stripe Billing Integration (Subscriptions)', quantity: 1, unitCents: CENTS(3200) },
|
||||
{ description: 'Team-Management & Invites', quantity: 1, unitCents: CENTS(1800) },
|
||||
{ description: 'RBAC & Permissions Matrix', quantity: 1, unitCents: CENTS(1500) },
|
||||
{ description: 'Audit-Log (Hash-Kette)', quantity: 1, unitCents: CENTS(900) },
|
||||
],
|
||||
},
|
||||
{
|
||||
number: 'AN-2026-0014', company: 'Klinik am Park MV GmbH', dealId: null, projectId: projectMap['SEO-Optimierung & BFGG-Check'],
|
||||
subject: 'SEO-Optimierung & BFSG-Barrierefreiheit', status: 'rejected', validUntil: daysFromNow(-10),
|
||||
issueDate: daysFromNow(-25), acceptedAt: null, acceptedBy: null,
|
||||
notes: 'Kunde hat Budget gekürzt, scope reduziert. Neues Angebot folgt.',
|
||||
lines: [
|
||||
{ description: 'SEO-Audit & Keyword-Analyse', quantity: 1, unitCents: CENTS(1200) },
|
||||
{ description: 'Schema.org LocalBusiness Markup', quantity: 1, unitCents: CENTS(450) },
|
||||
{ description: 'BFSG Accessibility-Audit (axe)', quantity: 1, unitCents: CENTS(800) },
|
||||
{ description: 'Accessibility-Behebung (Kontrast, ARIA, Skip-Links)', quantity: 1, unitCents: CENTS(2200) },
|
||||
],
|
||||
},
|
||||
]
|
||||
for (const est of estimates) {
|
||||
const { company, lines, ...rest } = est
|
||||
const netCents = lines.reduce((s, l) => s + l.unitCents * l.quantity, 0)
|
||||
const taxBp = 1900
|
||||
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
|
||||
const created = await db.estimate.create({
|
||||
data: { ...rest, companyId: companyMap[company], netCents, taxBp, grossCents },
|
||||
})
|
||||
for (const l of lines) {
|
||||
await db.estimateLine.create({
|
||||
data: { ...l, estimateId: created.id, totalCents: l.unitCents * l.quantity },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// audit entries for tickets + estimates
|
||||
await db.auditLog.create({ data: { actor: 'aylin@nordlicht.digital', action: 'ticket.reply', entity: 'Ticket', entityId: 'seed', after: '{"status":"pending"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
await db.auditLog.create({ data: { actor: 'system:llm', action: 'ai.ticket:summarize', entity: 'Ticket', entityId: 'seed', after: '{"model":"glm","length":340}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.send', entity: 'Estimate', entityId: 'seed', after: '{"status":"sent"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.accept', entity: 'Estimate', entityId: 'seed', after: '{"status":"accepted","projectId":"created"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||
|
||||
console.log('Seed complete.')
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export type ViewId =
|
||||
| 'licenses'
|
||||
| 'contracts'
|
||||
| 'expenses'
|
||||
| 'estimates'
|
||||
| 'support'
|
||||
| 'finance'
|
||||
| 'audit'
|
||||
| 'settings'
|
||||
|
||||
Reference in new issue
Block a user