b6167bcb-7363-4695-9481-addd81de4367

This commit is contained in:
Z User committed 2026-10-26 13:40:52 +00:00
1 parent fdc3b82d10
commit d226dabba3
136 files changed
+13904 -2

No files matched your search

+10
View File
@@ -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 })
}
+26
View File
@@ -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 })
}
+95
View File
@@ -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,
})
}
+29
View File
@@ -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 },
})
}
+41
View File
@@ -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 })
}
+88
View File
@@ -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)
}
+17
View File
@@ -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 })
}
+43
View File
@@ -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,
},
})
}
+31
View File
@@ -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 })
}
+25
View File
@@ -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 })
}
+41
View File
@@ -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 })
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AOS — Agency Operating System",
description:
"Eine selbstgehostete Plattform, die eine Software-Agentur end-to-end betreibt: Leads, CRM, Projekte, Lizenzen, Finanzen — mit EU/DE-Compliance.",
keywords: [
"AOS",
"Agency OS",
"CRM",
"Leads Finder",
"Lizenzmanagement",
"E-Rechnung",
"DSGVO",
"GoBD",
],
authors: [{ name: "Nordlicht Digital GmbH" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "AOS — Agency Operating System",
description:
"Selbstgehostete Agentur-Plattform mit CRM, Leads Finder, Projekten, Lizenzen und Finanzen.",
siteName: "AOS",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children}
<Sonner position="bottom-right" richColors={false} closeButton />
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from '@/components/aos/app-shell'
export default function Home() {
return <AppShell />
}