1bc42b25-8a92-4b1e-9e45-a4dba3d1ac57

This commit is contained in:
Z User committed 2026-10-26 14:41:13 +00:00
1 parent 5855f699f3
commit d4c3b0f5ca
21 files changed
+1140

No files matched your search

+4
View File
@@ -12,6 +12,8 @@ import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses'
import { ContractsView } from './views/contracts'
import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance'
import { AuditView } from './views/audit'
import { SettingsView } from './views/settings'
@@ -45,6 +47,8 @@ export function AppShell() {
{view === 'leads' && <LeadsView />}
{view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />}
{view === 'contracts' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
{view === 'finance' && <FinanceView />}
{view === 'audit' && <AuditView />}
{view === 'settings' && <SettingsView />}
+4
View File
@@ -14,6 +14,8 @@ import {
ScrollText,
Search as SearchIcon,
ArrowRight,
FileSignature,
Wallet,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -33,6 +35,8 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
+4
View File
@@ -12,6 +12,8 @@ import {
Search,
Bot,
ScrollText,
FileSignature,
Wallet,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
@@ -41,6 +43,8 @@ const NAV: {
group: 'Betrieb',
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
],
+2
View File
@@ -21,6 +21,8 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
+340
View File
@@ -0,0 +1,340 @@
'use client'
import { useState } from 'react'
import {
FileSignature,
Plus,
CalendarClock,
ShieldCheck,
PenLine,
FileText,
AlertTriangle,
Loader2,
X,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface Contract {
id: string
number: string
companyId: string
companyName: string
type: string
title: string
status: string
valueCents: number
startDate: string | null
endDate: string | null
signedAt: string | null
signedBy: string | null
signedIp: string | null
signToken: string | null
notes: string | null
createdAt: string
}
interface ContractsData {
contracts: Contract[]
stats: {
total: number
active: number
draft: number
sent: number
signed: number
expired: number
expiringSoon: number
totalValueCents: number
}
}
const TYPE_META: Record<string, { label: string; desc: string }> = {
'werkvertrag': { label: 'Werkvertrag', desc: '§ 631 BGB — Erfolgsschuld, Abnahme, Gewährleistung' },
'dienstvertrag': { label: 'Dienstvertrag', desc: '§ 611 BGB — Tätigkeits schuld, kein Erfolgsversprechen' },
'wartung-sla': { label: 'Wartungs-/SLA-Vertrag', desc: 'Service-Level-Agreement, Reaktionszeiten' },
'avv': { label: 'AVV (Art. 28 DSGVO)', desc: 'Auftragsverarbeitungsvertrag — DSGVO-Pflicht bei Datenverarbeitung' },
'nda': { label: 'NDA', desc: 'Geheimhaltungsvereinbarung' },
'dpa': { label: 'DPA', desc: 'Data Processing Agreement' },
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/50 text-background' },
signed: { label: 'Unterzeichnet', cls: 'bg-foreground/70 text-background' },
active: { label: 'Aktiv', cls: 'bg-foreground text-background' },
expired: { label: 'Abgelaufen', cls: 'bg-muted text-muted-foreground' },
terminated: { label: 'Gekündigt', cls: 'bg-muted text-muted-foreground' },
}
export function ContractsView() {
const { data, loading, refetch } = useFetch<ContractsData>('/api/aos/contracts')
const [selected, setSelected] = useState<Contract | null>(null)
const [signing, setSigning] = useState(false)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-40" />
</div>
)
}
const { contracts, stats } = data
const sign = async (contract: Contract) => {
setSigning(true)
const t = toast.loading('E-Signatur wird erzeugt…')
try {
const r = await fetch(`/api/aos/contracts/${contract.id}/sign`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ signedBy: contract.companyName + ' (Portal)' }),
})
if (!r.ok) throw new Error('sign failed')
toast.success('Vertrag unterzeichnet (Signatur-Token generiert)', { id: t })
setSelected(null)
refetch()
} catch {
toast.error('Signatur fehlgeschlagen', { id: t })
} finally {
setSigning(false)
}
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Verträge"
description="Vertragsarten: Werkvertrag, Dienstvertrag, Wartung/SLA, AVV (Art. 28 DSGVO), NDA. E-Signatur-Hook, Ablauf-Tracking, Verlängerung."
actions={
<Button size="sm">
<Plus className="size-4" />
Vertrag
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktive Verträge</p>
<FileSignature className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.active}</p>
<p className="text-[10px] text-muted-foreground">{formatEuro(stats.totalValueCents)} Volumen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Entwürfe / Versendet</p>
<FileText className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.draft + stats.sent}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Läuft bald ab</p>
<CalendarClock className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon}</p>
<p className="text-[10px] text-muted-foreground">≤ 30 Tage</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Unterzeichnet</p>
<PenLine className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.signed}</p>
</Card>
</div>
{/* DSGVO notice */}
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs">
<ShieldCheck className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">AVV-Pflicht bei Auftragsverarbeitung (Art. 28 DSGVO)</p>
<p className="mt-0.5 text-muted-foreground">
Sobald personenbezogene Daten im Auftrag verarbeitet werden (z. B. Hosting, CRM, Newsletter), ist ein Auftragsverarbeitungsvertrag zwingend. Die Verträge werden hier zentral verwaltet und bei Ablauf erneuert.
</p>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Verträge ({contracts.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Titel</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium text-right">Wert</th>
<th className="px-4 py-2 font-medium">Zeitraum</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{contracts.map((c) => {
const st = STATUS_META[c.status] ?? STATUS_META.draft
const tm = TYPE_META[c.type] ?? { label: c.type, desc: '' }
const expiring = c.endDate && c.status === 'active' && daysUntil(c.endDate) <= 30 && daysUntil(c.endDate) >= 0
const expired = c.status === 'expired' || (c.endDate && daysUntil(c.endDate) < 0 && c.status === 'active')
return (
<tr
key={c.id}
className="cursor-pointer transition hover:bg-muted/40"
onClick={() => setSelected(c)}
>
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{c.number}</code></td>
<td className="px-4 py-2.5">
<Badge variant="outline" className="text-[10px]">{tm.label}</Badge>
</td>
<td className="px-4 py-2.5 text-xs font-medium">{c.title}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{c.companyName}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{c.valueCents ? formatEuro(c.valueCents) : '—'}</td>
<td className="px-4 py-2.5 text-xs">
{c.startDate ? (
<span className={cn(expiring && 'text-foreground', expired && 'text-muted-foreground')}>
{formatDate(c.startDate)}
{c.endDate && ` → ${formatDate(c.endDate)}`}
{expiring && <span className="ml-1 text-[10px]">({daysUntil(c.endDate!)} d)</span>}
</span>
) : '—'}
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('gap-1 text-[10px]', st.cls)}>
{expiring && c.status === 'active' && <AlertTriangle className="size-2.5" />}
{st.label}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Contract detail dialog */}
<Dialog open={!!selected} onOpenChange={(o) => !o && setSelected(null)}>
<DialogContent className="sm:max-w-2xl">
{selected && (() => {
const tm = TYPE_META[selected.type] ?? { label: selected.type, desc: '' }
const st = STATUS_META[selected.status] ?? STATUS_META.draft
const canSign = selected.status === 'draft' || selected.status === 'sent'
return (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="font-mono">{selected.number}</DialogTitle>
<DialogDescription className="mt-1">{selected.title}</DialogDescription>
</div>
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Type */}
<div className="rounded-md border border-border p-3">
<div className="flex items-center gap-2">
<FileSignature className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{tm.label}</span>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{tm.desc}</p>
</div>
{/* Meta */}
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-3">
<Meta label="Kunde" value={selected.companyName} />
<Meta label="Wert" value={selected.valueCents ? formatEuro(selected.valueCents) : '—'} />
<Meta label="Vertragsbeginn" value={selected.startDate ? formatDate(selected.startDate) : '—'} />
<Meta label="Vertragsende" value={selected.endDate ? formatDate(selected.endDate) : 'Unbefristet'} />
{selected.endDate && selected.status === 'active' && (
<Meta label="Ablauf in" value={`${daysUntil(selected.endDate)} Tagen`} />
)}
</div>
{/* Signature block */}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<PenLine className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wide">E-Signatur</span>
</div>
{selected.signedAt ? (
<div className="mt-2 space-y-1 text-[11px]">
<SigRow label="Unterzeichnet am" value={formatDate(selected.signedAt)} />
<SigRow label="Unterzeichner" value={selected.signedBy ?? '—'} />
<SigRow label="IP (Portal)" value={selected.signedIp ?? '—'} mono />
<SigRow label="Signatur-Token" value={selected.signToken ?? '—'} mono />
</div>
) : (
<p className="mt-2 text-[11px] text-muted-foreground">
Noch nicht unterzeichnet. Versand an Kunden-Portal zur E-Signatur möglich.
</p>
)}
</div>
{selected.notes && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Notizen</p>
<p className="mt-1 text-xs">{selected.notes}</p>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setSelected(null)}>Schließen</Button>
{canSign && (
<Button onClick={() => sign(selected)} disabled={signing} className="gap-1.5">
{signing ? <Loader2 className="size-3.5 animate-spin" /> : <PenLine className="size-3.5" />}
E-Signatur anfordern
</Button>
)}
</DialogFooter>
</>
)
})()}
</DialogContent>
</Dialog>
</div>
)
}
function Meta({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-0.5 text-xs font-medium">{value}</p>
</div>
)
}
function SigRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex gap-2">
<span className="shrink-0 text-muted-foreground">{label}:</span>
<span className={cn('font-medium', mono && 'font-mono text-[10px]')}>{value}</span>
</div>
)
}
+328
View File
@@ -0,0 +1,328 @@
'use client'
import { useState } from 'react'
import {
Wallet,
Plus,
Repeat,
FileCheck2,
ArrowRight,
Loader2,
Receipt,
TrendingUp,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
interface Expense {
id: string
number: string
categoryId: string
vendor: string | null
description: string
amountCents: number
taxBp: number
grossCents: number
date: string
billable: boolean
markupPct: number
recurring: boolean
recurringInterval: string | null
datevExported: boolean
status: string
companyName: string | null
projectName: string | null
}
interface ExpensesData {
expenses: Expense[]
stats: {
total: number
billable: number
recurring: number
billableCents: number
totalGrossCents: number
invoiced: number
pendingApproval: number
datevExported: number
}
report: {
byCategory: Record<string, { count: number; grossCents: number }>
monthly: { label: string; cents: number }[]
}
}
const CATEGORY_LABELS: Record<string, string> = {
reise: 'Reise',
software: 'Software',
hardware: 'Hardware',
fremdleistung: 'Fremdleistung',
buero: 'Büro',
marketing: 'Marketing',
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
recorded: { label: 'Erfasst', cls: 'bg-muted text-foreground' },
approved: { label: 'Freigegeben', cls: 'bg-foreground/50 text-background' },
invoiced: { label: 'Berechnet', cls: 'bg-foreground text-background' },
rejected: { label: 'Abgelehnt', cls: 'bg-muted text-muted-foreground' },
}
export function ExpensesView() {
const { data, loading, refetch } = useFetch<ExpensesData>('/api/aos/expenses')
const [converting, setConverting] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-40" />
</div>
)
}
const { expenses, stats, report } = data
const convert = async (e: Expense) => {
setConverting(e.id)
const t = toast.loading('Ausgabe wird in Rechnung umgewandelt…')
try {
const r = await fetch(`/api/aos/expenses/${e.id}/convert`, { method: 'POST' })
if (!r.ok) {
const err = await r.json().catch(() => ({}))
throw new Error(err.error || 'convert failed')
}
const data = await r.json()
toast.success(`Rechnung ${data.invoice.number} erstellt (${formatEuro(data.invoice.grossCents)})`, { id: t })
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Umwandlung fehlgeschlagen', { id: t })
} finally {
setConverting(null)
}
}
const categoryData = Object.entries(report.byCategory)
.map(([cat, v]) => ({ category: CATEGORY_LABELS[cat] ?? cat, cents: v.grossCents, count: v.count }))
.sort((a, b) => b.cents - a.cents)
const maxCat = Math.max(...categoryData.map((c) => c.cents), 1)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Ausgaben"
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
actions={
<Button size="sm">
<Plus className="size-4" />
Ausgabe
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Gesamtausgaben (brutto)</p>
<Wallet className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalGrossCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Belastbar (offen)</p>
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.billableCents)}</p>
<p className="text-[10px] text-muted-foreground">{stats.billable - stats.invoiced} Posten offen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Wiederkehrend</p>
<Repeat className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.recurring}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">DATEV exportiert</p>
<FileCheck2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.datevExported}</p>
<p className="text-[10px] text-muted-foreground">{stats.pendingApproval} warten auf Freigabe</p>
</Card>
</div>
<Tabs defaultValue="list">
<TabsList>
<TabsTrigger value="list">Ausgaben</TabsTrigger>
<TabsTrigger value="report">Ausgabenreport</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ausgaben ({expenses.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<th className="px-4 py-2 font-medium">Beschreibung</th>
<th className="px-4 py-2 font-medium">Lieferant</th>
<th className="px-4 py-2 font-medium">Bezug</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{expenses.map((e) => {
const st = STATUS_META[e.status] ?? STATUS_META.recorded
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
return (
<tr key={e.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
{e.recurring && (
<Badge variant="outline" className="ml-1 gap-0.5 text-[9px]">
<Repeat className="size-2.5" />{e.recurringInterval}
</Badge>
)}
</td>
<td className="px-4 py-2.5 text-xs">{e.description}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.vendor ?? '—'}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">
{e.companyName && <div>{e.companyName}</div>}
{e.projectName && <div className="text-[10px]">→ {e.projectName}</div>}
{e.billable && <Badge variant="outline" className="mt-0.5 text-[9px]">+{e.markupPct}%</Badge>}
</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.grossCents)}</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</td>
<td className="px-4 py-2.5">
{canConvert ? (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 text-[11px]"
onClick={() => convert(e)}
disabled={converting === e.id}
>
{converting === e.id ? <Loader2 className="size-3 animate-spin" /> : <ArrowRight className="size-3" />}
Zur Rechnung
</Button>
) : e.status === 'invoiced' ? (
<Badge variant="outline" className="gap-1 text-[9px]">
<Receipt className="size-2.5" />Berechnet
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="report" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* By category */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<TrendingUp className="size-3.5 text-muted-foreground" />
Nach Kategorie
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{categoryData.map((c) => (
<div key={c.category}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{c.category} <span className="text-muted-foreground">({c.count})</span></span>
<span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded bg-muted">
<div className="h-full rounded bg-foreground transition-all duration-500" style={{ width: `${(c.cents / maxCat) * 100}%` }} />
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Monthly trend */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Monatlicher Verlauf (6 Monate, brutto)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={report.monthly} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [formatEuro(v), 'Brutto']}
/>
<Bar dataKey="cents" radius={[3, 3, 0, 0]}>
{report.monthly.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / report.monthly.length) * 0.6} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* DATEV info */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<FileCheck2 className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">DATEV-Export für Steuerberater</p>
<p className="mt-1 text-xs text-muted-foreground">
Freigegebene Ausgaben werden als DATEV-kompatible CSV exportiert (Buchungsstapel ERP). GoBD-konform mit Beleg-Hash (SHA-256, content-addressed receipts). Eigenbelege und durchlaufende Posten getrennt.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px]">CSV (DATEV Format v5)</Badge>
<Badge variant="outline" className="text-[10px]">Beleg-Hash: SHA-256</Badge>
<Badge variant="outline" className="text-[10px]">Eigenbeleg / Fremdbeleg</Badge>
</div>
</div>
<Button size="sm" variant="outline" className="gap-1.5">
<FileCheck2 className="size-3.5" />
Export
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+2
View File
@@ -38,6 +38,8 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
leads: { label: 'Leads Finder', desc: 'Evidenzbasierte Website-Audits + LLM-Erklärung.', phase: 'Phase 2' },
projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' },
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
contracts: { label: 'Verträge', desc: 'Werkvertrag, AVV (Art. 28 DSGVO), NDA, SLA mit E-Signatur.', phase: 'Phase 5' },
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },