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:
1 parent
3cb7532417
commit
1573931c0f
13 files changed
+2234
-5
No files matched your search
@@ -568,6 +568,106 @@ model Article {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export async function POST() {
|
||||
await db.portalApproval.deleteMany()
|
||||
await db.article.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.estimate.deleteMany()
|
||||
await db.ticketMessage.deleteMany()
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
import { RecurringView } from './views/recurring'
|
||||
import { AccountingView } from './views/accounting'
|
||||
import { FinanzamtView } from './views/finanzamt'
|
||||
import { AuditView } from './views/audit'
|
||||
import { DataView } from './views/data'
|
||||
import { KbView } from './views/kb'
|
||||
@@ -75,6 +76,7 @@ export function AppShell() {
|
||||
{view === 'finance' && <FinanceView />}
|
||||
{view === 'recurring' && <RecurringView />}
|
||||
{view === 'accounting' && <AccountingView />}
|
||||
{view === 'finanzamt' && <FinanzamtView />}
|
||||
{view === 'audit' && <AuditView />}
|
||||
{view === 'data' && <DataView />}
|
||||
{view === 'kb' && <KbView />}
|
||||
|
||||
@@ -131,14 +131,14 @@ function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id:
|
||||
<ArrowLeft className="size-4" />Zur Agentur-Ansicht
|
||||
</Button>
|
||||
<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>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8">
|
||||
<div className="mb-6 space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
@@ -222,7 +222,7 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<PortalNotificationsBell companyId={data.company.id} />
|
||||
<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>
|
||||
</header>
|
||||
@@ -281,7 +281,7 @@ function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overv
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2>
|
||||
<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>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -421,7 +421,7 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v
|
||||
<CheckCircle2 className="size-5 text-muted-foreground" />
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Clock,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Landmark,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { translate } from '@/lib/i18n'
|
||||
@@ -71,6 +72,7 @@ const NAV: {
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||
{ id: 'recurring', label: 'Wiederkehrend', icon: RefreshCw, permission: 'finance: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: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' },
|
||||
],
|
||||
|
||||
@@ -33,6 +33,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' },
|
||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||
finanzamt: { title: 'Finanzamt', crumb: 'Betrieb / Finanzamt' },
|
||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||
data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' },
|
||||
kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' },
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.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) ---------
|
||||
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.' },
|
||||
|
||||
@@ -18,6 +18,7 @@ export type ViewId =
|
||||
| 'finance'
|
||||
| 'recurring'
|
||||
| 'accounting'
|
||||
| 'finanzamt'
|
||||
| 'audit'
|
||||
| 'data'
|
||||
| 'api'
|
||||
|
||||
Reference in new issue
Block a user