diff --git a/aos-shots/v11-reporting-mrr.png b/aos-shots/v11-reporting-mrr.png new file mode 100644 index 0000000..898dbfd Binary files /dev/null and b/aos-shots/v11-reporting-mrr.png differ diff --git a/aos-shots/v11-reporting-utilization.png b/aos-shots/v11-reporting-utilization.png new file mode 100644 index 0000000..b574027 Binary files /dev/null and b/aos-shots/v11-reporting-utilization.png differ diff --git a/aos-shots/v11-reporting.png b/aos-shots/v11-reporting.png new file mode 100644 index 0000000..e56db3c Binary files /dev/null and b/aos-shots/v11-reporting.png differ diff --git a/aos-shots/v11-wizard-step1.png b/aos-shots/v11-wizard-step1.png new file mode 100644 index 0000000..b574027 Binary files /dev/null and b/aos-shots/v11-wizard-step1.png differ diff --git a/aos-shots/v11-wizard-step3-modules.png b/aos-shots/v11-wizard-step3-modules.png new file mode 100644 index 0000000..b574027 Binary files /dev/null and b/aos-shots/v11-wizard-step3-modules.png differ diff --git a/aos-shots/v11-wizard-step4-mail.png b/aos-shots/v11-wizard-step4-mail.png new file mode 100644 index 0000000..b574027 Binary files /dev/null and b/aos-shots/v11-wizard-step4-mail.png differ diff --git a/aos-shots/v12-wizard.png b/aos-shots/v12-wizard.png new file mode 100644 index 0000000..97d3f13 Binary files /dev/null and b/aos-shots/v12-wizard.png differ diff --git a/aos-verify9.sh b/aos-verify9.sh new file mode 100644 index 0000000..f671f45 --- /dev/null +++ b/aos-verify9.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Verify Reporting + Setup Wizard +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/reporting -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/reporting" +curl -s "$BASE/api/aos/reporting" | python3 -c " +import sys,json +d=json.load(sys.stdin) +p=d['pipeline'] +print(f' pipeline: {p[\"conversionRate\"]}% conversion, €{p[\"pipelineValue\"]/100:.0f} open, €{p[\"wonValue\"]/100:.0f} won') +r=d['recurring'] +print(f' MRR: €{r[\"mrr\"]/100:.0f}, ARR: €{r[\"arr\"]/100:.0f}, renewals: {len(r[\"renewalsDue\"])}') +print(f' utilization: {len(d[\"utilization\"][\"byProject\"])} projects, {len(d[\"utilization\"][\"weekly\"])} weeks') +print(f' activity: {len(d[\"activity\"])} groups, estimateWinRate: {d[\"estimateWinRate\"]}%') +" 2>/dev/null + +echo "" +echo "=== Browser: Reporting 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 "Reporting" 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 " Reporting H1: $H1" +agent-browser screenshot --full "$SHOTS/v11-reporting.png" >/dev/null 2>&1 && echo " shot OK" + +echo "=== Switch to MRR tab ===" +agent-browser find role tab click --name "MRR & Erneuerungen" >/dev/null 2>&1 || agent-browser find text "MRR" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v11-reporting-mrr.png" >/dev/null 2>&1 && echo " mrr shot OK" + +echo "=== Switch to Auslastung tab ===" +agent-browser find role tab click --name "Auslastung" >/dev/null 2>&1 || agent-browser find text "Auslastung" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v11-reporting-utilization.png" >/dev/null 2>&1 && echo " utilization shot OK" + +echo "" +echo "=== Browser: Setup Wizard ===" +agent-browser find text "Setup-Wizard" 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 " Wizard H1: $H1" +agent-browser screenshot --full "$SHOTS/v11-wizard-step1.png" >/dev/null 2>&1 && echo " step1 shot OK" + +echo "=== Click Weiter to step 3 (Modules) ===" +agent-browser find text "Weiter & Speichern" click >/dev/null 2>&1 +agent-browser wait 1500 >/dev/null 2>&1 +agent-browser find text "Weiter & Speichern" click >/dev/null 2>&1 +agent-browser wait 1500 >/dev/null 2>&1 +# now at step 3 +agent-browser screenshot --full "$SHOTS/v11-wizard-step3-modules.png" >/dev/null 2>&1 && echo " step3 shot OK" + +echo "=== Go to step 4 (Mail) and test ===" +agent-browser find text "E-Mail" click >/dev/null 2>&1 +agent-browser wait 1500 >/dev/null 2>&1 +agent-browser find text "Test ausführen" click >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v11-wizard-step4-mail.png" >/dev/null 2>&1 && echo " step4 mail shot OK" +MAIL_RES=$(agent-browser eval "document.body.innerText.includes('Verbindung erfolgreich') || document.body.innerText.includes('SMTP')" 2>/dev/null | tail -1) +echo " mail test result visible: $MAIL_RES" + +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"/v11-* 2>/dev/null | tail -8 diff --git a/src/app/api/aos/reporting/route.ts b/src/app/api/aos/reporting/route.ts new file mode 100644 index 0000000..50be540 --- /dev/null +++ b/src/app/api/aos/reporting/route.ts @@ -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 = { + 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 = {} + 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 + }) +} diff --git a/src/app/api/aos/setup/test-mail/route.ts b/src/app/api/aos/setup/test-mail/route.ts new file mode 100644 index 0000000..9ba7ca9 --- /dev/null +++ b/src/app/api/aos/setup/test-mail/route.ts @@ -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(), + }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 3e50c33..c4c7496 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -8,6 +8,7 @@ import { CommandMenu } from './command-menu' import { CompanyDrawer } from './company-drawer' import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' import { DashboardView } from './views/dashboard' +import { ReportingView } from './views/reporting' import { CrmView } from './views/crm' import { LeadsView } from './views/leads' import { EstimatesView } from './views/estimates' @@ -19,6 +20,7 @@ import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' import { AuditView } from './views/audit' import { SettingsView } from './views/settings' +import { SetupWizardView } from './views/setup-wizard' export function AppShell() { const { view, sidebarOpen, setSidebarOpen } = useAos() @@ -45,6 +47,7 @@ export function AppShell() {
{view === 'dashboard' && } + {view === 'reporting' && } {view === 'crm' && } {view === 'leads' && } {view === 'estimates' && } @@ -56,6 +59,7 @@ export function AppShell() { {view === 'finance' && } {view === 'audit' && } {view === 'settings' && } + {view === 'setup' && }