e70ae79d-5898-4c55-8ceb-d2c701ce08b4

This commit is contained in:
Z User committed 2026-10-26 14:18:39 +00:00
1 parent d226dabba3
commit 5855f699f3
53 files changed
+1911 -44

No files matched your search

+6 -1
View File
@@ -5,13 +5,15 @@ import { SidebarNav } from './sidebar-nav'
import { TopBar } from './top-bar'
import { Footer } from './footer'
import { CommandMenu } from './command-menu'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { CompanyDrawer } from './company-drawer'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { DashboardView } from './views/dashboard'
import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses'
import { FinanceView } from './views/finance'
import { AuditView } from './views/audit'
import { SettingsView } from './views/settings'
export function AppShell() {
@@ -29,6 +31,7 @@ export function AppShell() {
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-72 p-0">
<SheetTitle className="sr-only">Navigation</SheetTitle>
<SidebarNav />
</SheetContent>
</Sheet>
@@ -43,12 +46,14 @@ export function AppShell() {
{view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />}
{view === 'finance' && <FinanceView />}
{view === 'audit' && <AuditView />}
{view === 'settings' && <SettingsView />}
</main>
<Footer />
</div>
<CommandMenu />
<CompanyDrawer />
</div>
)
}
+76 -5
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
LayoutDashboard,
@@ -11,6 +11,9 @@ import {
Receipt,
Settings2,
RotateCcw,
ScrollText,
Search as SearchIcon,
ArrowRight,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -31,14 +34,26 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ 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' },
]
interface SearchResult {
group: string
view: ViewId
id: string
primary: string
secondary?: string
tertiary?: string
}
export function CommandMenu() {
const { commandOpen, setCommandOpen, setView } = useAos()
const { commandOpen, setCommandOpen, setView, setSelectedCompany } = useAos()
const router = useRouter()
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [searching, setSearching] = useState(false)
// keyboard shortcut cmd+k
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
@@ -50,11 +65,47 @@ export function CommandMenu() {
return () => window.removeEventListener('keydown', handler)
}, [commandOpen, setCommandOpen])
// debounced global search
useEffect(() => {
if (!commandOpen) {
setQuery('')
setResults([])
return
}
const q = query.trim()
if (q.length < 2) {
setResults([])
return
}
setSearching(true)
const t = setTimeout(async () => {
try {
const r = await fetch(`/api/aos/search?q=${encodeURIComponent(q)}`)
if (!r.ok) throw new Error('search failed')
const data = (await r.json()) as { results: SearchResult[] }
setResults(data.results)
} catch {
setResults([])
} finally {
setSearching(false)
}
}, 200)
return () => clearTimeout(t)
}, [query, commandOpen])
const go = (v: ViewId) => {
setView(v)
setCommandOpen(false)
}
const goResult = (r: SearchResult) => {
if (r.view === 'crm' && r.group === 'Firmen') {
setSelectedCompany(r.id)
}
setView(r.view)
setCommandOpen(false)
}
const reseed = async () => {
setCommandOpen(false)
const t = toast.loading('Datenbank wird zurückgesetzt…')
@@ -70,9 +121,29 @@ export function CommandMenu() {
return (
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Modul, Aktion oder Seite suchen…" />
<CommandInput placeholder="Suche Firmen, Deals, Leads, Projekte, Rechnungen…" value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CommandEmpty>{searching ? 'Suche läuft…' : query.length > 1 ? 'Keine Treffer.' : 'Tippe mindestens 2 Zeichen für globale Suche.'}</CommandEmpty>
{results.length > 0 && (
<>
<CommandGroup heading={`Suchergebnisse (${results.length})`}>
{results.slice(0, 12).map((r) => (
<CommandItem key={`${r.group}-${r.id}`} value={`${r.primary} ${r.secondary ?? ''} ${r.tertiary ?? ''}`} onSelect={() => goResult(r)}>
<SearchIcon className="size-4 text-muted-foreground" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{r.primary}</span>
{r.secondary && <span className="truncate text-xs text-muted-foreground">· {r.secondary}</span>}
</div>
<span className="shrink-0 text-[10px] text-muted-foreground">{r.group}</span>
<ArrowRight className="size-3 shrink-0 text-muted-foreground" />
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Navigation">
{VIEWS.map((v) => {
const Icon = v.icon
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useFetch } from '@/hooks/use-fetch'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { formatEuro, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import {
Building2,
Globe,
Mail,
Phone,
MapPin,
FileText,
Receipt,
FolderKanban,
KeyRound,
ShieldCheck,
Clock,
} from 'lucide-react'
interface CompanyDetail {
company: {
id: string
name: string
legalForm: string | null
vatId: string | null
registerCourt: string | null
registerNumber: string | null
website: string | null
email: string | null
phone: string | null
city: string | null
zip: string | null
street: string | null
source: string | null
tags: string | null
createdAt: string
}
persons: { id: string; firstName: string; lastName: string; email: string | null; role: string | null; phone: string | null }[]
deals: { id: string; title: string; stage: string; valueCents: number; probability: number }[]
projects: { id: string; name: string; status: string; type: string }[]
invoices: { id: string; number: string; status: string; grossCents: number; issueDate: string }[]
licenses: { id: string; key: string; product: string; status: string; activations: { id: string }[] }[]
leads: { id: string; companyName: string; score: number; status: string }[]
timeline: { kind: string; date: string; title: string; meta: string; valueCents: number | null }[]
consent: {
legalBasis: string
purpose: string
source: string
recordedAt: string
evidence: string
}
}
export function CompanyDrawer() {
const { selectedCompanyId, setSelectedCompany } = useAos()
const { data, loading } = useFetch<CompanyDetail>(
selectedCompanyId ? `/api/aos/crm/companies/${selectedCompanyId}` : null
)
return (
<Sheet open={!!selectedCompanyId} onOpenChange={(o) => !o && setSelectedCompany(null)}>
<SheetContent side="right" className="w-full overflow-hidden p-0 sm:max-w-2xl">
<SheetTitle className="sr-only">Firmendetails</SheetTitle>
{loading || !data ? (
<div className="space-y-4 p-6">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-40 w-full" />
</div>
) : (
<>
<SheetHeader className="border-b border-border px-6 pb-4 pt-6">
<SheetTitle className="flex items-center gap-2 text-left">
<Building2 className="size-5 text-muted-foreground" />
<span>{data.company.name}</span>
{data.company.legalForm && (
<Badge variant="outline" className="text-[10px]">{data.company.legalForm}</Badge>
)}
</SheetTitle>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
{data.company.website && (
<a href={`https://${data.company.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground">
<Globe className="size-3" />{data.company.website}
</a>
)}
{data.company.email && <span className="flex items-center gap-1"><Mail className="size-3" />{data.company.email}</span>}
{data.company.phone && <span className="flex items-center gap-1"><Phone className="size-3" />{data.company.phone}</span>}
{data.company.city && <span className="flex items-center gap-1"><MapPin className="size-3" />{data.company.zip} {data.company.city}</span>}
</div>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-9rem)]">
<div className="space-y-5 p-6">
{/* Legal */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Stammdaten</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<Field label="USt-IdNr." value={data.company.vatId} mono />
<Field label="Registergericht" value={data.company.registerCourt} />
<Field label="HRB-Nummer" value={data.company.registerNumber} mono />
<Field label="Quelle" value={data.company.source} />
</div>
{data.company.tags && (
<div className="mt-2 flex flex-wrap gap-1">
{data.company.tags.split(',').map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
)}
</section>
{/* DSGVO consent */}
<section className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<ShieldCheck className="size-4 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wide">DSGVO-Konsens</h3>
</div>
<div className="mt-2 space-y-1.5 text-[11px]">
<ConsentRow label="Rechtsgrundlage" value={data.consent.legalBasis} />
<ConsentRow label="Zweck" value={data.consent.purpose} />
<ConsentRow label="Quelle" value={data.consent.source} />
<ConsentRow label="Nachweis" value={data.consent.evidence} />
<ConsentRow label="Erfasst am" value={formatDate(data.consent.recordedAt)} />
</div>
</section>
{/* Persons */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Ansprechpartner ({data.persons.length})
</h3>
<div className="space-y-1.5">
{data.persons.map((p) => (
<div key={p.id} className="flex items-center justify-between rounded-md border border-border p-2 text-xs">
<div>
<p className="font-medium">{p.firstName} {p.lastName}</p>
<p className="text-[10px] text-muted-foreground">{p.role ?? '—'}</p>
</div>
<div className="text-right text-[10px] text-muted-foreground">
{p.email && <p>{p.email}</p>}
</div>
</div>
))}
{data.persons.length === 0 && <Empty>No persons.</Empty>}
</div>
</section>
{/* Timeline */}
<section>
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Clock className="size-3.5" />
Timeline
</h3>
<div className="space-y-1.5">
{data.timeline.map((t, i) => {
const Icon = t.kind === 'deal' ? FileText : t.kind === 'invoice' ? Receipt : t.kind === 'project' ? FolderKanban : KeyRound
return (
<div key={i} className="flex items-start gap-2.5 rounded-md border border-border p-2">
<Icon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-medium">{t.title}</p>
{t.valueCents ? <span className="shrink-0 text-[10px] tabular-nums">{formatEuro(t.valueCents)}</span> : null}
</div>
<p className="text-[10px] text-muted-foreground">
{formatDate(t.date)} · <Badge variant="outline" className="ml-0.5 text-[9px] capitalize">{t.meta}</Badge>
</p>
</div>
</div>
)
})}
{data.timeline.length === 0 && <Empty>Noch keine Aktivität.</Empty>}
</div>
</section>
{/* Licenses + Invoices quick links */}
<section className="grid grid-cols-2 gap-3">
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Lizenzen</h3>
<div className="space-y-1.5">
{data.licenses.map((l) => (
<div key={l.id} className="rounded-md border border-border p-2">
<p className="text-[11px] font-medium">{l.product}</p>
<code className="text-[9px] font-mono text-muted-foreground">{l.key}</code>
</div>
))}
{data.licenses.length === 0 && <Empty>Keine Lizenzen.</Empty>}
</div>
</div>
<div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Rechnungen</h3>
<div className="space-y-1.5">
{data.invoices.slice(0, 5).map((i) => (
<div key={i.id} className="rounded-md border border-border p-2">
<div className="flex items-center justify-between">
<code className="text-[10px] font-mono">{i.number}</code>
<Badge variant="outline" className="text-[9px]">{i.status}</Badge>
</div>
<p className="mt-0.5 text-[10px] tabular-nums text-muted-foreground">{formatEuro(i.grossCents)}</p>
</div>
))}
{data.invoices.length === 0 && <Empty>Keine Rechnungen.</Empty>}
</div>
</div>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
)
}
function Field({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
return (
<div className="rounded-md border border-border p-2">
<p className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className={`mt-0.5 font-medium ${mono ? 'font-mono text-[11px]' : 'text-xs'}`}>{value ?? '—'}</p>
</div>
)
}
function ConsentRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-2">
<span className="shrink-0 text-muted-foreground">{label}:</span>
<span className="font-medium">{value}</span>
</div>
)
}
function Empty({ children }: { children: React.ReactNode }) {
return <p className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">{children}</p>
}
+122
View File
@@ -0,0 +1,122 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
interface Company { id: string; name: string }
interface Props {
open: boolean
onOpenChange: (o: boolean) => void
companies: Company[]
onCreated: () => void
}
const STAGES = [
{ value: 'lead', label: 'Lead' },
{ value: 'qualified', label: 'Qualifiziert' },
{ value: 'proposal', label: 'Angebot' },
{ value: 'negotiation', label: 'Verhandlung' },
]
export function CreateDealDialog({ open, onOpenChange, companies, onCreated }: Props) {
const [companyId, setCompanyId] = useState('')
const [title, setTitle] = useState('')
const [valueEuro, setValueEuro] = useState('')
const [stage, setStage] = useState('lead')
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open && companies[0] && !companyId) setCompanyId(companies[0].id)
}, [open, companies, companyId])
const submit = async () => {
if (!companyId) { toast.error('Firma auswählen'); return }
if (!title.trim()) { toast.error('Titel eingeben'); return }
const valueCents = Math.round((parseFloat(valueEuro.replace(',', '.')) || 0) * 100)
setSaving(true)
const t = toast.loading('Deal wird angelegt…')
try {
const r = await fetch('/api/aos/crm/deals', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ companyId, title: title.trim(), valueCents, stage }),
})
if (!r.ok) throw new Error('create failed')
toast.success('Deal angelegt', { id: t })
onOpenChange(false)
setTitle(''); setValueEuro('')
onCreated()
} catch {
toast.error('Anlegen fehlgeschlagen', { id: t })
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Neuer Deal</DialogTitle>
<DialogDescription>Legt einen Deal in der Pipeline an. Audit-Protokoll wird automatisch geschrieben.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Firma</Label>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
<SelectContent>
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Titel</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="z.B. Website-Relaunch" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Wert (€)</Label>
<Input value={valueEuro} onChange={(e) => setValueEuro(e.target.value)} placeholder="9.800" inputMode="decimal" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">Phase</Label>
<Select value={stage} onValueChange={setStage}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{STAGES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={saving} className="gap-1.5">
{saving && <Loader2 className="size-3.5 animate-spin" />}
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+216
View File
@@ -0,0 +1,216 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Loader2, FileCheck2, Lock, ShieldCheck } from 'lucide-react'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/70 text-background' },
paid: { label: 'Bezahlt', cls: 'bg-foreground text-background' },
overdue: { label: 'Überfällig', cls: 'bg-foreground text-background' },
cancelled: { label: 'Storniert', cls: 'bg-muted text-muted-foreground' },
storno: { label: 'Storno', cls: 'bg-muted text-muted-foreground' },
}
const TYPE_LABELS: Record<string, string> = {
final: 'Schlussrechnung',
partial: 'Abschlagszahlung',
recurring: 'Wiederkehrend',
credit: 'Gutschrift',
}
interface Props {
invoice: Invoice | null
open: boolean
onOpenChange: (o: boolean) => void
onFinalized: () => void
}
export function InvoiceDetailDialog({ invoice, open, onOpenChange, onFinalized }: Props) {
const [finalizing, setFinalizing] = useState(false)
const [wantXrechnung, setWantXrechnung] = useState(false)
if (!invoice) return null
const st = STATUS_META[invoice.status] ?? STATUS_META.draft
const dueDays = daysUntil(invoice.dueDate)
const isDraft = invoice.status === 'draft'
const finalize = async () => {
setFinalizing(true)
const t = toast.loading('Rechnung wird finalisiert (E-Rechnung generiert)…')
try {
const r = await fetch(`/api/aos/finance/${invoice.id}/finalize`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ zugferd: true, xrechnung: wantXrechnung }),
})
if (!r.ok) throw new Error('finalize failed')
toast.success('Rechnung finalisiert und versendet', { id: t })
onOpenChange(false)
onFinalized()
} catch {
toast.error('Finalisierung fehlgeschlagen', { id: t })
} finally {
setFinalizing(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3">
<div>
<DialogTitle className="flex items-center gap-2 font-mono">
{invoice.number}
</DialogTitle>
<DialogDescription className="mt-1">
{invoice.company.name} · {TYPE_LABELS[invoice.type] ?? invoice.type}
</DialogDescription>
</div>
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Meta */}
<div className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<Meta label="Rechnungsdatum" value={formatDate(invoice.issueDate)} />
<Meta label="Fällig" value={formatDate(invoice.dueDate)} sub={dueDays < 0 && invoice.status === 'sent' ? `${Math.abs(dueDays)} d überfällig` : undefined} />
<Meta label="Bezahlt am" value={invoice.paidDate ? formatDate(invoice.paidDate) : '—'} />
<Meta label="Steuersatz" value={`${(invoice.taxBp / 100).toFixed(0)} %`} />
</div>
{/* Lines */}
<div className="rounded-md border border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2 text-[10px] uppercase tracking-wide text-muted-foreground">
Rechnungspositionen ({invoice.lines.length})
</div>
<div className="divide-y divide-border">
{invoice.lines.map((l) => (
<div key={l.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs font-medium">{l.description}</p>
<p className="text-[10px] text-muted-foreground">{l.quantity} × {formatEuro(l.unitCents)}</p>
</div>
<p className="shrink-0 text-xs font-semibold tabular-nums">{formatEuro(l.totalCents)}</p>
</div>
))}
</div>
<Separator />
<div className="space-y-1 px-3 py-2.5 text-xs">
<Row label="Netto" value={formatEuro(invoice.netCents)} />
<Row label={`USt (${(invoice.taxBp / 100).toFixed(0)} %)`} value={formatEuro(invoice.grossCents - invoice.netCents)} />
<Separator className="my-1" />
<Row label="Brutto" value={formatEuro(invoice.grossCents)} strong />
</div>
</div>
{/* E-Rechnung status */}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<FileCheck2 className="size-4 text-muted-foreground" />
<p className="text-xs font-semibold">E-Rechnung-Status</p>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant={invoice.zugferd ? 'default' : 'outline'} className="text-[10px]">ZUGFeRD {invoice.zugferd ? '✓' : '—'}</Badge>
<Badge variant={invoice.xrechnung ? 'default' : 'outline'} className="text-[10px]">XRechnung {invoice.xrechnung ? '✓' : '—'}</Badge>
<Badge variant={invoice.datevExported ? 'default' : 'outline'} className="text-[10px]">DATEV {invoice.datevExported ? '✓' : '—'}</Badge>
</div>
<p className="mt-2 text-[10px] text-muted-foreground">
EN 16931 validiert via <code className="rounded bg-muted px-1 py-0.5 font-mono">@e-invoice-eu/core</code>. GoBD: Finalisierung sperrt nachträgliches Editieren (Storno via neue Zeile).
</p>
</div>
{/* Dunning */}
{invoice.status === 'overdue' && (
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
<p className="font-medium">Mahnstufe 1 fällig</p>
<p className="mt-0.5 text-muted-foreground">Überfällig seit {Math.abs(dueDays)} Tagen. Automatisches Mahnwesen konfigurierbar (3 Stufen → Suspendierung verknüpfter Lizenz).</p>
</div>
)}
</div>
<DialogFooter className="gap-2">
{isDraft ? (
<>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={wantXrechnung}
onChange={(e) => setWantXrechnung(e.target.checked)}
className="size-3.5 rounded border-border"
/>
XRechnung (öffentlicher Kunde)
</label>
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
<Button onClick={finalize} disabled={finalizing} className="gap-1.5">
{finalizing ? <Loader2 className="size-3.5 animate-spin" /> : <Lock className="size-3.5" />}
Finalisieren & versenden
</Button>
</>
) : (
<>
<div className="mr-auto flex items-center gap-1.5 text-[11px] text-muted-foreground">
<ShieldCheck className="size-3.5" />
Finalisiert · lückenlos nummeriert
</div>
<Button variant="outline" onClick={() => onOpenChange(false)}>Schließen</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function Meta({ label, value, sub }: { label: string; value: string; sub?: 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>
{sub && <p className="text-[10px] text-foreground">{sub}</p>}
</div>
)
}
function Row({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className={cn('flex items-center justify-between', strong && 'font-semibold')}>
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">{value}</span>
</div>
)
}
+2
View File
@@ -11,6 +11,7 @@ import {
Command,
Search,
Bot,
ScrollText,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
@@ -41,6 +42,7 @@ const NAV: {
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
],
},
{
+1
View File
@@ -22,6 +22,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
}
+221
View File
@@ -0,0 +1,221 @@
'use client'
import { useState, useMemo } from 'react'
import { ShieldCheck, Hash, Filter, Download, User, GitBranch } 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 { PageHeader } from '../primitives'
import { formatDateTime, formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
interface AuditEntry {
id: string
actor: string | null
action: string
entity: string
entityId: string
before: unknown
after: unknown
hash: string | null
createdAt: string
}
interface AuditData {
entries: AuditEntry[]
stats: { total: number; byAction: Record<string, number>; byActor: Record<string, number> }
}
const ACTION_GROUPS: Record<string, { label: string; tone: string }> = {
'crm.deal': { label: 'CRM / Deal', tone: 'border-border' },
'leads.review': { label: 'Leads / Review', tone: 'border-border' },
'leads.convert': { label: 'Leads / Convert', tone: 'border-foreground/40' },
'leads.job': { label: 'Leads / Job', tone: 'border-border' },
'ai.leads': { label: 'AI / Explain', tone: 'border-foreground/30' },
'invoice.finalize': { label: 'Finance / Finalize', tone: 'border-border' },
'projects.task': { label: 'Projects / Task', tone: 'border-border' },
'projects.time': { label: 'Projects / Time', tone: 'border-border' },
'module.enable': { label: 'System / Module', tone: 'border-foreground/30' },
'module.disable': { label: 'System / Module', tone: 'border-foreground/30' },
'settings.update': { label: 'System / Settings', tone: 'border-border' },
}
export function AuditView() {
const { data, loading } = useFetch<AuditData>('/api/aos/audit?limit=200')
const [actionFilter, setActionFilter] = useState<string>('all')
const [selectedId, setSelectedId] = useState<string | null>(null)
const entries = useMemo(() => data?.entries ?? [], [data])
const filtered = actionFilter === 'all' ? entries : entries.filter((e) => e.action.startsWith(actionFilter))
const selected = filtered.find((e) => e.id === selectedId) ?? filtered[0] ?? null
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-64" />
</div>
)
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Audit-Protokoll"
description="GoBD-konforme Hash-Kette: jeder Geschäftsvorfall unveränderlich protokolliert mit Alt-/Neu-Wert und Akteur. Storno statt Edit."
actions={
<Button size="sm" variant="outline">
<Download className="size-4" />
Export (CSV)
</Button>
}
/>
{/* Stats */}
<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">Einträge gesamt</p>
<Hash className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.total}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Akteure</p>
<User className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byActor).length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktionsgruppen</p>
<GitBranch className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byAction).length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Integrität</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold">Verifiziert</p>
<p className="text-[10px] text-muted-foreground">Hash-Kette lückenlos</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Entry list */}
<Card className="lg:col-span-2 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Ereignisse ({filtered.length})</CardTitle>
<div className="flex items-center gap-1">
<Filter className="size-3 text-muted-foreground" />
<select
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
className="h-7 rounded border border-input bg-background px-1.5 text-[11px] outline-none"
>
<option value="all">Alle Aktionen</option>
{Object.keys(ACTION_GROUPS).map((k) => (
<option key={k} value={k}>{ACTION_GROUPS[k].label}</option>
))}
</select>
</div>
</CardHeader>
<ScrollArea className="h-[68vh]">
<div className="space-y-1 p-2">
{filtered.map((e) => {
const groupKey = Object.keys(ACTION_GROUPS).find((k) => e.action.startsWith(k)) ?? e.action
const meta = ACTION_GROUPS[groupKey] ?? { label: e.action, tone: 'border-border' }
return (
<button
key={e.id}
onClick={() => setSelectedId(e.id)}
className={cn(
'w-full rounded-md border p-2.5 text-left transition hover:border-foreground/40',
meta.tone,
selected?.id === e.id && 'ring-1 ring-foreground/40'
)}
>
<div className="flex items-center justify-between gap-2">
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono">{e.action}</code>
<span className="shrink-0 text-[10px] text-muted-foreground">{formatRelative(e.createdAt)}</span>
</div>
<p className="mt-1 truncate text-[11px] text-muted-foreground">
{e.entity} · <span className="font-mono">{e.entityId.slice(0, 10)}</span>
</p>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">von {e.actor ?? 'system'}</p>
</button>
)
})}
{filtered.length === 0 && (
<div className="py-10 text-center text-xs text-muted-foreground">Keine Einträge.</div>
)}
</div>
</ScrollArea>
</Card>
{/* Detail */}
<Card className="lg:col-span-3">
{selected ? (
<>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<code className="text-sm font-semibold">{selected.action}</code>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{formatDateTime(selected.createdAt)} · Akteur: <span className="font-medium">{selected.actor ?? 'system'}</span>
</p>
</div>
<Badge variant="outline" className="shrink-0">{selected.entity}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Entitäts-ID</p>
<code className="mt-1 block break-all rounded bg-muted px-2 py-1.5 font-mono text-xs">{selected.entityId}</code>
</div>
{selected.before ? (
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Vorher</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
{JSON.stringify(selected.before, null, 2)}
</pre>
</div>
) : null}
{selected.after ? (
<div>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Nachher</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
{JSON.stringify(selected.after, null, 2)}
</pre>
</div>
) : null}
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center gap-2">
<Hash className="size-3.5 text-muted-foreground" />
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Hash-Kette</span>
</div>
<code className="mt-1.5 block break-all font-mono text-[10px] text-muted-foreground">{selected.hash}</code>
<p className="mt-2 text-[10px] text-muted-foreground">
Verkettet mit vorherigem Eintrag (SHA-256). GoBD: unveränderlich, nachvollziehbar, lückenlos.
</p>
</div>
</CardContent>
</>
) : (
<div className="flex h-full items-center justify-center p-10">
<p className="text-sm text-muted-foreground">Wähle ein Ereignis.</p>
</div>
)}
</Card>
</div>
</div>
)
}
+21 -10
View File
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { CreateDealDialog } from '../create-deal-dialog'
import { formatEuro, formatDate } from '@/lib/format'
import { useAos } from '@/store/aos'
import { cn } from '@/lib/utils'
interface CrmData {
@@ -32,7 +34,9 @@ const STAGE_COLORS: Record<string, string> = {
}
export function CrmView() {
const { data, loading } = useFetch<CrmData>('/api/aos/crm')
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
const [createOpen, setCreateOpen] = useState(false)
if (loading || !data) {
return (
@@ -56,9 +60,9 @@ export function CrmView() {
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Firma
Deal
</Button>
}
/>
@@ -94,13 +98,20 @@ export function CrmView() {
</TabsContent>
<TabsContent value="companies" className="mt-4">
<CompaniesTable companies={data.companies} />
<CompaniesTable companies={data.companies} onSelectCompany={setSelectedCompany} />
</TabsContent>
<TabsContent value="persons" className="mt-4">
<PersonsTable persons={data.persons} />
<PersonsTable persons={data.persons} onSelectCompany={setSelectedCompany} />
</TabsContent>
</Tabs>
<CreateDealDialog
open={createOpen}
onOpenChange={setCreateOpen}
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
onCreated={refetch}
/>
</div>
)
}
@@ -158,7 +169,7 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
)
}
function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
const [q, setQ] = useState('')
const filtered = companies.filter(
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
@@ -190,14 +201,14 @@ function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
</thead>
<tbody className="divide-y divide-border">
{filtered.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<tr key={c.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{c.name}</span>
{c.website && (
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<Globe className="size-3" />{c.website}
</a>
)}
@@ -238,7 +249,7 @@ function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
)
}
function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
function PersonsTable({ persons, onSelectCompany }: { persons: CrmData['persons']; onSelectCompany: (id: string) => void }) {
return (
<Card>
<CardContent className="p-0">
@@ -254,7 +265,7 @@ function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
</thead>
<tbody className="divide-y divide-border">
{persons.map((p) => (
<tr key={p.id} className="transition hover:bg-muted/40">
<tr key={p.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => p.companyId && onSelectCompany(p.companyId)}>
<td className="px-4 py-2.5 font-medium">{p.firstName} {p.lastName}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{p.company?.name ?? '—'}</td>
<td className="px-4 py-2.5 text-xs">{p.role ?? '—'}</td>
+17 -19
View File
@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import {
Plus,
Receipt,
@@ -16,26 +17,10 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { InvoiceDetailDialog, type Invoice } from '../invoice-dialog'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
interface FinanceData {
invoices: Invoice[]
stats: {
@@ -68,7 +53,9 @@ const TYPE_LABELS: Record<string, string> = {
}
export function FinanceView() {
const { data, loading } = useFetch<FinanceData>('/api/aos/finance')
const { data, loading, refetch } = useFetch<FinanceData>('/api/aos/finance')
const [selected, setSelected] = useState<Invoice | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
if (loading || !data) {
return (
@@ -163,7 +150,11 @@ export function FinanceView() {
const st = STATUS_META[inv.status] ?? STATUS_META.draft
const dueDays = daysUntil(inv.dueDate)
return (
<tr key={inv.id} className="transition hover:bg-muted/40">
<tr
key={inv.id}
className="cursor-pointer transition hover:bg-muted/40"
onClick={() => { setSelected(inv); setDialogOpen(true) }}
>
<td className="px-4 py-2.5">
<code className="font-mono text-[11px]">{inv.number}</code>
</td>
@@ -242,6 +233,13 @@ export function FinanceView() {
</CardContent>
</Card>
</div>
<InvoiceDetailDialog
invoice={selected}
open={dialogOpen}
onOpenChange={setDialogOpen}
onFinalized={refetch}
/>
</div>
)
}
+42 -2
View File
@@ -15,6 +15,7 @@ import {
XCircle,
Loader2,
Filter,
ArrowRight,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -439,10 +440,49 @@ function LeadDetail({ lead, onReview, onUpdated }: { lead: Lead; onReview: (id:
</div>
)}
{lead.status !== 'pending' && (
<div className="border-t border-border p-3 text-center text-[11px] text-muted-foreground">
Reviewed {formatRelative(lead.reviewedAt ?? lead.createdAt)} · Status: {meta.label}
<div className="space-y-2 border-t border-border p-3">
<p className="text-center text-[11px] text-muted-foreground">
Reviewed {formatRelative(lead.reviewedAt ?? lead.createdAt)} · Status: {meta.label}
</p>
{lead.status === 'approved' && (
<ConvertButton leadId={lead.id} onConverted={onUpdated} />
)}
{lead.status === 'converted' && (
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-2 text-center text-[11px]">
✓ In CRM konvertiert — Firma + Deal angelegt
</div>
)}
</div>
)}
</Card>
)
}
function ConvertButton({ leadId, onConverted }: { leadId: string; onConverted?: () => void }) {
const [converting, setConverting] = useState(false)
const convert = async () => {
setConverting(true)
const t = toast.loading('Lead wird ins CRM konvertiert…')
try {
const r = await fetch('/api/aos/leads/convert', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ leadId }),
})
if (!r.ok) throw new Error('convert failed')
const data = await r.json()
toast.success(`Firma „${data.company.name}" + Deal angelegt`, { id: t })
onConverted?.()
} catch {
toast.error('Konvertierung fehlgeschlagen', { id: t })
} finally {
setConverting(false)
}
}
return (
<Button size="sm" className="w-full gap-1.5" onClick={convert} disabled={converting}>
{converting ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRight className="size-3.5" />}
In CRM konvertieren
</Button>
)
}
+105 -3
View File
@@ -21,6 +21,10 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -72,7 +76,7 @@ const TASK_ICON = {
}
export function ProjectsView() {
const { data, loading } = useFetch<ProjectsData>('/api/aos/projects')
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
const [selectedId, setSelectedId] = useState<string | null>(null)
if (loading || !data) {
@@ -179,7 +183,7 @@ export function ProjectsView() {
{/* Detail */}
<div className="lg:col-span-3">
{selected ? <ProjectDetail project={selected} /> : (
{selected ? <ProjectDetail project={selected} onUpdated={refetch} /> : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
</Card>
@@ -190,13 +194,62 @@ export function ProjectsView() {
)
}
function ProjectDetail({ project }: { project: Project }) {
function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: () => void }) {
const [running, setRunning] = useState(false)
const [newTask, setNewTask] = useState('')
const [taskPriority, setTaskPriority] = useState('medium')
const [timeDesc, setTimeDesc] = useState('')
const [timeMin, setTimeMin] = useState('')
const [savingTask, setSavingTask] = useState(false)
const [savingTime, setSavingTime] = useState(false)
const todo = project.tasks.filter((t) => t.status === 'todo')
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
const review = project.tasks.filter((t) => t.status === 'review')
const done = project.tasks.filter((t) => t.status === 'done')
const addTask = async () => {
if (!newTask.trim()) return
setSavingTask(true)
const t = toast.loading('Aufgabe wird hinzugefügt…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/tasks`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: newTask.trim(), priority: taskPriority }),
})
if (!r.ok) throw new Error('task failed')
toast.success('Aufgabe hinzugefügt', { id: t })
setNewTask('')
onUpdated?.()
} catch {
toast.error('Speichern fehlgeschlagen', { id: t })
} finally {
setSavingTask(false)
}
}
const logTime = async () => {
const minutes = parseInt(timeMin, 10)
if (!timeDesc.trim() || !minutes || minutes < 1) return
setSavingTime(true)
const t = toast.loading('Zeiteintrag wird gespeichert…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/time`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ description: timeDesc.trim(), minutes, billable: true, rateCentsPerHour: 9500 }),
})
if (!r.ok) throw new Error('time failed')
toast.success(`${minutes} min gebucht`, { id: t })
setTimeDesc(''); setTimeMin('')
onUpdated?.()
} catch {
toast.error('Buchen fehlgeschlagen', { id: t })
} finally {
setSavingTime(false)
}
}
return (
<Card>
<CardHeader className="border-b border-border pb-3">
@@ -220,6 +273,28 @@ function ProjectDetail({ project }: { project: Project }) {
</TabsList>
<TabsContent value="tasks" className="mt-4 space-y-4">
{/* Add task inline */}
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
<Input
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Neue Aufgabe…"
className="h-8 flex-1 text-xs"
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
<Select value={taskPriority} onValueChange={setTaskPriority}>
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
</SelectContent>
</Select>
<Button size="sm" className="h-8 gap-1.5" onClick={addTask} disabled={savingTask || !newTask.trim()}>
{savingTask ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Hinzufügen
</Button>
</div>
<TaskColumn label="Offen" tasks={todo} />
<TaskColumn label="In Arbeit" tasks={inProgress} />
<TaskColumn label="Review" tasks={review} />
@@ -260,6 +335,33 @@ function ProjectDetail({ project }: { project: Project }) {
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
)}
</div>
{/* Log time inline */}
<div className="flex items-end gap-2 rounded-md border border-border bg-muted/20 p-2">
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Beschreibung</label>
<Input
value={timeDesc}
onChange={(e) => setTimeDesc(e.target.value)}
placeholder="z.B. SSO-Integration"
className="h-8 text-xs"
/>
</div>
<div className="w-24 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Minuten</label>
<Input
value={timeMin}
onChange={(e) => setTimeMin(e.target.value)}
placeholder="90"
inputMode="numeric"
className="h-8 text-xs"
/>
</div>
<Button size="sm" className="h-8 gap-1.5" onClick={logTime} disabled={savingTime || !timeDesc.trim() || !timeMin}>
{savingTime ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Buchen
</Button>
</div>
</TabsContent>
<TabsContent value="budget" className="mt-4 space-y-3">