b6167bcb-7363-4695-9481-addd81de4367
This commit is contained in:
1 parent
fdc3b82d10
commit
d226dabba3
136 files changed
+13904
-2
No files matched your search
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/audit — audit log (hash-chain evidence)
|
||||
export async function GET() {
|
||||
const entries = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
return NextResponse.json({ entries })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/crm — companies, persons, deals grouped by stage
|
||||
export async function GET() {
|
||||
const [companies, deals, persons] = await Promise.all([
|
||||
db.company.findMany({ orderBy: { createdAt: 'desc' }, include: { persons: true, deals: true } }),
|
||||
db.deal.findMany({ include: { company: true }, orderBy: { updatedAt: 'desc' } }),
|
||||
db.person.findMany({ include: { company: true } }),
|
||||
])
|
||||
|
||||
const stages = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']
|
||||
const stageLabels: Record<string, string> = {
|
||||
lead: 'Lead', qualified: 'Qualifiziert', proposal: 'Angebot', negotiation: 'Verhandlung', won: 'Gewonnen', lost: 'Verloren',
|
||||
}
|
||||
|
||||
const pipeline = stages.map((s) => ({
|
||||
stage: s,
|
||||
label: stageLabels[s],
|
||||
deals: deals.filter((d) => d.stage === s),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ companies, deals, persons, pipeline })
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/dashboard — agency KPI overview
|
||||
export async function GET() {
|
||||
const [deals, leads, projects, licenses, assets, invoices, audit, modules, users] = await Promise.all([
|
||||
db.deal.findMany({ include: { company: true } }),
|
||||
db.lead.findMany(),
|
||||
db.project.findMany(),
|
||||
db.license.findMany({ include: { activations: true, company: true } }),
|
||||
db.asset.findMany(),
|
||||
db.invoice.findMany(),
|
||||
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8 }),
|
||||
db.module.findMany(),
|
||||
db.user.findMany(),
|
||||
])
|
||||
|
||||
const pipelineValue = deals
|
||||
.filter((d) => d.stage !== 'lost' && d.stage !== 'won')
|
||||
.reduce((s, d) => s + d.valueCents, 0)
|
||||
|
||||
const wonValue = deals
|
||||
.filter((d) => d.stage === 'won')
|
||||
.reduce((s, d) => s + d.valueCents, 0)
|
||||
|
||||
const pendingLeads = leads.filter((l) => l.status === 'pending').length
|
||||
const approvedLeads = leads.filter((l) => l.status === 'approved').length
|
||||
const rejectedLeads = leads.filter((l) => l.status === 'rejected').length
|
||||
const acceptRate = approvedLeads + rejectedLeads > 0
|
||||
? Math.round((approvedLeads / (approvedLeads + rejectedLeads)) * 100)
|
||||
: 0
|
||||
|
||||
const openProjects = projects.filter((p) => p.status === 'active' || p.status === 'planning' || p.status === 'review').length
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'active').length
|
||||
const expiringAssets = assets.filter((a) => a.status === 'expiring' || a.status === 'expired').length
|
||||
|
||||
const outstandingInvoices = invoices
|
||||
.filter((i) => i.status === 'sent' || i.status === 'overdue')
|
||||
.reduce((s, i) => s + i.grossCents, 0)
|
||||
|
||||
const paidThisYear = invoices
|
||||
.filter((i) => i.status === 'paid')
|
||||
.reduce((s, i) => s + i.grossCents, 0)
|
||||
|
||||
const stages = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']
|
||||
const stageLabels: Record<string, string> = {
|
||||
lead: 'Lead', qualified: 'Qualifiziert', proposal: 'Angebot', negotiation: 'Verhandlung', won: 'Gewonnen', lost: 'Verloren',
|
||||
}
|
||||
const funnel = stages.map((s) => ({
|
||||
stage: s,
|
||||
label: stageLabels[s],
|
||||
count: deals.filter((d) => d.stage === s).length,
|
||||
valueCents: deals.filter((d) => d.stage === s).reduce((sum, d) => sum + d.valueCents, 0),
|
||||
}))
|
||||
|
||||
const now = new Date()
|
||||
const months: { label: string; cents: number }[] = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const label = m.toLocaleDateString('de-DE', { month: 'short' })
|
||||
const cents = invoices
|
||||
.filter((inv) => {
|
||||
const d = inv.issueDate
|
||||
return d.getMonth() === m.getMonth() && d.getFullYear() === m.getFullYear() && inv.status === 'paid'
|
||||
})
|
||||
.reduce((s, i) => s + i.grossCents, 0)
|
||||
months.push({ label, cents })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
kpis: {
|
||||
pipelineValue,
|
||||
wonValue,
|
||||
pendingLeads,
|
||||
acceptRate,
|
||||
openProjects,
|
||||
activeLicenses,
|
||||
expiringAssets,
|
||||
outstandingInvoices,
|
||||
paidThisYear,
|
||||
auditEntries: audit.length,
|
||||
enabledModules: modules.filter((m) => m.enabled).length,
|
||||
totalModules: modules.length,
|
||||
teamMembers: users.filter((u) => u.role !== 'client').length,
|
||||
},
|
||||
funnel,
|
||||
revenue: months,
|
||||
audit,
|
||||
modules,
|
||||
users,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/finance — invoices with lines + company
|
||||
export async function GET() {
|
||||
const invoices = await db.invoice.findMany({
|
||||
orderBy: { issueDate: 'desc' },
|
||||
include: { company: true, lines: true },
|
||||
})
|
||||
|
||||
const total = invoices.filter((i) => i.status !== 'cancelled' && i.status !== 'storno').length
|
||||
const draft = invoices.filter((i) => i.status === 'draft').length
|
||||
const sent = invoices.filter((i) => i.status === 'sent').length
|
||||
const paid = invoices.filter((i) => i.status === 'paid').length
|
||||
const overdue = invoices.filter((i) => i.status === 'overdue').length
|
||||
const zugferdCount = invoices.filter((i) => i.zugferd).length
|
||||
const xrechnungCount = invoices.filter((i) => i.xrechnung).length
|
||||
const datevCount = invoices.filter((i) => i.datevExported).length
|
||||
const outstandingCents = invoices
|
||||
.filter((i) => i.status === 'sent' || i.status === 'overdue')
|
||||
.reduce((s, i) => s + i.grossCents, 0)
|
||||
|
||||
return NextResponse.json({
|
||||
invoices,
|
||||
stats: { total, draft, sent, paid, overdue, zugferdCount, xrechnungCount, datevCount, outstandingCents },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/leads/[id] — human review: approve / reject / snooze / convert
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const { status, reviewReason } = body as { status: string; reviewReason?: string }
|
||||
|
||||
if (!['pending', 'approved', 'rejected', 'snoozed', 'converted'].includes(status)) {
|
||||
return NextResponse.json({ error: 'invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const lead = await db.lead.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
reviewReason: reviewReason ?? null,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// write an audit entry
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'sven@nordlicht.digital',
|
||||
action: 'leads.review:' + (status === 'approved' ? 'approve' : status === 'rejected' ? 'reject' : 'update'),
|
||||
entity: 'Lead',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ status, reviewReason }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ lead })
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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/leads/explain
|
||||
// Generates an evidence-grounded "why this lead" draft via LLM.
|
||||
// Every claim in the output must reference an evidence id.
|
||||
export async function POST(req: Request) {
|
||||
const { leadId } = (await req.json()) as { leadId: string }
|
||||
|
||||
const lead = await db.lead.findUnique({ where: { id: leadId } })
|
||||
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
|
||||
|
||||
const evidence = lead.evidence ? JSON.parse(lead.evidence) as { id: string; check: string; status: string; detail: string }[] : []
|
||||
|
||||
const evidenceBlock = evidence
|
||||
.map((e) => `- [${e.id}] ${e.check} (${e.status}): ${e.detail}`)
|
||||
.join('\n')
|
||||
|
||||
const systemPrompt = `Du bist ein Business-Development-Assistent für eine deutsche Software-Agentur (Nordlicht Digital GmbH).
|
||||
Deine Aufgabe: Aus einem Website-Audit eine kurze, evidenzbasierte Begründung ("warum dieser Lead") und einen vorgeschlagenen Ansatzwinkel formulieren.
|
||||
|
||||
Regeln:
|
||||
- Schreibe auf Deutsch.
|
||||
- Jede Behauptung MUSS in Klammern auf die zugehörige Evidence-ID verweisen, z.B. "(e3)".
|
||||
- Erfinde nichts. Beziehe dich nur auf die gelisteten Audit-Ergebnisse.
|
||||
- Maximal 3 Sätze Begründung, danach ein kurzer "Winkel"-Vorschlag.
|
||||
- KEINE automatischen Handlungen andeuten. Der Mensch entscheidet über den Kanal.`
|
||||
|
||||
const userPrompt = `Unternehmen: ${lead.companyName}
|
||||
Website: ${lead.website}
|
||||
Branche: ${lead.branch}
|
||||
Stadt: ${lead.city}
|
||||
Score: ${lead.score}/100 (Label: ${lead.scoreLabel})
|
||||
|
||||
Audit-Ergebnisse:
|
||||
${evidenceBlock}
|
||||
|
||||
Formuliere Begründung und Winkel.`
|
||||
|
||||
try {
|
||||
const zai = await ZAI.create()
|
||||
const res = await zai.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.4,
|
||||
max_tokens: 400,
|
||||
})
|
||||
|
||||
const explanation = res.choices?.[0]?.message?.content ?? ''
|
||||
|
||||
// log the AI action (prompt hash + actor) per AI_RULES
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'system:llm',
|
||||
action: 'ai.leads:explain',
|
||||
entity: 'Lead',
|
||||
entityId: leadId,
|
||||
after: JSON.stringify({ model: 'glm', promptHash: hash(userPrompt), length: explanation.length }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
// persist the (re)generated explanation
|
||||
const updated = await db.lead.update({
|
||||
where: { id: leadId },
|
||||
data: { explanation },
|
||||
})
|
||||
|
||||
return NextResponse.json({ explanation, lead: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'unknown error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function hash(s: string) {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
||||
}
|
||||
return ('00000000' + (h >>> 0).toString(16)).slice(-8)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/leads — leads finder review queue
|
||||
export async function GET() {
|
||||
const leads = await db.lead.findMany({
|
||||
orderBy: [{ status: 'asc' }, { score: 'desc' }],
|
||||
})
|
||||
// parse evidence JSON for convenience
|
||||
const withEvidence = leads.map((l) => ({
|
||||
...l,
|
||||
evidence: l.evidence ? JSON.parse(l.evidence) : [],
|
||||
}))
|
||||
return NextResponse.json({ leads: withEvidence })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/licenses — issued licenses + asset/renewal register
|
||||
export async function GET() {
|
||||
const [licenses, assets] = await Promise.all([
|
||||
db.license.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { company: true, activations: true },
|
||||
}),
|
||||
db.asset.findMany({ orderBy: { renewalDate: 'asc' } }),
|
||||
])
|
||||
|
||||
const activeLicenses = licenses.filter((l) => l.status === 'active').length
|
||||
const suspended = licenses.filter((l) => l.status === 'suspended').length
|
||||
const expired = licenses.filter((l) => l.status === 'expired').length
|
||||
const totalActivations = licenses.reduce((s, l) => s + l.activations.length, 0)
|
||||
|
||||
const expiringSoon = assets.filter((a) => {
|
||||
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
|
||||
return days <= 30 && a.status !== 'expired'
|
||||
})
|
||||
const expiredAssets = assets.filter((a) => a.status === 'expired')
|
||||
const monthlyRebill = assets
|
||||
.filter((a) => a.rebill)
|
||||
.reduce((s, a) => s + (a.costCentsPerMonth ?? 0), 0)
|
||||
|
||||
return NextResponse.json({
|
||||
licenses,
|
||||
assets,
|
||||
stats: {
|
||||
activeLicenses,
|
||||
suspended,
|
||||
expired,
|
||||
totalActivations,
|
||||
expiringSoon: expiringSoon.length,
|
||||
expiredAssets: expiredAssets.length,
|
||||
monthlyRebill,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/modules — module registry
|
||||
export async function GET() {
|
||||
const modules = await db.module.findMany({ orderBy: { id: 'asc' } })
|
||||
const settings = await db.setting.findMany()
|
||||
return NextResponse.json({
|
||||
modules,
|
||||
settings: Object.fromEntries(settings.map((s) => [s.key, s.value])),
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH /api/aos/modules — toggle a module on/off
|
||||
export async function PATCH(req: Request) {
|
||||
const { id, enabled } = (await req.json()) as { id: string; enabled: boolean }
|
||||
const mod = await db.module.update({ where: { id }, data: { enabled } })
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'max@nordlicht.digital',
|
||||
action: 'module.' + (enabled ? 'enable' : 'disable'),
|
||||
entity: 'Module',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ enabled }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ module: mod })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/projects — projects with tasks, time entries, company
|
||||
export async function GET() {
|
||||
const projects = await db.project.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
company: true,
|
||||
tasks: { orderBy: { createdAt: 'asc' } },
|
||||
timeEntries: { orderBy: { date: 'desc' } },
|
||||
},
|
||||
})
|
||||
|
||||
// weekly time totals (last 7 days)
|
||||
const since = new Date(Date.now() - 7 * 86400000)
|
||||
const recentTime = await db.timeEntry.findMany({
|
||||
where: { date: { gte: since } },
|
||||
})
|
||||
const minutesThisWeek = recentTime.reduce((s, t) => s + t.minutes, 0)
|
||||
|
||||
return NextResponse.json({ projects, minutesThisWeek })
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 60
|
||||
|
||||
// POST /api/aos/seed — reseed the database (destructive, dev only)
|
||||
export async function POST() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json({ error: 'not in production' }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
|
||||
const { db } = await import('@/lib/db')
|
||||
await db.invoiceLine.deleteMany()
|
||||
await db.invoice.deleteMany()
|
||||
await db.asset.deleteMany()
|
||||
await db.licenseActivation.deleteMany()
|
||||
await db.license.deleteMany()
|
||||
await db.timeEntry.deleteMany()
|
||||
await db.task.deleteMany()
|
||||
await db.project.deleteMany()
|
||||
await db.lead.deleteMany()
|
||||
await db.deal.deleteMany()
|
||||
await db.person.deleteMany()
|
||||
await db.company.deleteMany()
|
||||
await db.auditLog.deleteMany()
|
||||
await db.setting.deleteMany()
|
||||
await db.module.deleteMany()
|
||||
await db.user.deleteMany()
|
||||
|
||||
await execAsync('bun run src/lib/seed.ts', { cwd: process.cwd() })
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'seed failed'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user