bb983cb7-102d-4a43-baad-e3307280755d
This commit is contained in:
1 parent
34bd721ab1
commit
40805e16cd
17 files changed
+1119
-4
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user