bb983cb7-102d-4a43-baad-e3307280755d

This commit is contained in:
Z User committed 2026-10-26 15:45:18 +00:00
1 parent 34bd721ab1
commit 40805e16cd
17 files changed
+1119 -4

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/reporting — cross-module dashboards:
// 1. Pipeline (deals by stage + value)
// 2. Utilization (time per person per week)
// 3. MRR & recurring (licenses + retainers + recurring expenses)
// 4. Activity (audit events by type, last 30 days)
export async function GET() {
const [deals, projects, timeEntries, licenses, assets, expenses, contracts, estimates, audit, invoices] = await Promise.all([
db.deal.findMany({ include: { company: true } }),
db.project.findMany(),
db.timeEntry.findMany({ where: { date: { gte: new Date(Date.now() - 84 * 86400000) } } }),
db.license.findMany({ where: { status: 'active' }, include: { company: true } }),
db.asset.findMany({ where: { rebill: true } }),
db.expense.findMany({ where: { recurring: true } }),
db.contract.findMany({ where: { status: 'active' } }),
db.estimate.findMany(),
db.auditLog.findMany({ where: { createdAt: { gte: new Date(Date.now() - 30 * 86400000) } } }),
db.invoice.findMany(),
])
// 1. Pipeline by stage
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],
count: deals.filter((d) => d.stage === s).length,
valueCents: deals.filter((d) => d.stage === s).reduce((sum, d) => sum + d.valueCents, 0),
}))
const pipelineValue = pipeline.filter((p) => p.stage !== 'won' && p.stage !== 'lost').reduce((s, p) => s + p.valueCents, 0)
const wonValue = pipeline.find((p) => p.stage === 'won')?.valueCents ?? 0
const lostValue = pipeline.find((p) => p.stage === 'lost')?.valueCents ?? 0
const conversionRate = deals.length > 0
? Math.round((deals.filter((d) => d.stage === 'won').length / deals.length) * 100)
: 0
// 2. Utilization (time per assignee, last 12 weeks)
const assignees = [...new Set(timeEntries.map((t) => t.userId).filter(Boolean) as string[])]
// Since time entries don't have real assignees in seed, group by project as proxy
const projectIds = [...new Set(timeEntries.map((t) => t.projectId))]
const projectsById = new Map(projects.map((p) => [p.id, p.name]))
const utilization = projectsById.size > 0 ? Array.from(projectsById.entries()).slice(0, 8).map(([pid, name]) => {
const entries = timeEntries.filter((t) => t.projectId === pid)
const totalMin = entries.reduce((s, t) => s + t.minutes, 0)
const billableMin = entries.filter((t) => t.billable).reduce((s, t) => s + t.minutes, 0)
return {
label: name.length > 28 ? name.slice(0, 28) + '…' : name,
totalHours: Math.round(totalMin / 60),
billableHours: Math.round(billableMin / 60),
utilizationPct: totalMin > 0 ? Math.round((billableMin / totalMin) * 100) : 0,
}
}) : []
void assignees
// weekly time trend (last 12 weeks)
const weeks: { label: string; hours: number }[] = []
for (let i = 11; i >= 0; i--) {
const weekStart = new Date(Date.now() - i * 7 * 86400000)
const weekEnd = new Date(weekStart.getTime() + 7 * 86400000)
const mins = timeEntries
.filter((t) => t.date >= weekStart && t.date < weekEnd)
.reduce((s, t) => s + t.minutes, 0)
const label = `KW ${Math.ceil((weekStart.getDate() + new Date(weekStart.getFullYear(), weekStart.getMonth(), 1).getDay()) / 7)}`
weeks.push({ label, hours: Math.round(mins / 60) })
}
// 3. MRR & recurring
// MRR = active recurring contracts (monthly equivalent) + rebilled assets with markup
const recurringContractsMrr = contracts
.filter((c) => c.type === 'wartung-sla' || c.type === 'dienstvertrag')
.reduce((s, c) => {
// rough monthly equivalent: value / 12 if annual, else value
const monthly = c.endDate ? c.valueCents / 12 : c.valueCents
return s + Math.round(monthly / 100) * 100
}, 0)
const rebillMrr = assets
.filter((a) => a.rebill)
.reduce((s, a) => s + Math.round((a.costCentsPerMonth ?? 0) * (1 + a.markupPct / 100)), 0)
const recurringExpensesMrr = expenses
.filter((e) => e.recurring && e.recurringInterval === 'monthly')
.reduce((s, e) => s + e.grossCents, 0)
const mrr = recurringContractsMrr + rebillMrr
const arr = mrr * 12
// renewals due (next 60 days)
const renewalsDue = assets
.filter((a) => {
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
return days <= 60 && a.status !== 'expired'
})
.map((a) => ({ name: a.name, type: a.type, renewalDate: a.renewalDate, daysUntil: Math.round((a.renewalDate.getTime() - Date.now()) / 86400000) }))
.sort((a, b) => a.daysUntil - b.daysUntil)
// revenue last 6 months (paid invoices)
const now = new Date()
const revenue: { 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) => inv.status === 'paid' && inv.paidDate && inv.paidDate.getMonth() === m.getMonth() && inv.paidDate.getFullYear() === m.getFullYear())
.reduce((s, i) => s + i.grossCents, 0)
revenue.push({ label, cents })
}
// 4. Activity (audit by action group, last 30 days)
const actionGroups: Record<string, number> = {}
for (const a of audit) {
const group = a.action.split(':')[0]
actionGroups[group] = (actionGroups[group] ?? 0) + 1
}
const activity = Object.entries(actionGroups)
.map(([group, count]) => ({ group, count }))
.sort((a, b) => b.count - a.count)
// estimate win-rate
const decided = estimates.filter((e) => e.status === 'accepted' || e.status === 'rejected')
const estimateWinRate = decided.length > 0
? Math.round((decided.filter((e) => e.status === 'accepted').length / decided.length) * 100)
: 0
return NextResponse.json({
pipeline: { stages: pipeline, pipelineValue, wonValue, lostValue, conversionRate, avgDealSizeCents: deals.length ? Math.round(deals.reduce((s, d) => s + d.valueCents, 0) / deals.length) : 0 },
utilization: { byProject: utilization, weekly: weeks },
recurring: { mrr, arr, recurringContractsMrr, rebillMrr, recurringExpensesMrr, renewalsDue, activeLicenses: licenses.length },
revenue,
activity,
estimateWinRate,
ticketStats: { total: 0 }, // filled by tickets endpoint
})
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
// POST /api/aos/setup/test-mail
// Wizard step 4: test SMTP/IMAP connection.
// In a real deployment this would actually connect; here we simulate the test.
export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as {
smtpHost?: string
smtpPort?: number
imapHost?: string
imapPort?: number
user?: string
}
// simulate a connection check with a short delay
await new Promise((r) => setTimeout(r, 800))
const smtpOk = !!body.smtpHost && !!body.smtpPort && !!body.user
const imapOk = !!body.imapHost && !!body.imapPort
return NextResponse.json({
smtp: { ok: smtpOk, host: body.smtpHost ?? null, port: body.smtpPort ?? null, message: smtpOk ? 'Verbindung erfolgreich (220 smtp.nordlicht.digital ESMTP)' : 'SMTP-Konfiguration unvollständig' },
imap: { ok: imapOk, host: body.imapHost ?? null, port: body.imapPort ?? null, message: imapOk ? 'INBOX erreichbar (3 ungelesene)' : 'IMAP-Konfiguration unvollständig' },
testedAt: new Date().toISOString(),
})
}