1bc42b25-8a92-4b1e-9e45-a4dba3d1ac57

This commit is contained in:
Z User committed 2026-10-26 14:41:13 +00:00
1 parent 5855f699f3
commit d4c3b0f5ca
21 files changed
+1140

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+103
View File
@@ -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
BIN
View File
Binary file not shown.
+62
View File
@@ -275,3 +275,65 @@ model InvoiceLine {
unitCents Int unitCents Int
totalCents 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
}
@@ -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 })
}
+35
View File
@@ -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 })
}
@@ -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 })
}
+57
View File
@@ -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<string, { count: number; grossCents: number }> = {}
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 } })
}
+2
View File
@@ -15,6 +15,8 @@ export async function POST() {
try { try {
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order) // wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
const { db } = await import('@/lib/db') const { db } = await import('@/lib/db')
await db.expense.deleteMany()
await db.contract.deleteMany()
await db.invoiceLine.deleteMany() await db.invoiceLine.deleteMany()
await db.invoice.deleteMany() await db.invoice.deleteMany()
await db.asset.deleteMany() await db.asset.deleteMany()
+4
View File
@@ -12,6 +12,8 @@ import { CrmView } from './views/crm'
import { LeadsView } from './views/leads' import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects' import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses' import { LicensesView } from './views/licenses'
import { ContractsView } from './views/contracts'
import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance' import { FinanceView } from './views/finance'
import { AuditView } from './views/audit' import { AuditView } from './views/audit'
import { SettingsView } from './views/settings' import { SettingsView } from './views/settings'
@@ -45,6 +47,8 @@ export function AppShell() {
{view === 'leads' && <LeadsView />} {view === 'leads' && <LeadsView />}
{view === 'projects' && <ProjectsView />} {view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />} {view === 'licenses' && <LicensesView />}
{view === 'contracts' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
{view === 'finance' && <FinanceView />} {view === 'finance' && <FinanceView />}
{view === 'audit' && <AuditView />} {view === 'audit' && <AuditView />}
{view === 'settings' && <SettingsView />} {view === 'settings' && <SettingsView />}
+4
View File
@@ -14,6 +14,8 @@ import {
ScrollText, ScrollText,
Search as SearchIcon, Search as SearchIcon,
ArrowRight, ArrowRight,
FileSignature,
Wallet,
} from 'lucide-react' } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos' import { useAos, type ViewId } from '@/store/aos'
import { 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: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' }, { id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' }, { 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: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' }, { id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
+4
View File
@@ -12,6 +12,8 @@ import {
Search, Search,
Bot, Bot,
ScrollText, ScrollText,
FileSignature,
Wallet,
} from 'lucide-react' } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos' import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -41,6 +43,8 @@ const NAV: {
group: 'Betrieb', group: 'Betrieb',
items: [ items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' }, { 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: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
], ],
+2
View File
@@ -21,6 +21,8 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' }, leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' }, projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, 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' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' },
+340
View File
@@ -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<string, { label: string; desc: string }> = {
'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<string, { label: string; cls: string }> = {
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<ContractsData>('/api/aos/contracts')
const [selected, setSelected] = useState<Contract | null>(null)
const [signing, setSigning] = useState(false)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-40" />
</div>
)
}
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 (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Verträge"
description="Vertragsarten: Werkvertrag, Dienstvertrag, Wartung/SLA, AVV (Art. 28 DSGVO), NDA. E-Signatur-Hook, Ablauf-Tracking, Verlängerung."
actions={
<Button size="sm">
<Plus className="size-4" />
Vertrag
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktive Verträge</p>
<FileSignature className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.active}</p>
<p className="text-[10px] text-muted-foreground">{formatEuro(stats.totalValueCents)} Volumen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Entwürfe / Versendet</p>
<FileText className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.draft + stats.sent}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Läuft bald ab</p>
<CalendarClock className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon}</p>
<p className="text-[10px] text-muted-foreground">≤ 30 Tage</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Unterzeichnet</p>
<PenLine className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.signed}</p>
</Card>
</div>
{/* DSGVO notice */}
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">AVV-Pflicht bei Auftragsverarbeitung (Art. 28 DSGVO)</p>
<p className="mt-0.5 text-muted-foreground">
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.
</p>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Verträge ({contracts.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Titel</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium text-right">Wert</th>
<th className="px-4 py-2 font-medium">Zeitraum</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{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 (
<tr
key={c.id}
className="cursor-pointer transition hover:bg-muted/40"
onClick={() => setSelected(c)}
>
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{c.number}</code></td>
<td className="px-4 py-2.5">
<Badge variant="outline" className="text-[10px]">{tm.label}</Badge>
</td>
<td className="px-4 py-2.5 text-xs font-medium">{c.title}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{c.companyName}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{c.valueCents ? formatEuro(c.valueCents) : '—'}</td>
<td className="px-4 py-2.5 text-xs">
{c.startDate ? (
<span className={cn(expiring && 'text-foreground', expired && 'text-muted-foreground')}>
{formatDate(c.startDate)}
{c.endDate && ` → ${formatDate(c.endDate)}`}
{expiring && <span className="ml-1 text-[10px]">({daysUntil(c.endDate!)} d)</span>}
</span>
) : '—'}
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('gap-1 text-[10px]', st.cls)}>
{expiring && c.status === 'active' && <AlertTriangle className="size-2.5" />}
{st.label}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Contract detail dialog */}
<Dialog open={!!selected} onOpenChange={(o) => !o && setSelected(null)}>
<DialogContent className="sm:max-w-2xl">
{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 (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="font-mono">{selected.number}</DialogTitle>
<DialogDescription className="mt-1">{selected.title}</DialogDescription>
</div>
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Type */}
<div className="rounded-md border border-border p-3">
<div className="flex items-center gap-2">
<FileSignature className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{tm.label}</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{tm.desc}</p>
</div>
{/* Meta */}
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-3">
<Meta label="Kunde" value={selected.companyName} />
<Meta label="Wert" value={selected.valueCents ? formatEuro(selected.valueCents) : '—'} />
<Meta label="Vertragsbeginn" value={selected.startDate ? formatDate(selected.startDate) : '—'} />
<Meta label="Vertragsende" value={selected.endDate ? formatDate(selected.endDate) : 'Unbefristet'} />
{selected.endDate && selected.status === 'active' && (
<Meta label="Ablauf in" value={`${daysUntil(selected.endDate)} Tagen`} />
)}
</div>
{/* Signature block */}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<PenLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide">E-Signatur</span>
</div>
{selected.signedAt ? (
<div className="mt-2 space-y-1 text-[11px]">
<SigRow label="Unterzeichnet am" value={formatDate(selected.signedAt)} />
<SigRow label="Unterzeichner" value={selected.signedBy ?? '—'} />
<SigRow label="IP (Portal)" value={selected.signedIp ?? '—'} mono />
<SigRow label="Signatur-Token" value={selected.signToken ?? '—'} mono />
</div>
) : (
<p className="mt-2 text-[11px] text-muted-foreground">
Noch nicht unterzeichnet. Versand an Kunden-Portal zur E-Signatur möglich.
</p>
)}
</div>
{selected.notes && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Notizen</p>
<p className="mt-1 text-xs">{selected.notes}</p>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setSelected(null)}>Schließen</Button>
{canSign && (
<Button onClick={() => sign(selected)} disabled={signing} className="gap-1.5">
{signing ? <Loader2 className="size-3.5 animate-spin" /> : <PenLine className="size-3.5" />}
E-Signatur anfordern
</Button>
)}
</DialogFooter>
</>
)
})()}
</DialogContent>
</Dialog>
</div>
)
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-0.5 text-xs font-medium">{value}</p>
</div>
)
}
function SigRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex gap-2">
<span className="shrink-0 text-muted-foreground">{label}:</span>
<span className={cn('font-medium', mono && 'font-mono text-[10px]')}>{value}</span>
</div>
)
}
+328
View File
@@ -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<string, { count: number; grossCents: number }>
monthly: { label: string; cents: number }[]
}
}
const CATEGORY_LABELS: Record<string, string> = {
reise: 'Reise',
software: 'Software',
hardware: 'Hardware',
fremdleistung: 'Fremdleistung',
buero: 'Büro',
marketing: 'Marketing',
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
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<ExpensesData>('/api/aos/expenses')
const [converting, setConverting] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-40" />
</div>
)
}
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 (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Ausgaben"
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
actions={
<Button size="sm">
<Plus className="size-4" />
Ausgabe
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Gesamtausgaben (brutto)</p>
<Wallet className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalGrossCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Belastbar (offen)</p>
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.billableCents)}</p>
<p className="text-[10px] text-muted-foreground">{stats.billable - stats.invoiced} Posten offen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Wiederkehrend</p>
<Repeat className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.recurring}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">DATEV exportiert</p>
<FileCheck2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.datevExported}</p>
<p className="text-[10px] text-muted-foreground">{stats.pendingApproval} warten auf Freigabe</p>
</Card>
</div>
<Tabs defaultValue="list">
<TabsList>
<TabsTrigger value="list">Ausgaben</TabsTrigger>
<TabsTrigger value="report">Ausgabenreport</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ausgaben ({expenses.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<th className="px-4 py-2 font-medium">Beschreibung</th>
<th className="px-4 py-2 font-medium">Lieferant</th>
<th className="px-4 py-2 font-medium">Bezug</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{expenses.map((e) => {
const st = STATUS_META[e.status] ?? STATUS_META.recorded
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
return (
<tr key={e.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
{e.recurring && (
<Badge variant="outline" className="ml-1 gap-0.5 text-[9px]">
<Repeat className="size-2.5" />{e.recurringInterval}
</Badge>
)}
</td>
<td className="px-4 py-2.5 text-xs">{e.description}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.vendor ?? '—'}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{e.companyName && <div>{e.companyName}</div>}
{e.projectName && <div className="text-[10px]">→ {e.projectName}</div>}
{e.billable && <Badge variant="outline" className="mt-0.5 text-[9px]">+{e.markupPct}%</Badge>}
</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.grossCents)}</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</td>
<td className="px-4 py-2.5">
{canConvert ? (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-[11px]"
onClick={() => convert(e)}
disabled={converting === e.id}
>
{converting === e.id ? <Loader2 className="size-3 animate-spin" /> : <ArrowRight className="size-3" />}
Zur Rechnung
</Button>
) : e.status === 'invoiced' ? (
<Badge variant="outline" className="gap-1 text-[9px]">
<Receipt className="size-2.5" />Berechnet
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="report" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* By category */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<TrendingUp className="size-3.5 text-muted-foreground" />
Nach Kategorie
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{categoryData.map((c) => (
<div key={c.category}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{c.category} <span className="text-muted-foreground">({c.count})</span></span>
<span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded bg-muted">
<div className="h-full rounded bg-foreground transition-all duration-500" style={{ width: `${(c.cents / maxCat) * 100}%` }} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Monthly trend */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Monatlicher Verlauf (6 Monate, brutto)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={report.monthly} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [formatEuro(v), 'Brutto']}
/>
<Bar dataKey="cents" radius={[3, 3, 0, 0]}>
{report.monthly.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / report.monthly.length) * 0.6} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* DATEV info */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<FileCheck2 className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">DATEV-Export für Steuerberater</p>
<p className="mt-1 text-xs text-muted-foreground">
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.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px]">CSV (DATEV Format v5)</Badge>
<Badge variant="outline" className="text-[10px]">Beleg-Hash: SHA-256</Badge>
<Badge variant="outline" className="text-[10px]">Eigenbeleg / Fremdbeleg</Badge>
</div>
</div>
<Button size="sm" variant="outline" className="gap-1.5">
<FileCheck2 className="size-3.5" />
Export
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+2
View File
@@ -38,6 +38,8 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
leads: { label: 'Leads Finder', desc: 'Evidenzbasierte Website-Audits + LLM-Erklärung.', phase: 'Phase 2' }, leads: { label: 'Leads Finder', desc: 'Evidenzbasierte Website-Audits + LLM-Erklärung.', phase: 'Phase 2' },
projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' }, projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' },
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' }, licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
contracts: { label: 'Verträge', desc: 'Werkvertrag, AVV (Art. 28 DSGVO), NDA, SLA mit E-Signatur.', phase: 'Phase 5' },
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' }, 'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' }, finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' }, support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
+64
View File
@@ -32,6 +32,8 @@ async function main() {
{ id: 'leads', enabled: true, version: '1.0.0' }, { id: 'leads', enabled: true, version: '1.0.0' },
{ id: 'projects', enabled: true, version: '1.0.0' }, { id: 'projects', enabled: true, version: '1.0.0' },
{ id: 'licenses', enabled: true, version: '1.0.0' }, { id: 'licenses', enabled: true, version: '1.0.0' },
{ id: 'contracts', enabled: true, version: '1.0.0' },
{ id: 'expenses', enabled: true, version: '1.0.0' },
{ id: 'sales-docs', enabled: false, version: '0.9.0' }, { id: 'sales-docs', enabled: false, version: '0.9.0' },
{ id: 'finance', enabled: true, version: '1.0.0' }, { id: 'finance', enabled: true, version: '1.0.0' },
{ id: 'support', enabled: false, version: '0.5.0' }, { id: 'support', enabled: false, version: '0.5.0' },
@@ -399,6 +401,68 @@ async function main() {
await db.auditLog.create({ data: { ...a, hash: 'sha256:' + Math.random().toString(36).slice(2) } }) await db.auditLog.create({ data: { ...a, hash: 'sha256:' + Math.random().toString(36).slice(2) } })
} }
// --- Contracts / Verträge (adapted from PerfEx, Germanized) --------------
const contracts = [
{ number: 'VER-2026-0007', company: 'Nordwind Logistik GmbH', type: 'werkvertrag', title: 'Werkvertrag Intranet-Portal', status: 'active', valueCents: CENTS(18000), startDate: daysFromNow(-20), endDate: daysFromNow(60), projectId: projectMap['Intranet-Portal Q4'] },
{ number: 'VER-2026-0006', company: 'HanseMöbel Manufaktur GmbH', type: 'wartung-sla', title: 'Wartungs- und SLA-Vertrag (12 Monate)', status: 'active', valueCents: CENTS(6600), startDate: daysFromNow(-90), endDate: daysFromNow(275) },
{ number: 'AVV-2026-0003', company: 'Nordwind Logistik GmbH', type: 'avv', title: 'Auftragsverarbeitungsvertrag (AVV) Art. 28 DSGVO', status: 'signed', valueCents: 0, startDate: daysFromNow(-20), endDate: null },
{ number: 'AVV-2026-0004', company: 'HanseMöbel Manufaktur GmbH', type: 'avv', title: 'AVV Auftragsverarbeitung WooCommerce', status: 'signed', valueCents: 0, startDate: daysFromNow(-90), endDate: null },
{ number: 'NDA-2026-0011', company: 'TechStart UG (haftungsbeschränkt)', type: 'nda', title: 'Geheimhaltungsvereinbarung (NDA) MVP-Entwicklung', status: 'active', valueCents: 0, startDate: daysFromNow(-30), endDate: daysFromNow(335) },
{ number: 'VER-2026-0005', company: 'Klinik am Park MV GmbH', type: 'dienstvertrag', title: 'Dienstvertrag SEO-Beratung', status: 'active', valueCents: CENTS(12500), startDate: daysFromNow(-15), endDate: daysFromNow(15), projectId: projectMap['SEO-Optimierung & BFGG-Check'] },
{ number: 'VER-2026-0008', company: 'Ostsee Hotel & Spa AG', type: 'werkvertrag', title: 'Werkvertrag Website-Redesign', status: 'draft', valueCents: CENTS(24500), startDate: null, endDate: null },
{ number: 'VER-2026-0002', company: 'Bauer Handwerk GmbH', type: 'werkvertrag', title: 'Werkvertrag Corporate Website', status: 'sent', valueCents: CENTS(9800), startDate: null, endDate: null, dealId: undefined },
{ number: 'NDA-2025-0044', company: 'Bauer Handwerk GmbH', type: 'nda', title: 'NDA Projektvorbereitung', status: 'signed', valueCents: 0, startDate: daysFromNow(-5), endDate: daysFromNow(360) },
{ number: 'VER-2025-0091', company: 'Müller Bäckerei e.K.', type: 'wartung-sla', title: 'Wartungsvertrag WooCommerce (alt)', status: 'expired', valueCents: CENTS(2400), startDate: daysFromNow(-400), endDate: daysFromNow(-35) },
]
for (const c of contracts) {
const { company, ...rest } = c
await db.contract.create({
data: {
...rest,
companyId: companyMap[company],
signToken: rest.status === 'sent' ? 'sig_' + Math.random().toString(36).slice(2, 12) : null,
signedAt: (rest.status === 'signed' || rest.status === 'active') ? daysFromNow(-3) : null,
signedBy: (rest.status === 'signed' || rest.status === 'active') ? 'Klaus Bauer / Geschäftsführung' : null,
signedIp: (rest.status === 'signed' || rest.status === 'active') ? '91.64.x.x (portal)' : null,
},
})
}
// --- Expenses / Ausgaben ------------------------------------------------
const expenses = [
{ number: 'AUS-2026-0034', categoryId: 'fremdleistung', vendor: 'Cloudflare Inc.', description: 'Domain-Routing & CDN Q4', amountCents: CENTS(89), date: daysFromNow(-2), company: 'TechStart UG (haftungsbeschränkt)', billable: true, markupPct: 30, status: 'recorded' },
{ number: 'AUS-2026-0033', categoryId: 'software', vendor: 'Sectigo', description: 'SSL Wildcard-Zertifikat', amountCents: CENTS(180), date: daysFromNow(-3), company: 'HanseMöbel Manufaktur GmbH', billable: true, markupPct: 30, status: 'approved', recurring: true, recurringInterval: 'yearly' },
{ number: 'AUS-2026-0032', categoryId: 'reise', vendor: 'Deutsche Bahn', description: 'Hamburg → Berlin (Projekt-Kickoff TechStart)', amountCents: CENTS(142), date: daysFromNow(-4), company: 'TechStart UG (haftungsbeschränkt)', projectId: 'SaaS MVP — Auth & Billing', billable: true, markupPct: 0, status: 'recorded' },
{ number: 'AUS-2026-0031', categoryId: 'software', vendor: 'JetBrains', description: 'All Products Pack (Team-Lizenz 5 Seats)', amountCents: CENTS(899), date: daysFromNow(-8), billable: false, status: 'approved', recurring: true, recurringInterval: 'yearly' },
{ number: 'AUS-2026-0030', categoryId: 'fremdleistung', vendor: 'Raidboxes', description: 'Managed WordPress Hosting (Bauer Handwerk)', amountCents: CENTS(348), date: daysFromNow(-10), company: 'Bauer Handwerk GmbH', billable: true, markupPct: 60, status: 'invoiced', recurring: true, recurringInterval: 'yearly' },
{ number: 'AUS-2026-0029', categoryId: 'hardware', vendor: 'Alternate', description: 'Entwicklungs-Laptop (ThinkPad T14)', amountCents: CENTS(1649), date: daysFromNow(-12), billable: false, status: 'approved' },
{ number: 'AUS-2026-0028', categoryId: 'marketing', vendor: 'Google Ads', description: 'AdWords-Kampagne "Webdesign Hamburg"', amountCents: CENTS(450), date: daysFromNow(-15), billable: false, status: 'approved' },
{ number: 'AUS-2026-0027', categoryId: 'buero', vendor: 'Otto Office', description: 'Büromaterial Q4', amountCents: CENTS(67), date: daysFromNow(-18), billable: false, status: 'approved' },
{ number: 'AUS-2026-0026', categoryId: 'fremdleistung', vendor: 'Ionos', description: 'Hosting & Domain nordwind-logistik.de', amountCents: CENTS(96), date: daysFromNow(-20), company: 'Nordwind Logistik GmbH', billable: true, markupPct: 50, status: 'invoiced', recurring: true, recurringInterval: 'yearly' },
{ number: 'AUS-2026-0025', categoryId: 'reise', vendor: 'Hotel am Markt (Lübeck)', description: 'Übernachtung Kunden-Termin Bauer Handwerk', amountCents: CENTS(118), date: daysFromNow(-22), company: 'Bauer Handwerk GmbH', billable: true, markupPct: 0, status: 'recorded' },
{ number: 'AUS-2026-0024', categoryId: 'software', vendor: 'GitHub', description: 'GitHub Team (10 Seats) monatlich', amountCents: CENTS(44), date: daysFromNow(-25), billable: false, status: 'approved', recurring: true, recurringInterval: 'monthly' },
{ number: 'AUS-2026-0023', categoryId: 'fremdleistung', vendor: 'OnTheGoSystems', description: 'WPML Multilingual CMS (HanseMöbel)', amountCents: CENTS(99), date: daysFromNow(-30), company: 'HanseMöbel Manufaktur GmbH', billable: true, markupPct: 0, status: 'invoiced' },
]
for (const e of expenses) {
const { company, projectId, ...rest } = e
const taxBp = 1900
const grossCents = Math.round(rest.amountCents * (1 + taxBp / 10000))
await db.expense.create({
data: {
...rest,
taxBp,
grossCents,
companyId: company ? companyMap[company] : null,
projectId: projectId ? projectMap[projectId] : null,
},
})
}
// audit entries for the new modules
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'contract.sign', entity: 'Contract', entityId: 'seed', after: '{"status":"active","type":"werkvertrag"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'expense.record', entity: 'Expense', entityId: 'seed', after: '{"category":"reise","billable":true}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'expense.convert:invoice', entity: 'Expense', entityId: 'seed', after: '{"status":"invoiced"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
console.log('Seed complete.') console.log('Seed complete.')
} }
+2
View File
@@ -9,6 +9,8 @@ export type ViewId =
| 'leads' | 'leads'
| 'projects' | 'projects'
| 'licenses' | 'licenses'
| 'contracts'
| 'expenses'
| 'finance' | 'finance'
| 'audit' | 'audit'
| 'settings' | 'settings'