102a8603-26df-462f-a56d-01cee292f6f6
This commit is contained in:
1 parent
40805e16cd
commit
85c4006803
19 files changed
+987
No files matched your search
@@ -18,6 +18,7 @@ import { LicensesView } from './views/licenses'
|
||||
import { ContractsView } from './views/contracts'
|
||||
import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
import { AccountingView } from './views/accounting'
|
||||
import { AuditView } from './views/audit'
|
||||
import { SettingsView } from './views/settings'
|
||||
import { SetupWizardView } from './views/setup-wizard'
|
||||
@@ -57,6 +58,7 @@ export function AppShell() {
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
{view === 'finance' && <FinanceView />}
|
||||
{view === 'accounting' && <AccountingView />}
|
||||
{view === 'audit' && <AuditView />}
|
||||
{view === 'settings' && <SettingsView />}
|
||||
{view === 'setup' && <SetupWizardView />}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LifeBuoy,
|
||||
BarChart3,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LifeBuoy,
|
||||
BarChart3,
|
||||
Rocket,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -57,6 +58,7 @@ const NAV: {
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' },
|
||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BookOpen,
|
||||
Landmark,
|
||||
TrendingUp,
|
||||
ArrowDownLeft,
|
||||
ArrowUpRight,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Link2,
|
||||
Banknote,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatEuroCompact, formatDate, formatDateTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell, Line, LineChart, CartesianGrid } from 'recharts'
|
||||
|
||||
interface VatReturn {
|
||||
id: string
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
periodType: string
|
||||
status: string
|
||||
k81StandardNetCents: number
|
||||
k86ReducedNetCents: number
|
||||
k66TaxFreeCents: number
|
||||
k83TaxStandardCents: number
|
||||
k65TaxReducedCents: number
|
||||
k37InputTaxCents: number
|
||||
outputTaxCents: number
|
||||
inputTaxCents: number
|
||||
payableCents: number
|
||||
submittedAt: string | null
|
||||
elsterRef: string | null
|
||||
acceptedAt: string | null
|
||||
notes: string | null
|
||||
}
|
||||
interface BankTransaction {
|
||||
id: string
|
||||
amountCents: number
|
||||
bookingDate: string
|
||||
valueDate: string
|
||||
partnerName: string | null
|
||||
partnerIban: string | null
|
||||
reference: string | null
|
||||
purpose: string | null
|
||||
matchedStatus: string
|
||||
matchReason: string | null
|
||||
}
|
||||
interface AccountingData {
|
||||
vat: {
|
||||
current: VatReturn | null
|
||||
history: VatReturn[]
|
||||
totalPayableYTD: number
|
||||
stats: { periods: number; accepted: number; pending: number }
|
||||
}
|
||||
eur: {
|
||||
months: { label: string; incomeCents: number; costCents: number; profitCents: number }[]
|
||||
totalIncome: number
|
||||
totalCost: number
|
||||
totalProfit: number
|
||||
costsByCategory: { category: string; cents: number }[]
|
||||
}
|
||||
bank: {
|
||||
transactions: BankTransaction[]
|
||||
stats: { matched: number; suggested: number; unmatched: number; inflowCents: number; outflowCents: number; total: number }
|
||||
}
|
||||
dunning: {
|
||||
overdue: { id: string; number: string; companyName: string; grossCents: number; dueDate: string; daysOverdue: number }[]
|
||||
runs: { id: string; level: number; status: string; feesCents: number; sentAt: string; invoice: { number: string; company: { name: string } } }[]
|
||||
stats: { overdueCount: number; overdueCents: number; dunningSent: number }
|
||||
}
|
||||
}
|
||||
|
||||
const VAT_STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
|
||||
computed: { label: 'Berechnet', cls: 'bg-foreground/50 text-background' },
|
||||
submitted: { label: 'Übermittelt', cls: 'bg-foreground/70 text-background' },
|
||||
accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' },
|
||||
corrected: { label: 'Korrigiert', cls: 'bg-muted text-muted-foreground' },
|
||||
}
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
reise: 'Reise', software: 'Software', hardware: 'Hardware', fremdleistung: 'Fremdleistung', buero: 'Büro', marketing: 'Marketing',
|
||||
}
|
||||
|
||||
export function AccountingView() {
|
||||
const { data, loading, refetch } = useFetch<AccountingData>('/api/aos/accounting')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { vat, eur, bank, dunning } = data
|
||||
|
||||
const submitVat = async (vatId: string) => {
|
||||
setSubmitting(true)
|
||||
const t = toast.loading('Voranmeldung wird an ELSTER übermittelt (ERiC)…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/accounting/vat/${vatId}/submit`, { method: 'PATCH' })
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'submit failed')
|
||||
}
|
||||
const res = await r.json()
|
||||
toast.success(`Akzeptiert — ELSTER-Ref: ${res.elsterRef}`, { id: t })
|
||||
refetch()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Übermittlung fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const matchTx = async (txId: string, invoiceId: string) => {
|
||||
const t = toast.loading('Bankbewegung wird zugeordnet…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/accounting/bank/${txId}/match`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ invoiceId }),
|
||||
})
|
||||
if (!r.ok) throw new Error('match failed')
|
||||
toast.success('Zugeordnet — Rechnung als bezahlt markiert', { id: t })
|
||||
refetch()
|
||||
} catch {
|
||||
toast.error('Zuordnung fehlgeschlagen', { id: t })
|
||||
}
|
||||
}
|
||||
|
||||
const currentVat = vat.current
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="Buchhaltung"
|
||||
description="Alman muhasebe uyumlu: Umsatzsteuer-Voranmeldung (ELSTER), EÜR, Bankabgleich (CAMT.053) und Mahnwesen. GoBD-konform mit Audit-Hash-Kette."
|
||||
/>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">USt-Zahllast (lfd. Monat)</p>
|
||||
<Landmark className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{currentVat ? formatEuroCompact(currentVat.payableCents) : '—'}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{currentVat ? `${MONTH_NAMES[currentVat.periodMonth - 1]} ${currentVat.periodYear}` : 'keine offene Periode'}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">USt YTD (abgeführt)</p>
|
||||
<CheckCircle2 className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(vat.totalPayableYTD)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{vat.stats.accepted} Perioden akzeptiert</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">EÜR Gewinn (6 Mo)</p>
|
||||
<TrendingUp className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(eur.totalProfit)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(eur.totalIncome)} Einnahmen</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Überfällige Posten</p>
|
||||
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(dunning.stats.overdueCents)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{dunning.stats.overdueCount} Rechnungen</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="vat">
|
||||
<TabsList>
|
||||
<TabsTrigger value="vat">Umsatzsteuer</TabsTrigger>
|
||||
<TabsTrigger value="eur">EÜR</TabsTrigger>
|
||||
<TabsTrigger value="bank">Bankabgleich</TabsTrigger>
|
||||
<TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* VAT tab */}
|
||||
<TabsContent value="vat" className="mt-4 space-y-4">
|
||||
{currentVat && (
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
Voranmeldung {MONTH_NAMES[currentVat.periodMonth - 1]} {currentVat.periodYear}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{currentVat.periodType === 'monthly' ? 'Monatlich' : 'Quartalsweise'} ·{' '}
|
||||
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).cls)}>
|
||||
{(VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).label}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
{currentVat.status === 'computed' && (
|
||||
<Button size="sm" className="gap-1.5" onClick={() => submitVat(currentVat.id)} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
|
||||
An ELSTER übermitteln
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{/* Kennzahlen */}
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kennzahlen (UStVA)</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Kennzahl kz="81" label="19% Umsatz (netto)" value={currentVat.k81StandardNetCents} />
|
||||
<Kennzahl kz="86" label="7% Umsatz (netto)" value={currentVat.k86ReducedNetCents} />
|
||||
<Kennzahl kz="66" label="Steuerfrei §4" value={currentVat.k66TaxFreeCents} />
|
||||
<Kennzahl kz="83" label="Steuer 19%" value={currentVat.k83TaxStandardCents} />
|
||||
<Kennzahl kz="65" label="Steuer 7%" value={currentVat.k65TaxReducedCents} />
|
||||
<Kennzahl kz="37" label="Vorsteuer" value={currentVat.k37InputTaxCents} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Calculation */}
|
||||
<div className="space-y-1.5 rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||
<CalcRow label="Umsatzsteuer (Summe 83+65)" value={currentVat.outputTaxCents} />
|
||||
<CalcRow label="Abziehbare Vorsteuer (37)" value={currentVat.inputTaxCents} negative />
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
|
||||
<span>Zahllast / Gutschrift</span>
|
||||
<span className={cn('tabular-nums', currentVat.payableCents < 0 && 'text-muted-foreground')}>
|
||||
{currentVat.payableCents < 0 ? '−' : ''}{formatEuro(Math.abs(currentVat.payableCents))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{currentVat.notes && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">{currentVat.notes}</p>
|
||||
)}
|
||||
<div className="mt-3 rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||
<p className="font-medium text-foreground">ELSTER-Übermittlung (ERiC)</p>
|
||||
<p className="mt-1">Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Übermittlung via ERiC-Schnittstelle. Vor dem Absenden: Vorprüfung jeder Kennzahl mit Herkunftsnachweis.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* VAT history */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Historie ({vat.history.length} Perioden)</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">Periode</th>
|
||||
<th className="px-4 py-2 font-medium text-right">81 (19% netto)</th>
|
||||
<th className="px-4 py-2 font-medium text-right">37 (Vorsteuer)</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Zahllast</th>
|
||||
<th className="px-4 py-2 font-medium">ELSTER-Ref</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{vat.history.map((v) => (
|
||||
<tr key={v.id} className="transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5 text-xs font-medium">{MONTH_NAMES[v.periodMonth - 1]} {v.periodYear}</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k81StandardNetCents)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k37InputTaxCents)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(v.payableCents)}</td>
|
||||
<td className="px-4 py-2.5"><code className="text-[10px] font-mono text-muted-foreground">{v.elsterRef ?? '—'}</code></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).cls)}>
|
||||
{(VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).label}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* EÜR tab */}
|
||||
<TabsContent value="eur" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Einnahmen vs. Ausgaben (6 Monate, netto)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||
<Tooltip
|
||||
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
|
||||
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||
formatter={(v: number, name: string) => [formatEuro(v), name === 'incomeCents' ? 'Einnahmen' : 'Ausgaben']}
|
||||
/>
|
||||
<Bar dataKey="incomeCents" fill="var(--foreground)" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="costCents" fill="var(--muted-foreground)" fillOpacity={0.5} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Gewinnverlauf</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||
formatter={(v: number) => [formatEuro(v), 'Gewinn']}
|
||||
/>
|
||||
<Line type="monotone" dataKey="profitCents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">EÜR-Summe (6 Monate)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5 text-xs">
|
||||
<CalcRow label="Einnahmen (bezahlte Rechnungen, netto)" value={eur.totalIncome} />
|
||||
<CalcRow label="Ausgaben (Aufwendungen, netto)" value={eur.totalCost} negative />
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
|
||||
<span>Gewinn (Einnahmen-Überschuss)</span>
|
||||
<span className="tabular-nums">{formatEuro(eur.totalProfit)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Ausgaben nach Kategorie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{eur.costsByCategory.map((c) => {
|
||||
const max = eur.costsByCategory[0]?.cents ?? 1
|
||||
const pct = Math.round((c.cents / max) * 100)
|
||||
return (
|
||||
<div key={c.category}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
||||
<div className="h-full rounded bg-foreground" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||
<p className="font-medium text-foreground">EÜR (Einnahmen-Überschuss-Rechnung)</p>
|
||||
<p className="mt-1">Für Freiberufler und Kleingewerbe (§ 4 Abs. 3 EStG). Einnahmen bei Ist-Versteuerung nach Zufluss, Ausgaben nach Abfluss. Jahres-EÜR mit AVEÜR an das Finanzamt (Anlage EÜR). AfA und pauschalen werden separat berechnet.</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Bank reconciliation tab */}
|
||||
<TabsContent value="bank" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Zugeordnet</p>
|
||||
<CheckCircle2 className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.matched}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Vorgeschlagen</p>
|
||||
<Link2 className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.suggested}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Zugänge</p>
|
||||
<ArrowDownLeft className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.inflowCents)}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Abgänge</p>
|
||||
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.outflowCents)}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm">Bankbewegungen (CAMT.053 Import)</CardTitle>
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs">
|
||||
<Banknote className="size-3.5" />
|
||||
CAMT.053 importieren
|
||||
</Button>
|
||||
</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">Buchungstag</th>
|
||||
<th className="px-4 py-2 font-medium">Partner</th>
|
||||
<th className="px-4 py-2 font-medium">Verwendungszweck</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Betrag</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{bank.transactions.map((tx) => {
|
||||
const isIncome = tx.amountCents > 0
|
||||
return (
|
||||
<tr key={tx.id} className="transition hover:bg-muted/40">
|
||||
<td className="px-4 py-2.5 text-xs">{formatDate(tx.bookingDate)}</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
<p className="font-medium">{tx.partnerName ?? '—'}</p>
|
||||
{tx.partnerIban && <p className="text-[10px] font-mono text-muted-foreground">{tx.partnerIban}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="truncate text-xs">{tx.purpose ?? tx.reference ?? '—'}</p>
|
||||
{tx.matchReason && <p className="text-[10px] text-muted-foreground">{tx.matchReason}</p>}
|
||||
</td>
|
||||
<td className={cn('px-4 py-2.5 text-right text-xs font-semibold tabular-nums', isIncome ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{isIncome ? '+' : ''}{formatEuro(tx.amountCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className={cn(
|
||||
'text-[10px]',
|
||||
tx.matchedStatus === 'matched' && 'border-foreground/40',
|
||||
tx.matchedStatus === 'suggested' && 'border-foreground/30',
|
||||
)}>
|
||||
{tx.matchedStatus === 'matched' ? 'Zugeordnet' : tx.matchedStatus === 'suggested' ? 'Vorschlag' : 'Offen'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{tx.matchedStatus === 'suggested' && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-[11px]"
|
||||
onClick={() => matchTx(tx.id, 'demo')}
|
||||
>
|
||||
<CheckCircle2 className="size-3" />Zuordnen
|
||||
</Button>
|
||||
)}
|
||||
{tx.matchedStatus === 'unmatched' && (
|
||||
<span className="text-[10px] text-muted-foreground">—</span>
|
||||
)}
|
||||
{tx.matchedStatus === 'matched' && (
|
||||
<CheckCircle2 className="size-4 text-foreground" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Dunning tab */}
|
||||
<TabsContent value="dunning" className="mt-4 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||
Überfällige Rechnungen ({dunning.overdue.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dunning.overdue.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">Keine überfälligen Rechnungen.</p>
|
||||
)}
|
||||
{dunning.overdue.map((inv) => (
|
||||
<div key={inv.id} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-[11px] font-mono">{inv.number}</code>
|
||||
<Badge variant="outline" className="text-[10px]">{inv.daysOverdue} d überfällig</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs">{inv.companyName}</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Fällig: {formatDate(inv.dueDate)}</span>
|
||||
<span className="text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Mahnlauf-Historie ({dunning.runs.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{dunning.runs.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">Keine Mahnungen versendet.</p>
|
||||
)}
|
||||
{dunning.runs.map((run) => (
|
||||
<div key={run.id} className="rounded-md border border-border p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-[10px]">Mahnstufe {run.level}</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{formatDateTime(run.sentAt)}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs">{run.invoice.company.name} · <code className="font-mono text-[10px]">{run.invoice.number}</code></p>
|
||||
{run.feesCents > 0 && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">Mahngebühren: {formatEuro(run.feesCents)}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Mahnwesen (3 Stufen)</p>
|
||||
<p className="mt-1">Stufe 1: Zahlungserinnerung (kostenlos) · Stufe 2: Mahnung mit Gebühr (2,50 €) · Stufe 3: Letzte Mahnung + Verzugszinsen (§ 288 BGB, 5 % p.a.). Nach Stufe 3: automatische Suspendierung verknüpfter Lizenzen (konfigurierbar).</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kennzahl({ kz, label, value }: { kz: string; label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-[10px] font-mono font-bold">{kz}</code>
|
||||
<span className="text-[9px] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(value)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalcRow({ label, value, negative }: { label: string; value: number; negative?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="tabular-nums">{negative ? '−' : ''}{formatEuro(value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
|
||||
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
|
||||
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
|
||||
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
|
||||
accounting: { label: 'Buchhaltung', desc: 'USt-Voranmeldung (ELSTER), EÜR, Bankabgleich, Mahnwesen.', phase: 'Phase 6' },
|
||||
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
|
||||
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
|
||||
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
|
||||
|
||||
Reference in new issue
Block a user