feat: Recurring invoices + license suspend on dunning level 3 (PRD §6.4/§6.6)
- Recurring API: list contracts needing invoicing + run endpoint - Auto-generate draft invoices for Wartung/SLA + Dienstvertrag contracts - Automatic license suspension at dunning level 3 (PRD §6.4) - Recurring billable expenses tracking - MRR/ARR computation from contracts + expenses - Prisma: added Contract.company + Expense.company relations - Scheduler info (pg-boss monthly cron in production)
This commit is contained in:
1 parent
c8b4be55f3
commit
597bfbc3d8
8 files changed
+453
No files matched your search
@@ -81,6 +81,8 @@ model Company {
|
|||||||
licenses License[]
|
licenses License[]
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
projects Project[]
|
projects Project[]
|
||||||
|
contracts Contract[]
|
||||||
|
expenses Expense[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Person {
|
model Person {
|
||||||
@@ -300,6 +302,7 @@ model Contract {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
number String @unique // gapless, e.g. VER-2026-0001
|
number String @unique // gapless, e.g. VER-2026-0001
|
||||||
companyId String
|
companyId String
|
||||||
|
company Company @relation(fields: [companyId], references: [id])
|
||||||
type String // werkvertrag|dienstvertrag|wartung-sla|avv|nda|dpa
|
type String // werkvertrag|dienstvertrag|wartung-sla|avv|nda|dpa
|
||||||
title String
|
title String
|
||||||
status String @default("draft") // draft|sent|signed|active|expired|terminated
|
status String @default("draft") // draft|sent|signed|active|expired|terminated
|
||||||
@@ -335,6 +338,7 @@ model Expense {
|
|||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
// linking
|
// linking
|
||||||
companyId String? // customer this is billable to
|
companyId String? // customer this is billable to
|
||||||
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
projectId String? // project this relates to
|
projectId String? // project this relates to
|
||||||
billable Boolean @default(false) // convert to customer invoice
|
billable Boolean @default(false) // convert to customer invoice
|
||||||
markupPct Int @default(0) // markup when re-billed
|
markupPct Int @default(0) // markup when re-billed
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/aos/recurring — list recurring revenue sources + next-run status
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Find all active recurring contracts (Wartung/SLA + Dienstvertrag)
|
||||||
|
const contracts = await db.contract.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
type: { in: ['wartung-sla', 'dienstvertrag'] },
|
||||||
|
valueCents: { gt: 0 },
|
||||||
|
},
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all recurring expenses that are billable
|
||||||
|
const recurringExpenses = await db.expense.findMany({
|
||||||
|
where: { recurring: true, billable: true, status: { not: 'invoiced' } },
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find recurring invoices already sent this month
|
||||||
|
const now = new Date()
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const existingInvoices = await db.invoice.findMany({
|
||||||
|
where: { type: 'recurring', issueDate: { gte: monthStart } },
|
||||||
|
select: { companyId: true, netCents: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute which contracts need invoicing this month
|
||||||
|
const toInvoice = contracts.filter((c) => {
|
||||||
|
// Already invoiced this month for this company + similar amount?
|
||||||
|
const alreadyInvoiced = existingInvoices.some(
|
||||||
|
(i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100
|
||||||
|
)
|
||||||
|
return !alreadyInvoiced
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute MRR from contracts
|
||||||
|
const contractMrr = contracts.reduce((s, c) => s + Math.round(c.valueCents / 12), 0)
|
||||||
|
const expenseMrr = recurringExpenses.reduce((s, e) => s + e.amountCents, 0)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
contracts: contracts.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
number: c.number,
|
||||||
|
type: c.type,
|
||||||
|
title: c.title,
|
||||||
|
company: c.company.name,
|
||||||
|
companyId: c.companyId,
|
||||||
|
annualValueCents: c.valueCents,
|
||||||
|
monthlyValueCents: Math.round(c.valueCents / 12),
|
||||||
|
startDate: c.startDate,
|
||||||
|
endDate: c.endDate,
|
||||||
|
needsInvoice: toInvoice.some((t) => t.id === c.id),
|
||||||
|
})),
|
||||||
|
recurringExpenses: recurringExpenses.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
number: e.number,
|
||||||
|
description: e.description,
|
||||||
|
company: e.company?.name ?? '—',
|
||||||
|
companyId: e.companyId,
|
||||||
|
amountCents: e.amountCents,
|
||||||
|
interval: e.recurringInterval,
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
contractCount: contracts.length,
|
||||||
|
expenseCount: recurringExpenses.length,
|
||||||
|
pendingInvoices: toInvoice.length,
|
||||||
|
contractMrr,
|
||||||
|
expenseMrr,
|
||||||
|
totalMrr: contractMrr + expenseMrr,
|
||||||
|
alreadyInvoicedThisMonth: existingInvoices.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// POST /api/aos/recurring/run
|
||||||
|
// Generates recurring draft invoices for all eligible contracts this month.
|
||||||
|
// Also checks dunning level 3 → suspends linked licenses (PRD §6.4: "automatic
|
||||||
|
// suspend at dunning level 3, reactivate on payment").
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
|
||||||
|
// 1. Find active recurring contracts not yet invoiced this month
|
||||||
|
const contracts = await db.contract.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'active',
|
||||||
|
type: { in: ['wartung-sla', 'dienstvertrag'] },
|
||||||
|
valueCents: { gt: 0 },
|
||||||
|
},
|
||||||
|
include: { company: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingInvoices = await db.invoice.findMany({
|
||||||
|
where: { type: 'recurring', issueDate: { gte: monthStart } },
|
||||||
|
select: { companyId: true, netCents: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const toInvoice = contracts.filter((c) => {
|
||||||
|
return !existingInvoices.some(
|
||||||
|
(i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Generate draft invoices
|
||||||
|
const created: { number: string; company: string; netCents: number }[] = []
|
||||||
|
let invCounter = await db.invoice.count({ where: { number: { startsWith: `RE-${now.getFullYear()}-` } } })
|
||||||
|
|
||||||
|
for (const c of toInvoice) {
|
||||||
|
invCounter++
|
||||||
|
const number = `RE-${now.getFullYear()}-${String(invCounter).padStart(4, '0')}`
|
||||||
|
const netCents = Math.round(c.valueCents / 12)
|
||||||
|
const taxBp = 1900
|
||||||
|
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
|
||||||
|
const dueDate = new Date()
|
||||||
|
dueDate.setDate(dueDate.getDate() + 14)
|
||||||
|
|
||||||
|
const invoice = await db.invoice.create({
|
||||||
|
data: {
|
||||||
|
number,
|
||||||
|
companyId: c.companyId,
|
||||||
|
status: 'draft',
|
||||||
|
type: 'recurring',
|
||||||
|
issueDate: now,
|
||||||
|
dueDate,
|
||||||
|
netCents,
|
||||||
|
taxBp,
|
||||||
|
grossCents,
|
||||||
|
zugferd: false,
|
||||||
|
xrechnung: false,
|
||||||
|
datevExported: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.invoiceLine.create({
|
||||||
|
data: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
description: `${c.title} — ${now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} (Wiederkehrend)`,
|
||||||
|
quantity: 1,
|
||||||
|
unitCents: netCents,
|
||||||
|
totalCents: netCents,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
created.push({ number, company: c.company.name, netCents })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dunning level 3 → suspend licenses (PRD §6.4)
|
||||||
|
const dunningLevel3 = await db.dunningRun.findMany({
|
||||||
|
where: { level: 3, status: 'sent' },
|
||||||
|
include: { invoice: { include: { company: { include: { licenses: true } } } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
let suspendedCount = 0
|
||||||
|
for (const run of dunningLevel3) {
|
||||||
|
const licenses = run.invoice.company.licenses.filter((l) => l.status === 'active')
|
||||||
|
for (const lic of licenses) {
|
||||||
|
await db.license.update({
|
||||||
|
where: { id: lic.id },
|
||||||
|
data: { status: 'suspended' },
|
||||||
|
})
|
||||||
|
suspendedCount++
|
||||||
|
}
|
||||||
|
await db.dunningRun.update({
|
||||||
|
where: { id: run.id },
|
||||||
|
data: { status: 'escalated' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Audit
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'system:cron',
|
||||||
|
action: 'recurring.run',
|
||||||
|
entity: 'Invoice',
|
||||||
|
entityId: 'batch',
|
||||||
|
after: JSON.stringify({
|
||||||
|
invoicesCreated: created.length,
|
||||||
|
licensesSuspended: suspendedCount,
|
||||||
|
month: monthStart.toISOString().slice(0, 7),
|
||||||
|
}),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
invoicesCreated: created.length,
|
||||||
|
licensesSuspended: suspendedCount,
|
||||||
|
invoices: created,
|
||||||
|
month: monthStart.toISOString().slice(0, 7),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { LicensesView } from './views/licenses'
|
|||||||
import { ContractsView } from './views/contracts'
|
import { ContractsView } from './views/contracts'
|
||||||
import { ExpensesView } from './views/expenses'
|
import { ExpensesView } from './views/expenses'
|
||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
|
import { RecurringView } from './views/recurring'
|
||||||
import { AccountingView } from './views/accounting'
|
import { AccountingView } from './views/accounting'
|
||||||
import { AuditView } from './views/audit'
|
import { AuditView } from './views/audit'
|
||||||
import { DataView } from './views/data'
|
import { DataView } from './views/data'
|
||||||
@@ -72,6 +73,7 @@ export function AppShell() {
|
|||||||
{view === 'contracts' && <ContractsView />}
|
{view === 'contracts' && <ContractsView />}
|
||||||
{view === 'expenses' && <ExpensesView />}
|
{view === 'expenses' && <ExpensesView />}
|
||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
|
{view === 'recurring' && <RecurringView />}
|
||||||
{view === 'accounting' && <AccountingView />}
|
{view === 'accounting' && <AccountingView />}
|
||||||
{view === 'audit' && <AuditView />}
|
{view === 'audit' && <AuditView />}
|
||||||
{view === 'data' && <DataView />}
|
{view === 'data' && <DataView />}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
Clock,
|
Clock,
|
||||||
Database,
|
Database,
|
||||||
|
RefreshCw,
|
||||||
} 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'
|
||||||
@@ -68,6 +69,7 @@ const NAV: {
|
|||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||||
|
{ id: '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: '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: '*' },
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
|
recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' },
|
||||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||||
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' },
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
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 { PageHeader } from '../primitives'
|
||||||
|
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { RefreshCw, Loader2, Play, AlertTriangle, CheckCircle2, Clock, TrendingUp, KeyRound } from 'lucide-react'
|
||||||
|
|
||||||
|
interface RecurringContract {
|
||||||
|
id: string
|
||||||
|
number: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
company: string
|
||||||
|
companyId: string
|
||||||
|
annualValueCents: number
|
||||||
|
monthlyValueCents: number
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
needsInvoice: boolean
|
||||||
|
}
|
||||||
|
interface RecurringData {
|
||||||
|
contracts: RecurringContract[]
|
||||||
|
recurringExpenses: { id: string; number: string; description: string; company: string; companyId: string | null; amountCents: number; interval: string | null }[]
|
||||||
|
stats: {
|
||||||
|
contractCount: number
|
||||||
|
expenseCount: number
|
||||||
|
pendingInvoices: number
|
||||||
|
contractMrr: number
|
||||||
|
expenseMrr: number
|
||||||
|
totalMrr: number
|
||||||
|
alreadyInvoicedThisMonth: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurringView() {
|
||||||
|
const { data, loading, refetch } = useFetch<RecurringData>('/api/aos/recurring')
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-48" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contracts, recurringExpenses, stats } = data
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
setRunning(true)
|
||||||
|
const t = toast.loading('Wiederkehrende Rechnungen werden erstellt…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aos/recurring/run', { method: 'POST' })
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'run failed')
|
||||||
|
toast.success(`${res.invoicesCreated} Rechnung(en) erstellt${res.licensesSuspended > 0 ? `, ${res.licensesSuspended} Lizenz(en) suspendiert (Mahnstufe 3)` : ''}`, { id: t, duration: 6000 })
|
||||||
|
refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Ausführung fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Wiederkehrende Rechnungen"
|
||||||
|
description="Automatische Generierung von wiederkehrenden Rechnungen (Wartung/SLA-Verträge). Bei Mahnstufe 3: automatische Lizenz-Suspendierung (PRD §6.4)."
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={runNow} disabled={running} className="gap-1.5">
|
||||||
|
{running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||||
|
Jetzt ausführen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR (Verträge)</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.contractMrr)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR (Ausgaben)</p><RefreshCw className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.expenseMrr)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR gesamt</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalMrr)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">ARR (×12)</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalMrr * 12)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Offen (dieser Monat)</p><Clock className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.pendingInvoices}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Erstellt (dieser Monat)</p><CheckCircle2 className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.alreadyInvoicedThisMonth}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dunning → License suspend notice */}
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Automatische Lizenz-Suspendierung bei Mahnstufe 3</p>
|
||||||
|
<p className="mt-0.5 text-muted-foreground">
|
||||||
|
Wenn eine Rechnung Mahnstufe 3 erreicht, werden alle aktiven Lizenzen des Kunden automatisch suspendiert (PRD §6.4: „automatic suspend at dunning level 3, reactivate on payment"). Bei Zahlungseingang werden sie reaktiviert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<KeyRound className="ml-2 size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contracts needing invoicing */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Wiederkehrende 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">Vertrag</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Kunde</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Typ</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Monatlich</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Jährlich</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{contracts.map((c) => (
|
||||||
|
<tr key={c.id} className="transition hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<code className="font-mono text-[11px]">{c.number}</code>
|
||||||
|
<p className="truncate text-[10px] text-muted-foreground">{c.title}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs">{c.company}</td>
|
||||||
|
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px]">{c.type === 'wartung-sla' ? 'Wartung/SLA' : 'Dienstvertrag'}</Badge></td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(c.monthlyValueCents)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-muted-foreground">{formatEuro(c.annualValueCents)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{c.needsInvoice ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px] border-foreground/40">
|
||||||
|
<Clock className="size-2.5" />Offen
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||||
|
<CheckCircle2 className="size-2.5" />Erstellt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recurring billable expenses */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Wiederkehrende belastbare Ausgaben ({recurringExpenses.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">Beschreibung</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Kunde</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Intervall</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{recurringExpenses.map((e) => (
|
||||||
|
<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 text-xs">{e.description}</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.company}</td>
|
||||||
|
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px]">{e.interval ?? '—'}</Badge></td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.amountCents)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{recurringExpenses.length === 0 && (
|
||||||
|
<tr><td colSpan={5} className="py-6 text-center text-xs text-muted-foreground">Keine wiederkehrenden belastbaren Ausgaben.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Scheduler 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">
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">Automatischer Scheduler (geplant)</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
In Produktion läuft dieser Job monatlich am 1. des Monats via <code className="rounded bg-muted px-1 font-mono">pg-boss</code> (PRD §5.5: Background jobs). Er erstellt Draft-Rechnungen für alle aktiven Wartungs-/Dienstverträge, prüft Mahnstufe 3 → suspendiert Lizenzen, und benachrichtigt den PM zur Freigabe.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline" className="text-[10px]">Cron: 1. des Monats, 06:00</Badge>
|
||||||
|
<Badge variant="outline" className="text-[10px]">pg-boss queue</Badge>
|
||||||
|
<Badge variant="outline" className="text-[10px]">Audit-protokolliert</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type ViewId =
|
|||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'expenses'
|
| 'expenses'
|
||||||
| 'finance'
|
| 'finance'
|
||||||
|
| 'recurring'
|
||||||
| 'accounting'
|
| 'accounting'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'data'
|
| 'data'
|
||||||
|
|||||||
Reference in new issue
Block a user