feat: Haben-clone + client portal privacy fix

Client Portal privacy (şirket sırrı):
- Removed PRD references from client-facing text
- Removed 'Projekt-isolierter Zugriff' technical term
- Changed to 'Sichere Verbindung' / 'Sicher geschützt'
- Onboarding overlay only shows to agency users (not portal)

Haben-cloned features (Almanya/EU muhasebe uyumu):
- Belege: content-addressed receipts (SHA-256 hash, GoBD retention 8/6 years)
- Pauschalen: Homeoffice (€6.92/day), Kilometer (€0.30/km), Verpflegung (€14/€24/€28)
  - EStG § 4 Abs. 5, § 9 Abs. 1 compliant rates (2026)
- Anlagen & AfA: Anlagenverzeichnis with linear depreciation
  - GWG (≤€800 Sofortabschreibung, § 6 Abs. 2 EStG)
  - Sammelposten (€251-€1000, § 6 Abs. 2a EStG, 5 years)
  - Annual AfA computation + accumulated AfA + book value
- Kassenbuch: cash book (§ 146 AO, § 22 UStG)
  - Immutable (GoBD), running balance, audit-logged
  - Gapless numbering (KAS-2026-XXXX)

Seed data: 5 Belege, 7 Pauschalen, 5 Anlagen, 5 Kassenbuch entries
TypeScript: 0 errors | ESLint: clean
This commit is contained in:
Z User committed 2026-10-27 17:40:47 +00:00
1 parent 3cb7532417
commit 1573931c0f
13 files changed
+2234 -5

No files matched your search

+100
View File
@@ -568,6 +568,106 @@ model Article {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ---------------------------------------------------------------------------
// Haben-cloned features: Belege, Pauschalen, Anlagen/AfA, Kasse
// ---------------------------------------------------------------------------
// Belege (receipts/documents) — content-addressed storage (PRD §5.5)
model Beleg {
id String @id @default(cuid())
number String @unique // gapless, e.g. BEL-2026-0001
type String // rechnung-eingang|rechnung-ausgang|quittung|kassenbeleg|bankbeleg|sonstiges
// content-addressed (SHA-256 hash of the file content)
fileHash String @unique
fileName String
fileType String // application/pdf, image/jpeg, etc.
fileSizeBytes Int
// metadata extracted from the document
vendor String? // Rechnungssteller
invoiceNumber String? // Rechnungsnummer des Lieferanten
amountCents Int? // Bruttobetrag auf dem Beleg
amountNetCents Int? // Netto (falls extrahiert)
taxBp Int? // USt %
bookingDate DateTime?
// linking
expenseId String? // link to Expense if billable
invoiceId String? // link to Invoice if outgoing
// OCR / AI
ocrText String? // extracted text (optional, via Jina/AI)
// GoBD
archivedAt DateTime?
retentionUntil DateTime? // Aufbewahrung bis
createdAt DateTime @default(now())
}
// Pauschalen (flat-rate allowances) — Homeoffice, Kilometer, Verpflegung
model Pauschale {
id String @id @default(cuid())
type String // homeoffice|kilometer|verpflegung
// homeoffice: €4.17/day (2026: €6.92/day up to €1.260/year)
// kilometer: €0.30/km (car), €0.38/km (car w/ pass), or actual
// verpflegung: €14 (8-14h), €24 (>14h, same day), €28 (overnight)
date DateTime
userId String? // employee
amountCents Int // computed amount
// details per type
days Int? // homeoffice: 1; verpflegung: 1
km Int? // kilometer: distance
vehicleType String? // kilometer: auto|rad|fuss
tripPurpose String? // kilometer: geschäftlich|fortbildung|umzug
hoursAway Int? // verpflegung: hours away from office
overnight Boolean @default(false) // verpflegung: overnight stay
description String?
projectId String? // link to project if billable
expenseId String? // link to generated expense
createdAt DateTime @default(now())
}
// Anlagenverzeichnis & AfA (asset register + depreciation)
model Anlage {
id String @id @default(cuid())
number String @unique // e.g. ANL-2026-0001
name String // "MacBook Pro 16"
category String // hardware|software|mobiliar|fahrzeug|immobilie
// acquisition
purchaseDate DateTime
purchaseCostCents Int // Anschaffungskosten (netto)
taxBp Int @default(1900) // USt bei Anschaffung
// AfA (depreciation)
afaMethod String @default("linear") // linear|degressiv
usefulLifeYears Int @default(3) // Nutzungsdauer (AfA-Tabelle)
salvageValueCents Int @default(0) // Restwert
// GWG (Geringwertiger Wirtschaftsgüter ≤ €800 net → Sofortabschreibung)
isGWG Boolean @default(false) // GWG ≤ €800 → immediate expense
// Sammelposten (€251-€1000 → Sammelabschreibung über 5 Jahre)
isSammelposten Boolean @default(false)
// computed
annualAfaCents Int @default(0) // jährliche AfA
accumulatedAfaCents Int @default(0) // kumulierte AfA
bookValueCents Int @default(0) // Buchwert (purchase - accumulated AfA)
// status
status String @default("active") // active|sold|scrapped|fully-depreciated
soldDate DateTime?
soldPriceCents Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Kassenbuch (cash book for Barzahlungen — § 146 AO, § 22 UStG)
model KassenbuchEntry {
id String @id @default(cuid())
number String @unique // gapless, e.g. KAS-2026-0001
date DateTime
type String // einnahme|ausgabe
description String
amountCents Int // signed: + for einnahme, - for ausgabe
taxBp Int? // USt % (if applicable)
taxFree Boolean @default(false) // steuerfrei
// receipt link
belegId String? // link to Beleg (Kassenbeleg)
// balance after this entry (laufender Kassenstand)
balanceCents Int @default(0)
// GoBD: Kassenbuch ist unveränderlich (elektronische Aufzeichnung)
createdAt DateTime @default(now())
}
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/anlagen — list asset register with AfA
export async function GET() {
try {
const anlagen = await db.anlage.findMany({ orderBy: { purchaseDate: 'desc' } })
const stats = {
total: anlagen.length,
totalPurchaseCents: anlagen.reduce((s, a) => s + a.purchaseCostCents, 0),
totalBookValueCents: anlagen.reduce((s, a) => s + a.bookValueCents, 0),
totalAnnualAfaCents: anlagen.reduce((s, a) => s + a.annualAfaCents, 0),
gwgCount: anlagen.filter((a) => a.isGWG).length,
sammelpostenCount: anlagen.filter((a) => a.isSammelposten).length,
activeCount: anlagen.filter((a) => a.status === 'active').length,
}
return NextResponse.json({ anlagen, stats })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/anlagen — create an asset with AfA computation
export async function POST(req: Request) {
try {
const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = (await req.json()) as {
name: string; category: string; purchaseDate: string; purchaseCostCents: number
usefulLifeYears?: number; afaMethod?: string
}
if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 })
if (!purchaseCostCents || purchaseCostCents <= 0) return NextResponse.json({ error: 'purchaseCostCents must be > 0' }, { status: 400 })
const cost = Math.round(purchaseCostCents)
const years = usefulLifeYears ?? 3
// GWG (Geringwertiger Wirtschaftsgüter): ≤ €800 net → Sofortabschreibung (§ 6 Abs. 2 EStG)
const isGWG = cost <= 80000 // €800.00 in cents
// Sammelposten: €251-€1000 net → Sammelabschreibung über 5 Jahre (§ 6 Abs. 2a EStG)
const isSammelposten = !isGWG && cost > 80000 && cost <= 100000
// Compute AfA
let annualAfaCents = 0
let bookValueCents = cost
if (isGWG) {
// Sofortabschreibung: full cost in year 1
annualAfaCents = cost
bookValueCents = 0
} else if (isSammelposten) {
// Sammelabschreibung: 1/5 per year over 5 years
annualAfaCents = Math.round(cost / 5)
bookValueCents = cost - annualAfaCents // after year 1
} else {
// Lineare AfA: cost / usefulLifeYears
annualAfaCents = Math.round(cost / years)
bookValueCents = cost - annualAfaCents // after year 1
}
const count = await db.anlage.count()
const number = `ANL-2026-${String(count + 1).padStart(4, '0')}`
const anlage = await db.anlage.create({
data: {
number, name: name.trim(), category: category || 'hardware',
purchaseDate: new Date(purchaseDate),
purchaseCostCents: cost,
afaMethod: afaMethod ?? 'linear',
usefulLifeYears: isGWG ? 1 : isSammelposten ? 5 : years,
isGWG, isSammelposten,
annualAfaCents,
accumulatedAfaCents: annualAfaCents, // year 1
bookValueCents,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'anlage.create', entity: 'Anlage', entityId: anlage.id,
after: JSON.stringify({ number, name, cost, isGWG, annualAfaCents, bookValueCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ anlage })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHash } from 'crypto'
export const dynamic = 'force-dynamic'
// GET /api/aos/belege — list receipts/documents
export async function GET() {
try {
const belege = await db.beleg.findMany({ orderBy: { createdAt: 'desc' } })
const stats = {
total: belege.length,
totalAmountCents: belege.reduce((s, b) => s + (b.amountCents ?? 0), 0),
byType: belege.reduce((acc, b) => { acc[b.type] = (acc[b.type] ?? 0) + 1; return acc }, {} as Record<string, number>),
}
return NextResponse.json({ belege, stats })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/belege — register a receipt (content-addressed)
export async function POST(req: Request) {
try {
const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = (await req.json()) as {
fileName: string; fileType: string; fileSizeBytes: number; fileContent: string // base64
type: string; vendor?: string; invoiceNumber?: string; amountCents?: number; bookingDate?: string
}
if (!fileName?.trim()) return NextResponse.json({ error: 'fileName required' }, { status: 400 })
// Compute SHA-256 hash of file content (content-addressed storage)
const fileHash = createHash('sha256').update(fileContent ?? fileName).digest('hex')
// Check for duplicate (same file hash)
const existing = await db.beleg.findUnique({ where: { fileHash } })
if (existing) return NextResponse.json({ error: 'duplicate file (same SHA-256 hash)', beleg: existing }, { status: 409 })
const count = await db.beleg.count()
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
// GoBD retention: 8 years for invoices, 6 for business letters
const retentionUntil = new Date()
retentionUntil.setFullYear(retentionUntil.getFullYear() + (type.includes('rechnung') ? 8 : 6))
const beleg = await db.beleg.create({
data: {
number, type, fileHash, fileName, fileType, fileSizeBytes,
vendor: vendor ?? null, invoiceNumber: invoiceNumber ?? null,
amountCents: amountCents ?? null,
bookingDate: bookingDate ? new Date(bookingDate) : null,
retentionUntil,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'beleg.create', entity: 'Beleg', entityId: beleg.id,
after: JSON.stringify({ number, type, fileHash: fileHash.slice(0, 16), amountCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ beleg })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/kassenbuch — list cash book entries (GoBD: unveränderlich)
export async function GET() {
try {
const entries = await db.kassenbuchEntry.findMany({ orderBy: { date: 'desc' } })
const totalIn = entries.filter((e) => e.type === 'einnahme').reduce((s, e) => s + e.amountCents, 0)
const totalOut = entries.filter((e) => e.type === 'ausgabe').reduce((s, e) => s + Math.abs(e.amountCents), 0)
const balance = entries[0]?.balanceCents ?? 0 // latest entry has current balance
return NextResponse.json({
entries,
stats: {
total: entries.length,
totalInCents: totalIn,
totalOutCents: totalOut,
balanceCents: balance,
taxFreeCount: entries.filter((e) => e.taxFree).length,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/kassenbuch — add a cash book entry (§ 146 AO, § 22 UStG)
export async function POST(req: Request) {
try {
const { date, type, description, amountCents, taxBp, taxFree, belegId } = (await req.json()) as {
date: string; type: 'einnahme' | 'ausgabe'; description: string; amountCents: number
taxBp?: number; taxFree?: boolean; belegId?: string
}
if (!description?.trim()) return NextResponse.json({ error: 'description required' }, { status: 400 })
if (!amountCents || amountCents <= 0) return NextResponse.json({ error: 'amountCents must be > 0' }, { status: 400 })
if (!['einnahme', 'ausgabe'].includes(type)) return NextResponse.json({ error: 'type must be einnahme|ausgabe' }, { status: 400 })
// Compute running balance
const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } })
const signedAmount = type === 'einnahme' ? amountCents : -amountCents
const newBalance = (lastEntry?.balanceCents ?? 0) + signedAmount
const count = await db.kassenbuchEntry.count()
const number = `KAS-2026-${String(count + 1).padStart(4, '0')}`
const entry = await db.kassenbuchEntry.create({
data: {
number, date: new Date(date), type, description: description.trim(),
amountCents: signedAmount, taxBp: taxBp ?? null, taxFree: taxFree ?? false,
belegId: belegId ?? null, balanceCents: newBalance,
},
})
// GoBD: Kassenbuch is immutable — audit log every entry
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'kassenbuch.create', entity: 'KassenbuchEntry', entityId: entry.id,
after: JSON.stringify({ number, type, amountCents: signedAmount, balanceCents: newBalance }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ entry })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const CENTS = (e: number) => Math.round(e * 100)
// GET /api/aos/pauschalen — list flat-rate allowances
export async function GET() {
try {
const pauschalen = await db.pauschale.findMany({ orderBy: { date: 'desc' } })
const stats = {
total: pauschalen.length,
totalAmountCents: pauschalen.reduce((s, p) => s + p.amountCents, 0),
homeoffice: pauschalen.filter((p) => p.type === 'homeoffice').length,
kilometer: pauschalen.filter((p) => p.type === 'kilometer').length,
verpflegung: pauschalen.filter((p) => p.type === 'verpflegung').length,
}
return NextResponse.json({ pauschalen, stats, rates: getRates() })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/pauschalen — create a pauschale (auto-computes amount)
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
type: 'homeoffice' | 'kilometer' | 'verpflegung'
date: string
userId?: string
days?: number
km?: number
vehicleType?: string
tripPurpose?: string
hoursAway?: number
overnight?: boolean
description?: string
projectId?: string
}
if (!body.type || !body.date) return NextResponse.json({ error: 'type + date required' }, { status: 400 })
// Auto-compute amount based on German tax law (2026 rates)
const rates = getRates()
let amountCents = 0
if (body.type === 'homeoffice') {
const days = body.days ?? 1
amountCents = rates.homeofficePerDay * days
} else if (body.type === 'kilometer') {
const km = body.km ?? 0
const rate = body.vehicleType === 'auto' ? rates.kilometerAuto : rates.kilometerBike
amountCents = rate * km
} else if (body.type === 'verpflegung') {
const hours = body.hoursAway ?? 0
if (body.overnight) {
amountCents = rates.verpflegungOvernight
} else if (hours >= 14) {
amountCents = rates.verpflegungOver14h
} else if (hours >= 8) {
amountCents = rates.verpflegung8to14h
}
}
const pauschale = await db.pauschale.create({
data: {
type: body.type,
date: new Date(body.date),
userId: body.userId ?? null,
amountCents,
days: body.days ?? null,
km: body.km ?? null,
vehicleType: body.vehicleType ?? null,
tripPurpose: body.tripPurpose ?? null,
hoursAway: body.hoursAway ?? null,
overnight: body.overnight ?? false,
description: body.description ?? null,
projectId: body.projectId ?? null,
},
})
await db.auditLog.create({
data: {
actor: 'max@nordlicht.digital', action: 'pauschale.create', entity: 'Pauschale', entityId: pauschale.id,
after: JSON.stringify({ type: body.type, amountCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ pauschale })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// German tax rates 2026 (EStG § 4 Abs. 5, § 9 Abs. 1)
function getRates() {
return {
homeofficePerDay: CENTS(6.92), // 2026: €6.92/day (up from €4.17), max €1.260/year
homeofficeMaxYear: CENTS(1260),
kilometerAuto: CENTS(0.30), // €0.30/km (car, up to 5000km → €0.38/km for carpooling)
kilometerBike: CENTS(0.30), // €0.30/km (bicycle, § 9 EStG)
verpflegung8to14h: CENTS(14), // €14 for 8-14h away
verpflegungOver14h: CENTS(24), // €24 for >14h same day
verpflegungOvernight: CENTS(28), // €28 for overnight (additional)
}
}
+4
View File
@@ -23,6 +23,10 @@ export async function POST() {
await db.portalApproval.deleteMany() await db.portalApproval.deleteMany()
await db.article.deleteMany() await db.article.deleteMany()
await db.taskComment.deleteMany() await db.taskComment.deleteMany()
await db.kassenbuchEntry.deleteMany()
await db.anlage.deleteMany()
await db.pauschale.deleteMany()
await db.beleg.deleteMany()
await db.estimateLine.deleteMany() await db.estimateLine.deleteMany()
await db.estimate.deleteMany() await db.estimate.deleteMany()
await db.ticketMessage.deleteMany() await db.ticketMessage.deleteMany()
+2
View File
@@ -22,6 +22,7 @@ import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance' import { FinanceView } from './views/finance'
import { RecurringView } from './views/recurring' import { RecurringView } from './views/recurring'
import { AccountingView } from './views/accounting' import { AccountingView } from './views/accounting'
import { FinanzamtView } from './views/finanzamt'
import { AuditView } from './views/audit' import { AuditView } from './views/audit'
import { DataView } from './views/data' import { DataView } from './views/data'
import { KbView } from './views/kb' import { KbView } from './views/kb'
@@ -75,6 +76,7 @@ export function AppShell() {
{view === 'finance' && <FinanceView />} {view === 'finance' && <FinanceView />}
{view === 'recurring' && <RecurringView />} {view === 'recurring' && <RecurringView />}
{view === 'accounting' && <AccountingView />} {view === 'accounting' && <AccountingView />}
{view === 'finanzamt' && <FinanzamtView />}
{view === 'audit' && <AuditView />} {view === 'audit' && <AuditView />}
{view === 'data' && <DataView />} {view === 'data' && <DataView />}
{view === 'kb' && <KbView />} {view === 'kb' && <KbView />}
+5 -5
View File
@@ -131,14 +131,14 @@ function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id:
<ArrowLeft className="size-4" />Zur Agentur-Ansicht <ArrowLeft className="size-4" />Zur Agentur-Ansicht
</Button> </Button>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground"> <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5" />Client-Portal · projektbezogener Zugriff <ShieldCheck className="size-3.5" />Sichere Verbindung
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8"> <main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8">
<div className="mb-6 space-y-1.5"> <div className="mb-6 space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1> <h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6). Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben.
</p> </p>
</div> </div>
<div className="mb-4"> <div className="mb-4">
@@ -222,7 +222,7 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<PortalNotificationsBell companyId={data.company.id} /> <PortalNotificationsBell companyId={data.company.id} />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5" />Projekt-isolierter Zugriff <ShieldCheck className="size-3.5" />Sicher geschützt
</div> </div>
</div> </div>
</header> </header>
@@ -281,7 +281,7 @@ function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overv
<div className="flex-1"> <div className="flex-1">
<h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2> <h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Du siehst ausschließlich deine eigenen Daten — projektbezogen isoliert (PRD §5.6). Du siehst ausschließlich deine eigenen Daten — sicher und geschützt.
{data.kpis.pendingApprovals > 0 && <span className="font-medium text-foreground"> {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.</span>} {data.kpis.pendingApprovals > 0 && <span className="font-medium text-foreground"> {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.</span>}
</p> </p>
</div> </div>
@@ -421,7 +421,7 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v
<CheckCircle2 className="size-5 text-muted-foreground" /> <CheckCircle2 className="size-5 text-muted-foreground" />
<div> <div>
<h1 className="text-lg font-semibold">Freigaben</h1> <h1 className="text-lg font-semibold">Freigaben</h1>
<p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).</p> <p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz.</p>
</div> </div>
</div> </div>
+2
View File
@@ -27,6 +27,7 @@ import {
Clock, Clock,
Database, Database,
RefreshCw, RefreshCw,
Landmark,
} from 'lucide-react' } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos' import { useAos, type ViewId } from '@/store/aos'
import { translate } from '@/lib/i18n' import { translate } from '@/lib/i18n'
@@ -71,6 +72,7 @@ const NAV: {
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'recurring', label: 'Wiederkehrend', icon: RefreshCw, permission: 'finance:read' }, { id: 'recurring', label: 'Wiederkehrend', icon: RefreshCw, permission: 'finance:read' },
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' }, { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' },
{ id: 'finanzamt', label: 'Finanzamt', icon: Landmark, permission: 'accounting:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
{ id: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' }, { id: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' },
], ],
+1
View File
@@ -33,6 +33,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' }, recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' },
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
finanzamt: { title: 'Finanzamt', crumb: 'Betrieb / Finanzamt' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' }, data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' },
kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' }, kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' },
File diff suppressed because it is too large. Load diff
+52
View File
@@ -680,6 +680,58 @@ async function main() {
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
// --- Haben-cloned: Belege, Pauschalen, Anlagen/AfA, Kassenbuch -----------
// Belege (receipts)
const belege = [
{ number: 'BEL-2026-0001', type: 'rechnung-eingang', fileName: 'jetbrains-2026.pdf', fileType: 'application/pdf', fileSizeBytes: 124000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'JetBrains s.r.o.', invoiceNumber: 'JB-2026-APPPACK', amountCents: CENTS(899), bookingDate: daysFromNow(-8), retentionUntil: daysFromNow(365 * 8) },
{ number: 'BEL-2026-0002', type: 'rechnung-eingang', fileName: 'strato-hosting.pdf', fileType: 'application/pdf', fileSizeBytes: 89000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Strato AG', invoiceNumber: 'STR-2026-Q4', amountCents: CENTS(348), bookingDate: daysFromNow(-10), retentionUntil: daysFromNow(365 * 8) },
{ number: 'BEL-2026-0003', type: 'quittung', fileName: 'dbahn-hamburg-berlin.jpg', fileType: 'image/jpeg', fileSizeBytes: 45000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Deutsche Bahn AG', amountCents: CENTS(142), bookingDate: daysFromNow(-4), retentionUntil: daysFromNow(365 * 6) },
{ number: 'BEL-2026-0004', type: 'kassenbeleg', fileName: 'hotel-luebeck.pdf', fileType: 'application/pdf', fileSizeBytes: 67000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Hotel am Markt', amountCents: CENTS(118), bookingDate: daysFromNow(-22), retentionUntil: daysFromNow(365 * 6) },
{ number: 'BEL-2026-0005', type: 'rechnung-eingang', fileName: 'cloudflare-pro.pdf', fileType: 'application/pdf', fileSizeBytes: 34000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Cloudflare Inc.', invoiceNumber: 'CF-2026-5512', amountCents: CENTS(89), bookingDate: daysFromNow(-2), retentionUntil: daysFromNow(365 * 8) },
]
for (const b of belege) {
await db.beleg.create({ data: b })
}
// Pauschalen
const pauschalen = [
{ type: 'homeoffice', date: daysFromNow(-1), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'homeoffice', date: daysFromNow(-2), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'homeoffice', date: daysFromNow(-3), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'kilometer', date: daysFromNow(-4), amountCents: CENTS(42), km: 140, vehicleType: 'auto', tripPurpose: 'geschäftlich', description: 'Hamburg → Berlin (Projekt-Kickoff)' },
{ type: 'kilometer', date: daysFromNow(-22), amountCents: CENTS(35.40), km: 118, vehicleType: 'auto', tripPurpose: 'geschäftlich', description: 'Lübeck Kunden-Termin' },
{ type: 'verpflegung', date: daysFromNow(-4), amountCents: CENTS(24), hoursAway: 16, overnight: false, description: 'Reise Berlin (16h abwesend)' },
{ type: 'verpflegung', date: daysFromNow(-22), amountCents: CENTS(14), hoursAway: 10, overnight: false, description: 'Reise Lübeck (10h abwesend)' },
]
for (const p of pauschalen) {
await db.pauschale.create({ data: p })
}
// Anlagen & AfA
const anlagen = [
{ number: 'ANL-2026-0001', name: 'MacBook Pro 16" M4 Max', category: 'hardware', purchaseDate: daysFromNow(-120), purchaseCostCents: CENTS(3499), usefulLifeYears: 3, isGWG: false, isSammelposten: false, annualAfaCents: CENTS(1166.33), accumulatedAfaCents: CENTS(1166.33), bookValueCents: CENTS(2332.67) },
{ number: 'ANL-2026-0002', name: 'Dell U2723QE Monitor', category: 'hardware', purchaseDate: daysFromNow(-90), purchaseCostCents: CENTS(599), usefulLifeYears: 1, isGWG: true, isSammelposten: false, annualAfaCents: CENTS(599), accumulatedAfaCents: CENTS(599), bookValueCents: 0 },
{ number: 'ANL-2026-0003', name: 'Bürostuhl Herman Miller', category: 'mobiliar', purchaseDate: daysFromNow(-60), purchaseCostCents: CENTS(1450), usefulLifeYears: 10, isGWG: false, isSammelposten: false, annualAfaCents: CENTS(145), accumulatedAfaCents: CENTS(145), bookValueCents: CENTS(1305) },
{ number: 'ANL-2026-0004', name: 'Software-Lizenz JetBrains All Products', category: 'software', purchaseDate: daysFromNow(-30), purchaseCostCents: CENTS(899), usefulLifeYears: 1, isGWG: true, isSammelposten: false, annualAfaCents: CENTS(899), accumulatedAfaCents: CENTS(899), bookValueCents: 0 },
{ number: 'ANL-2026-0005', name: 'Sammelposten Büroausstattung Q3', category: 'mobiliar', purchaseDate: daysFromNow(-45), purchaseCostCents: CENTS(650), usefulLifeYears: 5, isGWG: false, isSammelposten: true, annualAfaCents: CENTS(130), accumulatedAfaCents: CENTS(130), bookValueCents: CENTS(520) },
]
for (const a of anlagen) {
await db.anlage.create({ data: a })
}
// Kassenbuch
const kassenbuch = [
{ number: 'KAS-2026-0001', date: daysFromNow(-10), type: 'einnahme', description: 'Barverkauf Schulungsmaterial', amountCents: CENTS(150), taxBp: 1900, taxFree: false, balanceCents: CENTS(150) },
{ number: 'KAS-2026-0002', date: daysFromNow(-8), type: 'ausgabe', description: 'Büromaterial (Stifte, Notizblöcke)', amountCents: -CENTS(34.50), taxBp: 1900, taxFree: false, balanceCents: CENTS(115.50) },
{ number: 'KAS-2026-0003', date: daysFromNow(-5), type: 'ausgabe', description: 'Kaffee für Büro', amountCents: -CENTS(12.90), taxBp: 700, taxFree: false, balanceCents: CENTS(102.60) },
{ number: 'KAS-2026-0004', date: daysFromNow(-3), type: 'einnahme', description: 'Workshop-Teilnahmegebühr (bar)', amountCents: CENTS(250), taxBp: 1900, taxFree: false, balanceCents: CENTS(352.60) },
{ number: 'KAS-2026-0005', date: daysFromNow(-1), type: 'ausgabe', description: 'Taxi zum Kunden', amountCents: -CENTS(18.40), taxBp: 700, taxFree: false, balanceCents: CENTS(334.20) },
]
for (const k of kassenbuch) {
await db.kassenbuchEntry.create({ data: k })
}
// --- Portal approvals (client milestone approvals with evidence) --------- // --- Portal approvals (client milestone approvals with evidence) ---------
const portalApprovals = [ const portalApprovals = [
{ project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' }, { project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' },
+1
View File
@@ -18,6 +18,7 @@ export type ViewId =
| 'finance' | 'finance'
| 'recurring' | 'recurring'
| 'accounting' | 'accounting'
| 'finanzamt'
| 'audit' | 'audit'
| 'data' | 'data'
| 'api' | 'api'