diff --git a/aos-shots/v9-contract-detail.png b/aos-shots/v9-contract-detail.png new file mode 100644 index 0000000..9725f9e Binary files /dev/null and b/aos-shots/v9-contract-detail.png differ diff --git a/aos-shots/v9-contracts.png b/aos-shots/v9-contracts.png new file mode 100644 index 0000000..5c16c23 Binary files /dev/null and b/aos-shots/v9-contracts.png differ diff --git a/aos-shots/v9-expenses-report.png b/aos-shots/v9-expenses-report.png new file mode 100644 index 0000000..210d895 Binary files /dev/null and b/aos-shots/v9-expenses-report.png differ diff --git a/aos-shots/v9-expenses.png b/aos-shots/v9-expenses.png new file mode 100644 index 0000000..b2498a3 Binary files /dev/null and b/aos-shots/v9-expenses.png differ diff --git a/aos-verify7.sh b/aos-verify7.sh new file mode 100644 index 0000000..377868a --- /dev/null +++ b/aos-verify7.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Verify Contracts + Expenses modules (adapted from PerfEx CRM) +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/contracts -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/contracts" +echo -n " /api/aos/expenses -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/expenses" +echo " contracts count:"; curl -s "$BASE/api/aos/contracts" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' {len(d[\"contracts\"])} contracts, stats: {d[\"stats\"]}')" 2>/dev/null +echo " expenses count:"; curl -s "$BASE/api/aos/expenses" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' {len(d[\"expenses\"])} expenses, billable: {d[\"stats\"][\"billable\"]}, report cats: {len(d[\"report\"][\"byCategory\"])}')" 2>/dev/null + +echo "" +echo "=== Browser: Contracts 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 "Verträge" 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 " Contracts H1: $H1" +agent-browser screenshot --full "$SHOTS/v9-contracts.png" >/dev/null 2>&1 && echo " shot OK" + +echo "=== Click first contract to open dialog ===" +agent-browser eval "document.querySelector('tbody tr')?.click()" >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +DIALOG=$(agent-browser eval "document.querySelector('[role=dialog]')?.textContent?.slice(0,150) || 'closed'" 2>/dev/null | tail -1 | tr -d '"') +echo " dialog: $(echo "$DIALOG" | head -c 120)" +agent-browser screenshot --full "$SHOTS/v9-contract-detail.png" >/dev/null 2>&1 && echo " detail shot OK" +# check if sign button present +SIGNBTN=$(agent-browser find text "E-Signatur anfordern" 2>&1 | head -1) +echo " sign button: $SIGNBTN" +agent-browser press Escape >/dev/null 2>&1; agent-browser wait 500 >/dev/null 2>&1 + +echo "" +echo "=== Browser: Expenses view ===" +agent-browser find text "Ausgaben" 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 " Expenses H1: $H1" +agent-browser screenshot --full "$SHOTS/v9-expenses.png" >/dev/null 2>&1 && echo " shot OK" + +echo "=== Switch to report tab ===" +agent-browser find role tab click --name "Ausgabenreport" >/dev/null 2>&1 || agent-browser find text "Ausgabenreport" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v9-expenses-report.png" >/dev/null 2>&1 && echo " report shot OK" + +echo "" +echo "=== Test convert-to-invoice (API) ===" +# find a billable, not-invoiced expense +EXP_ID=$(curl -s "$BASE/api/aos/expenses" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for e in d['expenses']: + if e['billable'] and e['status']!='invoiced' and e['companyName']: + print(e['id']); break +" 2>/dev/null) +echo " expense id to convert: $EXP_ID" +if [ -n "$EXP_ID" ]; then + RES=$(curl -s -X POST "$BASE/api/aos/expenses/$EXP_ID/convert") + echo " convert result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'invoice {d[\"invoice\"][\"number\"]} = {d[\"invoice\"][\"grossCents\"]/100:.2f}€')" 2>/dev/null)" +fi + +echo "" +echo "=== Test contract sign (API) ===" +CON_ID=$(curl -s "$BASE/api/aos/contracts" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for c in d['contracts']: + if c['status'] in ('draft','sent'): + print(c['id']); break +" 2>/dev/null) +echo " contract id to sign: $CON_ID" +if [ -n "$CON_ID" ]; then + RES=$(curl -s -X PATCH "$BASE/api/aos/contracts/$CON_ID/sign" -H 'content-type: application/json' -d '{"signedBy":"Test-Kunde"}') + echo " sign result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"contract\"][\"status\"]}, token={d[\"contract\"][\"signToken\"]}')" 2>/dev/null)" +fi + +echo "" +echo "=== Errors ===" +agent-browser errors 2>&1 | head -8 +echo "--- console last 5 ---" +agent-browser console 2>&1 | tail -5 + +echo "" +echo "=== Cleanup ===" +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 "" +echo "=== DONE ===" +ls -la "$SHOTS"/v9-* 2>/dev/null | tail -6 diff --git a/db/custom.db b/db/custom.db index 5db2b4a..5c05649 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d40064..2158b8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -275,3 +275,65 @@ model InvoiceLine { unitCents Int totalCents Int } + +// --------------------------------------------------------------------------- +// Contracts / Verträge (Phase 5 — adapted from PerfEx Contracts, Germanized) +// German contract types: Werkvertrag, Dienstvertrag, Wartungsvertrag/SLA, +// AVV (Auftragsverarbeitungsvertrag), NDA, AVV +// --------------------------------------------------------------------------- + +model Contract { + id String @id @default(cuid()) + number String @unique // gapless, e.g. VER-2026-0001 + companyId String + type String // werkvertrag|dienstvertrag|wartung-sla|avv|nda|dpa + title String + status String @default("draft") // draft|sent|signed|active|expired|terminated + valueCents Int @default(0) + startDate DateTime? + endDate DateTime? // null = unbefristet + templateId String? // reference to a stored template + dealId String? // optional link to originating deal + projectId String? // optional link to project + // E-signature hook (mock) + signedAt DateTime? + signedBy String? // signer name/email + signedIp String? + signToken String? // unique token for the signing link + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// --------------------------------------------------------------------------- +// Expenses / Ausgaben (adapted from PerfEx Expenses, Germanized for DATEV) +// --------------------------------------------------------------------------- + +model Expense { + id String @id @default(cuid()) + number String @unique // gapless, e.g. AUS-2026-0001 + categoryId String // reise|software|hardware|fremdleistung|buero|marketing + vendor String? // supplier / merchant + description String + amountCents Int // net amount + taxBp Int @default(1900) + grossCents Int @default(0) + date DateTime @default(now()) + // linking + companyId String? // customer this is billable to + projectId String? // project this relates to + billable Boolean @default(false) // convert to customer invoice + markupPct Int @default(0) // markup when re-billed + // recurring + recurring Boolean @default(false) + recurringInterval String? // monthly|quarterly|yearly + // receipt + export + receiptHash String? // SHA-256 of stored receipt file (content-addressed) + datevExported Boolean @default(false) + // conversion + convertedInvoiceId String? // link to the invoice created from this expense + status String @default("recorded") // recorded|approved|invoiced|rejected + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + diff --git a/src/app/api/aos/contracts/[id]/sign/route.ts b/src/app/api/aos/contracts/[id]/sign/route.ts new file mode 100644 index 0000000..7fc8062 --- /dev/null +++ b/src/app/api/aos/contracts/[id]/sign/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// PATCH /api/aos/contracts/[id]/sign — e-signature hook (mock) +// Transitions draft/sent → signed, records signer + IP + token, writes audit. +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const body = (await req.json().catch(() => ({}))) as { signedBy?: string } + + const contract = await db.contract.findUnique({ where: { id } }) + if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (contract.status === 'signed' || contract.status === 'active') { + return NextResponse.json({ error: 'already signed' }, { status: 409 }) + } + + const token = 'sig_' + Math.random().toString(36).slice(2, 14) + const updated = await db.contract.update({ + where: { id }, + data: { + status: 'signed', + signedAt: new Date(), + signedBy: body.signedBy ?? 'Kunde (via Portal)', + signedIp: '91.64.x.x (portal)', + signToken: token, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'contract.sign', + entity: 'Contract', + entityId: id, + before: JSON.stringify({ status: contract.status }), + after: JSON.stringify({ status: 'signed', signedBy: updated.signedBy, token }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ contract: updated }) +} diff --git a/src/app/api/aos/contracts/route.ts b/src/app/api/aos/contracts/route.ts new file mode 100644 index 0000000..c92e66a --- /dev/null +++ b/src/app/api/aos/contracts/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/contracts — contracts list with company, expiry stats +export async function GET() { + const contracts = await db.contract.findMany({ + orderBy: { updatedAt: 'desc' }, + }) + + // enrich with company names (separate query to keep SQLite happy) + const companyIds = [...new Set(contracts.map((c) => c.companyId))] + const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }) + const companyMap = new Map(companies.map((c) => [c.id, c.name])) + + const enriched = contracts.map((c) => ({ ...c, companyName: companyMap.get(c.companyId) ?? '—' })) + + const stats = { + total: contracts.length, + active: contracts.filter((c) => c.status === 'active').length, + draft: contracts.filter((c) => c.status === 'draft').length, + sent: contracts.filter((c) => c.status === 'sent').length, + signed: contracts.filter((c) => c.status === 'signed' || c.status === 'active').length, + expired: contracts.filter((c) => c.status === 'expired').length, + expiringSoon: contracts.filter((c) => { + if (!c.endDate || c.status === 'expired') return false + const days = (c.endDate.getTime() - Date.now()) / 86400000 + return days <= 30 && days >= 0 + }).length, + totalValueCents: contracts.filter((c) => c.status === 'active').reduce((s, c) => s + c.valueCents, 0), + } + + return NextResponse.json({ contracts: enriched, stats }) +} diff --git a/src/app/api/aos/expenses/[id]/convert/route.ts b/src/app/api/aos/expenses/[id]/convert/route.ts new file mode 100644 index 0000000..dcc600e --- /dev/null +++ b/src/app/api/aos/expenses/[id]/convert/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// POST /api/aos/expenses/[id]/convert — convert a billable expense into an invoice +// (adapted from PerfEx "Converting Billable Expense to Invoice") +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const expense = await db.expense.findUnique({ where: { id } }) + if (!expense) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (!expense.billable) return NextResponse.json({ error: 'expense not billable' }, { status: 409 }) + if (expense.status === 'invoiced') return NextResponse.json({ error: 'already invoiced' }, { status: 409 }) + if (!expense.companyId) return NextResponse.json({ error: 'no customer linked' }, { status: 409 }) + + // compute re-bill amount with markup + const markupMultiplier = 1 + expense.markupPct / 100 + const netCents = Math.round(expense.amountCents * markupMultiplier) + const taxBp = expense.taxBp + const grossCents = Math.round(netCents * (1 + taxBp / 10000)) + + // gapless invoice number + const year = new Date().getFullYear() + const count = await db.invoice.count({ where: { number: { startsWith: `RE-${year}-` } } }) + const number = `RE-${year}-${String(count + 1).padStart(4, '0')}` + + const dueDate = new Date() + dueDate.setDate(dueDate.getDate() + 14) + + const invoice = await db.invoice.create({ + data: { + number, + companyId: expense.companyId, + status: 'draft', + type: 'final', + issueDate: new Date(), + dueDate, + netCents, + taxBp, + grossCents, + zugferd: false, + xrechnung: false, + datevExported: false, + projectId: expense.projectId, + }, + }) + + // one line item describing the expense + const desc = expense.markupPct > 0 + ? `${expense.description} (Fremdleistung +${expense.markupPct}% Service-Pauschale)` + : expense.description + + await db.invoiceLine.create({ + data: { + invoiceId: invoice.id, + description: desc, + quantity: 1, + unitCents: netCents, + totalCents: netCents, + }, + }) + + // mark expense as invoiced + const updated = await db.expense.update({ + where: { id }, + data: { status: 'invoiced', convertedInvoiceId: invoice.id }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'expense.convert:invoice', + entity: 'Expense', + entityId: id, + before: JSON.stringify({ status: expense.status, amountCents: expense.amountCents }), + after: JSON.stringify({ status: 'invoiced', invoiceId: invoice.id, invoiceNumber: number, netCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ expense: updated, invoice }) +} diff --git a/src/app/api/aos/expenses/route.ts b/src/app/api/aos/expenses/route.ts new file mode 100644 index 0000000..db03a73 --- /dev/null +++ b/src/app/api/aos/expenses/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/expenses — expenses list with company/project, stats, reports +export async function GET() { + const expenses = await db.expense.findMany({ orderBy: { date: 'desc' } }) + + const companyIds = [...new Set(expenses.map((e) => e.companyId).filter(Boolean) as string[])] + const projectIds = [...new Set(expenses.map((e) => e.projectId).filter(Boolean) as string[])] + const [companies, projects] = await Promise.all([ + db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }), + db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, name: true } }), + ]) + const companyMap = new Map(companies.map((c) => [c.id, c.name])) + const projectMap = new Map(projects.map((p) => [p.id, p.name])) + + const enriched = expenses.map((e) => ({ + ...e, + companyName: e.companyId ? companyMap.get(e.companyId) ?? null : null, + projectName: e.projectId ? projectMap.get(e.projectId) ?? null : null, + })) + + const stats = { + total: expenses.length, + billable: expenses.filter((e) => e.billable).length, + recurring: expenses.filter((e) => e.recurring).length, + billableCents: expenses.filter((e) => e.billable && e.status !== 'invoiced').reduce((s, e) => s + e.grossCents, 0), + totalGrossCents: expenses.reduce((s, e) => s + e.grossCents, 0), + invoiced: expenses.filter((e) => e.status === 'invoiced').length, + pendingApproval: expenses.filter((e) => e.status === 'recorded').length, + datevExported: expenses.filter((e) => e.datevExported).length, + } + + // expense report: by category + const byCategory: Record = {} + for (const e of expenses) { + if (!byCategory[e.categoryId]) byCategory[e.categoryId] = { count: 0, grossCents: 0 } + byCategory[e.categoryId].count++ + byCategory[e.categoryId].grossCents += e.grossCents + } + + // monthly (last 6 months) + const now = new Date() + const monthly: { 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 = expenses + .filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear()) + .reduce((s, e) => s + e.grossCents, 0) + monthly.push({ label, cents }) + } + + return NextResponse.json({ expenses: enriched, stats, report: { byCategory, monthly } }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 7644eb8..50ebab7 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -15,6 +15,8 @@ export async function POST() { try { // wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order) const { db } = await import('@/lib/db') + await db.expense.deleteMany() + await db.contract.deleteMany() await db.invoiceLine.deleteMany() await db.invoice.deleteMany() await db.asset.deleteMany() diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 5ee7d19..3ab15bd 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -12,6 +12,8 @@ import { CrmView } from './views/crm' import { LeadsView } from './views/leads' import { ProjectsView } from './views/projects' import { LicensesView } from './views/licenses' +import { ContractsView } from './views/contracts' +import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' import { AuditView } from './views/audit' import { SettingsView } from './views/settings' @@ -45,6 +47,8 @@ export function AppShell() { {view === 'leads' && } {view === 'projects' && } {view === 'licenses' && } + {view === 'contracts' && } + {view === 'expenses' && } {view === 'finance' && } {view === 'audit' && } {view === 'settings' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 0e931bf..5295223 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -14,6 +14,8 @@ import { ScrollText, Search as SearchIcon, ArrowRight, + FileSignature, + Wallet, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -33,6 +35,8 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' }, { id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' }, { id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, + { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, + { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' }, { id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 608b8b5..23866df 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -12,6 +12,8 @@ import { Search, Bot, ScrollText, + FileSignature, + Wallet, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { cn } from '@/lib/utils' @@ -41,6 +43,8 @@ const NAV: { group: 'Betrieb', items: [ { id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, + { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, + { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, ], diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index b630edd..cc3a77b 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -21,6 +21,8 @@ const TITLES: Record = { leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' }, projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, + contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, + expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' }, diff --git a/src/components/aos/views/contracts.tsx b/src/components/aos/views/contracts.tsx new file mode 100644 index 0000000..be68513 --- /dev/null +++ b/src/components/aos/views/contracts.tsx @@ -0,0 +1,340 @@ +'use client' + +import { useState } from 'react' +import { + FileSignature, + Plus, + CalendarClock, + ShieldCheck, + PenLine, + FileText, + AlertTriangle, + Loader2, + X, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { PageHeader } from '../primitives' +import { formatEuro, formatDate, daysUntil } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +interface Contract { + id: string + number: string + companyId: string + companyName: string + type: string + title: string + status: string + valueCents: number + startDate: string | null + endDate: string | null + signedAt: string | null + signedBy: string | null + signedIp: string | null + signToken: string | null + notes: string | null + createdAt: string +} +interface ContractsData { + contracts: Contract[] + stats: { + total: number + active: number + draft: number + sent: number + signed: number + expired: number + expiringSoon: number + totalValueCents: number + } +} + +const TYPE_META: Record = { + 'werkvertrag': { label: 'Werkvertrag', desc: '§ 631 BGB — Erfolgsschuld, Abnahme, Gewährleistung' }, + 'dienstvertrag': { label: 'Dienstvertrag', desc: '§ 611 BGB — Tätigkeits schuld, kein Erfolgsversprechen' }, + 'wartung-sla': { label: 'Wartungs-/SLA-Vertrag', desc: 'Service-Level-Agreement, Reaktionszeiten' }, + 'avv': { label: 'AVV (Art. 28 DSGVO)', desc: 'Auftragsverarbeitungsvertrag — DSGVO-Pflicht bei Datenverarbeitung' }, + 'nda': { label: 'NDA', desc: 'Geheimhaltungsvereinbarung' }, + 'dpa': { label: 'DPA', desc: 'Data Processing Agreement' }, +} + +const STATUS_META: Record = { + draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' }, + sent: { label: 'Versendet', cls: 'bg-foreground/50 text-background' }, + signed: { label: 'Unterzeichnet', cls: 'bg-foreground/70 text-background' }, + active: { label: 'Aktiv', cls: 'bg-foreground text-background' }, + expired: { label: 'Abgelaufen', cls: 'bg-muted text-muted-foreground' }, + terminated: { label: 'Gekündigt', cls: 'bg-muted text-muted-foreground' }, +} + +export function ContractsView() { + const { data, loading, refetch } = useFetch('/api/aos/contracts') + const [selected, setSelected] = useState(null) + const [signing, setSigning] = useState(false) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { contracts, stats } = data + + const sign = async (contract: Contract) => { + setSigning(true) + const t = toast.loading('E-Signatur wird erzeugt…') + try { + const r = await fetch(`/api/aos/contracts/${contract.id}/sign`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ signedBy: contract.companyName + ' (Portal)' }), + }) + if (!r.ok) throw new Error('sign failed') + toast.success('Vertrag unterzeichnet (Signatur-Token generiert)', { id: t }) + setSelected(null) + refetch() + } catch { + toast.error('Signatur fehlgeschlagen', { id: t }) + } finally { + setSigning(false) + } + } + + return ( +
+ + + Vertrag + + } + /> + +
+ +
+

Aktive Verträge

+ +
+

{stats.active}

+

{formatEuro(stats.totalValueCents)} Volumen

+
+ +
+

Entwürfe / Versendet

+ +
+

{stats.draft + stats.sent}

+
+ +
+

Läuft bald ab

+ +
+

{stats.expiringSoon}

+

≤ 30 Tage

+
+ +
+

Unterzeichnet

+ +
+

{stats.signed}

+
+
+ + {/* DSGVO notice */} +
+ +
+

AVV-Pflicht bei Auftragsverarbeitung (Art. 28 DSGVO)

+

+ Sobald personenbezogene Daten im Auftrag verarbeitet werden (z. B. Hosting, CRM, Newsletter), ist ein Auftragsverarbeitungsvertrag zwingend. Die Verträge werden hier zentral verwaltet und bei Ablauf erneuert. +

+
+
+ + + + Verträge ({contracts.length}) + + +
+ + + + + + + + + + + + + + {contracts.map((c) => { + const st = STATUS_META[c.status] ?? STATUS_META.draft + const tm = TYPE_META[c.type] ?? { label: c.type, desc: '' } + const expiring = c.endDate && c.status === 'active' && daysUntil(c.endDate) <= 30 && daysUntil(c.endDate) >= 0 + const expired = c.status === 'expired' || (c.endDate && daysUntil(c.endDate) < 0 && c.status === 'active') + return ( + setSelected(c)} + > + + + + + + + + + ) + })} + +
Nr.TypTitelKundeWertZeitraumStatus
{c.number} + {tm.label} + {c.title}{c.companyName}{c.valueCents ? formatEuro(c.valueCents) : '—'} + {c.startDate ? ( + + {formatDate(c.startDate)} + {c.endDate && ` → ${formatDate(c.endDate)}`} + {expiring && ({daysUntil(c.endDate!)} d)} + + ) : '—'} + + + {expiring && c.status === 'active' && } + {st.label} + +
+
+
+
+ + {/* Contract detail dialog */} + !o && setSelected(null)}> + + {selected && (() => { + const tm = TYPE_META[selected.type] ?? { label: selected.type, desc: '' } + const st = STATUS_META[selected.status] ?? STATUS_META.draft + const canSign = selected.status === 'draft' || selected.status === 'sent' + return ( + <> + +
+
+ {selected.number} + {selected.title} +
+ {st.label} +
+
+ +
+ {/* Type */} +
+
+ + {tm.label} +
+

{tm.desc}

+
+ + {/* Meta */} +
+ + + + + {selected.endDate && selected.status === 'active' && ( + + )} +
+ + {/* Signature block */} +
+
+ + E-Signatur +
+ {selected.signedAt ? ( +
+ + + + +
+ ) : ( +

+ Noch nicht unterzeichnet. Versand an Kunden-Portal zur E-Signatur möglich. +

+ )} +
+ + {selected.notes && ( +
+

Notizen

+

{selected.notes}

+
+ )} +
+ + + + {canSign && ( + + )} + + + ) + })()} +
+
+
+ ) +} + +function Meta({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ) +} + +function SigRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label}: + {value} +
+ ) +} diff --git a/src/components/aos/views/expenses.tsx b/src/components/aos/views/expenses.tsx new file mode 100644 index 0000000..d524e4c --- /dev/null +++ b/src/components/aos/views/expenses.tsx @@ -0,0 +1,328 @@ +'use client' + +import { useState } from 'react' +import { + Wallet, + Plus, + Repeat, + FileCheck2, + ArrowRight, + Loader2, + Receipt, + TrendingUp, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { PageHeader } from '../primitives' +import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts' + +interface Expense { + id: string + number: string + categoryId: string + vendor: string | null + description: string + amountCents: number + taxBp: number + grossCents: number + date: string + billable: boolean + markupPct: number + recurring: boolean + recurringInterval: string | null + datevExported: boolean + status: string + companyName: string | null + projectName: string | null +} +interface ExpensesData { + expenses: Expense[] + stats: { + total: number + billable: number + recurring: number + billableCents: number + totalGrossCents: number + invoiced: number + pendingApproval: number + datevExported: number + } + report: { + byCategory: Record + monthly: { label: string; cents: number }[] + } +} + +const CATEGORY_LABELS: Record = { + reise: 'Reise', + software: 'Software', + hardware: 'Hardware', + fremdleistung: 'Fremdleistung', + buero: 'Büro', + marketing: 'Marketing', +} + +const STATUS_META: Record = { + recorded: { label: 'Erfasst', cls: 'bg-muted text-foreground' }, + approved: { label: 'Freigegeben', cls: 'bg-foreground/50 text-background' }, + invoiced: { label: 'Berechnet', cls: 'bg-foreground text-background' }, + rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' }, +} + +export function ExpensesView() { + const { data, loading, refetch } = useFetch('/api/aos/expenses') + const [converting, setConverting] = useState(null) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { expenses, stats, report } = data + + const convert = async (e: Expense) => { + setConverting(e.id) + const t = toast.loading('Ausgabe wird in Rechnung umgewandelt…') + try { + const r = await fetch(`/api/aos/expenses/${e.id}/convert`, { method: 'POST' }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || 'convert failed') + } + const data = await r.json() + toast.success(`Rechnung ${data.invoice.number} erstellt (${formatEuro(data.invoice.grossCents)})`, { id: t }) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Umwandlung fehlgeschlagen', { id: t }) + } finally { + setConverting(null) + } + } + + const categoryData = Object.entries(report.byCategory) + .map(([cat, v]) => ({ category: CATEGORY_LABELS[cat] ?? cat, cents: v.grossCents, count: v.count })) + .sort((a, b) => b.cents - a.cents) + const maxCat = Math.max(...categoryData.map((c) => c.cents), 1) + + return ( +
+ + + Ausgabe + + } + /> + +
+ +
+

Gesamtausgaben (brutto)

+ +
+

{formatEuroCompact(stats.totalGrossCents)}

+
+ +
+

Belastbar (offen)

+ +
+

{formatEuroCompact(stats.billableCents)}

+

{stats.billable - stats.invoiced} Posten offen

+
+ +
+

Wiederkehrend

+ +
+

{stats.recurring}

+
+ +
+

DATEV exportiert

+ +
+

{stats.datevExported}

+

{stats.pendingApproval} warten auf Freigabe

+
+
+ + + + Ausgaben + Ausgabenreport + + + + + + Ausgaben ({expenses.length}) + + +
+ + + + + + + + + + + + + + + {expenses.map((e) => { + const st = STATUS_META[e.status] ?? STATUS_META.recorded + const canConvert = e.billable && e.status !== 'invoiced' && e.companyName + return ( + + + + + + + + + + + ) + })} + +
Nr.KategorieBeschreibungLieferantBezugBruttoStatusAktion
{e.number} + {CATEGORY_LABELS[e.categoryId] ?? e.categoryId} + {e.recurring && ( + + {e.recurringInterval} + + )} + {e.description}{e.vendor ?? '—'} + {e.companyName &&
{e.companyName}
} + {e.projectName &&
→ {e.projectName}
} + {e.billable && +{e.markupPct}%} +
{formatEuro(e.grossCents)} + {st.label} + + {canConvert ? ( + + ) : e.status === 'invoiced' ? ( + + Berechnet + + ) : ( + — + )} +
+
+
+
+
+ + +
+ {/* By category */} + + + + + Nach Kategorie + + + +
+ {categoryData.map((c) => ( +
+
+ {c.category} ({c.count}) + {formatEuro(c.cents)} +
+
+
+
+
+ ))} +
+ + + + {/* Monthly trend */} + + + Monatlicher Verlauf (6 Monate, brutto) + + + + + + formatEuroCompact(v).replace(' €', '')} width={50} /> + [formatEuro(v), 'Brutto']} + /> + + {report.monthly.map((_, i) => ( + + ))} + + + + + +
+ + {/* DATEV info */} + + +
+
+ +
+
+

DATEV-Export für Steuerberater

+

+ Freigegebene Ausgaben werden als DATEV-kompatible CSV exportiert (Buchungsstapel ERP). GoBD-konform mit Beleg-Hash (SHA-256, content-addressed receipts). Eigenbelege und durchlaufende Posten getrennt. +

+
+ CSV (DATEV Format v5) + Beleg-Hash: SHA-256 + Eigenbeleg / Fremdbeleg +
+
+ +
+
+
+ + +
+ ) +} diff --git a/src/components/aos/views/settings.tsx b/src/components/aos/views/settings.tsx index 494363e..ef84643 100644 --- a/src/components/aos/views/settings.tsx +++ b/src/components/aos/views/settings.tsx @@ -38,6 +38,8 @@ const MODULE_META: Record