1bc42b25-8a92-4b1e-9e45-a4dba3d1ac57
This commit is contained in:
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
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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' && <LeadsView />}
|
||||
{view === 'projects' && <ProjectsView />}
|
||||
{view === 'licenses' && <LicensesView />}
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
{view === 'finance' && <FinanceView />}
|
||||
{view === 'audit' && <AuditView />}
|
||||
{view === 'settings' && <SettingsView />}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: '*' },
|
||||
],
|
||||
|
||||
@@ -21,6 +21,8 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
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' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' },
|
||||
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' },
|
||||
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
|
||||
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
|
||||
|
||||
@@ -32,6 +32,8 @@ async function main() {
|
||||
{ id: 'leads', enabled: true, version: '1.0.0' },
|
||||
{ id: 'projects', 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: 'finance', enabled: true, version: '1.0.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) } })
|
||||
}
|
||||
|
||||
// --- 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.')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ export type ViewId =
|
||||
| 'leads'
|
||||
| 'projects'
|
||||
| 'licenses'
|
||||
| 'contracts'
|
||||
| 'expenses'
|
||||
| 'finance'
|
||||
| 'audit'
|
||||
| 'settings'
|
||||
|
||||
Reference in new issue
Block a user