e70ae79d-5898-4c55-8ceb-d2c701ce08b4
This commit is contained in:
1 parent
d226dabba3
commit
5855f699f3
53 files changed
+1911
-44
No files matched your search
@@ -3,8 +3,39 @@ import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/audit — audit log (hash-chain evidence)
|
||||
export async function GET() {
|
||||
const entries = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 50 })
|
||||
return NextResponse.json({ entries })
|
||||
// GET /api/aos/audit — full audit log (hash-chain evidence)
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const limit = Math.min(Number(searchParams.get('limit') ?? 100), 500)
|
||||
const action = searchParams.get('action')
|
||||
|
||||
const where = action ? { action: { contains: action } } : {}
|
||||
const entries = await db.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
const parsed = entries.map((e) => ({
|
||||
...e,
|
||||
before: e.before ? safeParse(e.before) : null,
|
||||
after: e.after ? safeParse(e.after) : null,
|
||||
}))
|
||||
|
||||
const stats = {
|
||||
total: parsed.length,
|
||||
byAction: groupBy(parsed, (e) => e.action.split(':')[0]),
|
||||
byActor: groupBy(parsed, (e) => e.actor ?? 'system'),
|
||||
}
|
||||
|
||||
return NextResponse.json({ entries: parsed, stats })
|
||||
}
|
||||
|
||||
function safeParse(s: string) {
|
||||
try { return JSON.parse(s) } catch { return s }
|
||||
}
|
||||
function groupBy<T>(arr: T[], key: (t: T) => string) {
|
||||
const m: Record<string, number> = {}
|
||||
for (const x of arr) { const k = key(x); m[k] = (m[k] ?? 0) + 1 }
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/crm/companies/[id] — full company detail with timeline
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const company = await db.company.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
persons: true,
|
||||
deals: { orderBy: { updatedAt: 'desc' } },
|
||||
projects: { orderBy: { updatedAt: 'desc' } },
|
||||
invoices: { orderBy: { issueDate: 'desc' }, take: 10 },
|
||||
licenses: { include: { activations: true } },
|
||||
leads: true,
|
||||
},
|
||||
})
|
||||
if (!company) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
// audit entries touching this company's deals/invoices/licenses
|
||||
const audit = await db.auditLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ entity: 'Company', entityId: id },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
|
||||
// build a synthetic timeline from deals + invoices
|
||||
const timeline = [
|
||||
...company.deals.map((d) => ({ kind: 'deal', date: d.updatedAt, title: d.title, meta: d.stage, valueCents: d.valueCents })),
|
||||
...company.invoices.map((i) => ({ kind: 'invoice', date: i.issueDate, title: i.number, meta: i.status, valueCents: i.grossCents })),
|
||||
...company.projects.map((p) => ({ kind: 'project', date: p.updatedAt, title: p.name, meta: p.status, valueCents: p.fixedPriceCents })),
|
||||
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
// DSGVO consent record (synthetic from source + tags)
|
||||
const consent = {
|
||||
legalBasis: 'Art. 6 Abs. 1 lit. f (berechtigtes Interesse, § 13 DSGVO)',
|
||||
purpose: 'Geschäftsanbahnung, Vertragsdurchführung',
|
||||
source: company.source ?? 'manuell erfasst',
|
||||
recordedAt: company.createdAt,
|
||||
evidence: company.source === 'lead-finder' ? 'Leads-Finder-Audit + Impressum-Veröffentlichung' : 'Direkte Kontaktaufnahme',
|
||||
}
|
||||
|
||||
return NextResponse.json({ company, timeline, audit, consent })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/crm/deals — create a new deal in the pipeline
|
||||
export async function POST(req: Request) {
|
||||
const body = (await req.json()) as {
|
||||
companyId: string
|
||||
title: string
|
||||
valueCents: number
|
||||
stage?: string
|
||||
probability?: number
|
||||
expectedClose?: string | null
|
||||
}
|
||||
|
||||
if (!body.companyId) {
|
||||
return NextResponse.json({ error: 'companyId required' }, { status: 400 })
|
||||
}
|
||||
if (!body.title?.trim()) {
|
||||
return NextResponse.json({ error: 'title required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const deal = await db.deal.create({
|
||||
data: {
|
||||
companyId: body.companyId,
|
||||
title: body.title.trim(),
|
||||
valueCents: Math.max(0, Math.round(body.valueCents)),
|
||||
stage: body.stage ?? 'lead',
|
||||
probability: body.probability ?? 10,
|
||||
expectedClose: body.expectedClose ? new Date(body.expectedClose) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'sven@nordlicht.digital',
|
||||
action: 'crm.deal:create',
|
||||
entity: 'Deal',
|
||||
entityId: deal.id,
|
||||
after: JSON.stringify({ title: deal.title, stage: deal.stage, valueCents: deal.valueCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ deal })
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/finance/[id]/finalize — finalize a draft invoice:
|
||||
// assigns gapless number if needed, marks ZUGFeRD/XRechnung, sets status=sent, locks.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json().catch(() => ({}))) as { zugferd?: boolean; xrechnung?: boolean }
|
||||
|
||||
const inv = await db.invoice.findUnique({ where: { id } })
|
||||
if (!inv) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
if (inv.status !== 'draft') return NextResponse.json({ error: 'only drafts can be finalized' }, { status: 409 })
|
||||
|
||||
// E-Rechnung: public-sector customers (Leitweg-ID) → XRechnung; otherwise ZUGFeRD.
|
||||
const wantZugferd = body.zugferd ?? true
|
||||
const wantXrechnung = body.xrechnung ?? false
|
||||
|
||||
const updated = await db.invoice.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'sent',
|
||||
zugferd: wantZugferd,
|
||||
xrechnung: wantXrechnung,
|
||||
issueDate: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'nora@nordlicht.digital',
|
||||
action: 'invoice.finalize',
|
||||
entity: 'Invoice',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ status: 'draft', zugferd: false, xrechnung: false }),
|
||||
after: JSON.stringify({ status: 'sent', zugferd: wantZugferd, xrechnung: wantXrechnung, number: inv.number }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ invoice: updated })
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/leads/convert
|
||||
// Convert an approved lead into a CRM Company + Deal.
|
||||
// This is the "lead → deal" domain-event flow from the PRD (§5.4 events, §6.2 hand-off).
|
||||
export async function POST(req: Request) {
|
||||
const { leadId } = (await req.json()) as { leadId: string }
|
||||
|
||||
const lead = await db.lead.findUnique({ where: { id: leadId } })
|
||||
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
|
||||
if (lead.status === 'converted') return NextResponse.json({ error: 'already converted' }, { status: 409 })
|
||||
|
||||
// 1. create Company (or reuse if website matches an existing one)
|
||||
let company = lead.website
|
||||
? await db.company.findFirst({ where: { website: lead.website } })
|
||||
: null
|
||||
|
||||
if (!company) {
|
||||
company = await db.company.create({
|
||||
data: {
|
||||
name: lead.companyName,
|
||||
website: lead.website,
|
||||
city: lead.city,
|
||||
source: 'lead-finder',
|
||||
tags: [lead.scoreLabel, lead.branch].filter(Boolean).join(','),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 2. create a Deal in the "lead" stage seeded from the audit evidence
|
||||
const labelMap: Record<string, string> = {
|
||||
'new-site': 'Neue Website',
|
||||
redesign: 'Website-Redesign',
|
||||
seo: 'SEO-Optimierung',
|
||||
shop: 'Online-Shop Aufbau',
|
||||
maintenance: 'Wartungsvertrag',
|
||||
accessibility: 'Barrierefreiheit (BFSG)',
|
||||
}
|
||||
const title = lead.scoreLabel ? `${labelMap[lead.scoreLabel] ?? 'Projekt'} — ${lead.companyName}` : lead.companyName
|
||||
|
||||
const deal = await db.deal.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
title,
|
||||
valueCents: 0, // to be qualified
|
||||
probability: 10,
|
||||
stage: 'lead',
|
||||
},
|
||||
})
|
||||
|
||||
// 3. mark lead as converted + link
|
||||
await db.lead.update({
|
||||
where: { id: leadId },
|
||||
data: { status: 'converted', companyId: company.id, reviewedAt: new Date() },
|
||||
})
|
||||
|
||||
// 4. audit entries (domain event crm.deal.created + leads.converted)
|
||||
await db.auditLog.createMany({
|
||||
data: [
|
||||
{
|
||||
actor: 'sven@nordlicht.digital',
|
||||
action: 'leads.convert:crm',
|
||||
entity: 'Lead',
|
||||
entityId: leadId,
|
||||
after: JSON.stringify({ status: 'converted', companyId: company.id, dealId: deal.id }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
{
|
||||
actor: 'sven@nordlicht.digital',
|
||||
action: 'crm.deal:create',
|
||||
entity: 'Deal',
|
||||
entityId: deal.id,
|
||||
after: JSON.stringify({ title, stage: 'lead', sourceLeadId: leadId }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return NextResponse.json({ company, deal, leadId })
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/projects/[id]/tasks — add a task to a project
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json()) as { title: string; priority?: string; assignee?: string; estimateMin?: number }
|
||||
|
||||
const task = await db.task.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
title: body.title,
|
||||
priority: body.priority ?? 'medium',
|
||||
assignee: body.assignee ?? null,
|
||||
estimateMin: body.estimateMin ?? null,
|
||||
status: 'todo',
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'aylin@nordlicht.digital',
|
||||
action: 'projects.task:create',
|
||||
entity: 'Task',
|
||||
entityId: task.id,
|
||||
after: JSON.stringify({ title: task.title, projectId: id }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ task })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/projects/[id]/time — log a time entry on a project
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json()) as { description: string; minutes: number; billable?: boolean; rateCentsPerHour?: number }
|
||||
|
||||
const entry = await db.timeEntry.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
description: body.description,
|
||||
minutes: Math.max(1, Math.round(body.minutes)),
|
||||
billable: body.billable ?? true,
|
||||
rateCentsPerHour: body.rateCentsPerHour ?? null,
|
||||
date: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// bump spentHours on the project (denormalized for the board)
|
||||
await db.project.update({
|
||||
where: { id },
|
||||
data: { spentHours: { increment: Math.round(entry.minutes / 60) } },
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'tom@nordlicht.digital',
|
||||
action: 'projects.time:log',
|
||||
entity: 'TimeEntry',
|
||||
entityId: entry.id,
|
||||
after: JSON.stringify({ minutes: entry.minutes, projectId: id }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ entry })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/search?q=...
|
||||
// Global command-palette search across companies, persons, deals, leads, projects, invoices, licenses.
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const q = (searchParams.get('q') ?? '').trim().toLowerCase()
|
||||
if (q.length < 2) return NextResponse.json({ results: [] })
|
||||
|
||||
const [companies, persons, deals, leads, projects, invoices, licenses] = await Promise.all([
|
||||
db.company.findMany({ where: { OR: [{ name: { contains: q } }, { city: { contains: q } }, { website: { contains: q } }] }, take: 6 }),
|
||||
db.person.findMany({ where: { OR: [{ firstName: { contains: q } }, { lastName: { contains: q } }, { email: { contains: q } }] }, include: { company: true }, take: 6 }),
|
||||
db.deal.findMany({ where: { title: { contains: q } }, include: { company: true }, take: 6 }),
|
||||
db.lead.findMany({ where: { OR: [{ companyName: { contains: q } }, { website: { contains: q } }, { branch: { contains: q } }] }, take: 6 }),
|
||||
db.project.findMany({ where: { name: { contains: q } }, include: { company: true }, take: 6 }),
|
||||
db.invoice.findMany({ where: { OR: [{ number: { contains: q } }] }, include: { company: true }, take: 6 }),
|
||||
db.license.findMany({ where: { OR: [{ key: { contains: q } }, { product: { contains: q } }] }, include: { company: true }, take: 6 }),
|
||||
])
|
||||
|
||||
type Result = { group: string; view: string; id: string; primary: string; secondary?: string; tertiary?: string }
|
||||
const results: Result[] = []
|
||||
|
||||
for (const c of companies) results.push({ group: 'Firmen', view: 'crm', id: c.id, primary: c.name, secondary: c.city ?? undefined, tertiary: c.legalForm ?? undefined })
|
||||
for (const p of persons) results.push({ group: 'Ansprechpartner', view: 'crm', id: p.id, primary: `${p.firstName} ${p.lastName}`, secondary: p.company?.name, tertiary: p.role ?? undefined })
|
||||
for (const d of deals) results.push({ group: 'Deals', view: 'crm', id: d.id, primary: d.title, secondary: d.company.name, tertiary: d.stage })
|
||||
for (const l of leads) results.push({ group: 'Leads', view: 'leads', id: l.id, primary: l.companyName, secondary: l.website ?? l.city ?? undefined, tertiary: `Score ${l.score}` })
|
||||
for (const p of projects) results.push({ group: 'Projekte', view: 'projects', id: p.id, primary: p.name, secondary: p.company.name, tertiary: p.status })
|
||||
for (const i of invoices) results.push({ group: 'Rechnungen', view: 'finance', id: i.id, primary: i.number, secondary: i.company.name, tertiary: i.status })
|
||||
for (const l of licenses) results.push({ group: 'Lizenzen', view: 'licenses', id: l.id, primary: l.key, secondary: l.product, tertiary: l.company.name })
|
||||
|
||||
return NextResponse.json({ results, query: q })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '*' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ViewId =
|
||||
| 'projects'
|
||||
| 'licenses'
|
||||
| 'finance'
|
||||
| 'audit'
|
||||
| 'settings'
|
||||
|
||||
interface AosState {
|
||||
|
||||
Reference in new issue
Block a user