API routes (new + modified): - POST /api/aos/crm/companies (create company) - PATCH/DELETE /api/aos/crm/companies/[id] (edit/delete with dependency check) - PATCH/DELETE /api/aos/crm/deals/[id] (edit/delete deal) - POST /api/aos/projects (create project) - PATCH/DELETE /api/aos/projects/[id] (edit/delete with dependency check) - POST /api/aos/expenses (create expense) - PATCH/DELETE /api/aos/expenses/[id] (edit/delete with GoBD lock) - PATCH/DELETE /api/aos/tickets/[id] (edit/delete with message check) - DELETE /api/aos/licenses/[id] (delete only if cancelled + no deps) - NO invoice delete endpoint (GoBD retention — storno only) UI components (new): - DeleteDialog: reusable AlertDialog with dependency list - CompanyFormDialog: create + edit (12 fields) - DealFormDialog: create + edit (replaces CreateDealDialog) - ProjectFormDialog: create + edit - ExpenseFormDialog: create + edit - TicketEditDialog: edit status/priority/category/assignee UI views (modified): - CRM: 'Neue Firma' + 'Neuer Deal' buttons, edit/delete per row - Projects: 'Projekt' button, edit/delete on cards - Expenses: 'Ausgabe' button, edit/delete per row (hidden if DATEV-locked) - Support: edit/delete in inbox header - Licenses: delete button (only if cancelled/revoked/expired) Cascade policy: - Every DELETE counts FK relations, returns 409 with dependency list - Hard delete only when no dependencies exist - GoBD: expenses locked if DATEV-exported or invoiced - Audit log on every create/update/delete
134 lines
5.4 KiB
TypeScript
Executable File
134 lines
5.4 KiB
TypeScript
Executable File
import { NextResponse } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { db } from '@/lib/db'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
// GET /api/aos/expenses — expenses list with company/project, stats, reports
|
|
export async function GET() {
|
|
try {
|
|
const expenses = await db.expense.findMany({ orderBy: { date: 'desc' } })
|
|
|
|
const companyIds = [...new Set(expenses.map((e) => e.companyId).filter(Boolean) as string[])]
|
|
const projectIds = [...new Set(expenses.map((e) => e.projectId).filter(Boolean) as string[])]
|
|
const [companies, projects] = await Promise.all([
|
|
db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }),
|
|
db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, name: true } }),
|
|
])
|
|
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
|
|
const projectMap = new Map(projects.map((p) => [p.id, p.name]))
|
|
|
|
const enriched = expenses.map((e) => ({
|
|
...e,
|
|
companyName: e.companyId ? companyMap.get(e.companyId) ?? null : null,
|
|
projectName: e.projectId ? projectMap.get(e.projectId) ?? null : null,
|
|
}))
|
|
|
|
const stats = {
|
|
total: expenses.length,
|
|
billable: expenses.filter((e) => e.billable).length,
|
|
recurring: expenses.filter((e) => e.recurring).length,
|
|
billableCents: expenses.filter((e) => e.billable && e.status !== 'invoiced').reduce((s, e) => s + e.grossCents, 0),
|
|
totalGrossCents: expenses.reduce((s, e) => s + e.grossCents, 0),
|
|
invoiced: expenses.filter((e) => e.status === 'invoiced').length,
|
|
pendingApproval: expenses.filter((e) => e.status === 'recorded').length,
|
|
datevExported: expenses.filter((e) => e.datevExported).length,
|
|
}
|
|
|
|
// expense report: by category
|
|
const byCategory: Record<string, { count: number; grossCents: number }> = {}
|
|
for (const e of expenses) {
|
|
if (!byCategory[e.categoryId]) byCategory[e.categoryId] = { count: 0, grossCents: 0 }
|
|
byCategory[e.categoryId].count++
|
|
byCategory[e.categoryId].grossCents += e.grossCents
|
|
}
|
|
|
|
// monthly (last 6 months)
|
|
const now = new Date()
|
|
const monthly: { label: string; cents: number }[] = []
|
|
for (let i = 5; i >= 0; i--) {
|
|
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
|
const label = m.toLocaleDateString('de-DE', { month: 'short' })
|
|
const cents = expenses
|
|
.filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear())
|
|
.reduce((s, e) => s + e.grossCents, 0)
|
|
monthly.push({ label, cents })
|
|
}
|
|
|
|
return NextResponse.json({ expenses: enriched, stats, report: { byCategory, monthly } })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
const createExpenseSchema = z.object({
|
|
categoryId: z.enum(['reise', 'software', 'hardware', 'fremdleistung', 'buero', 'marketing']),
|
|
vendor: z.string().max(200).nullable().optional(),
|
|
description: z.string().min(1).max(8000),
|
|
amountCents: z.number().int().min(0),
|
|
taxBp: z.number().int().min(0).max(10000).optional().default(1900),
|
|
date: z.string().optional(),
|
|
companyId: z.string().nullable().optional(),
|
|
projectId: z.string().nullable().optional(),
|
|
billable: z.boolean().optional().default(false),
|
|
markupPct: z.number().int().min(0).max(500).optional().default(0),
|
|
recurring: z.boolean().optional().default(false),
|
|
recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(),
|
|
})
|
|
|
|
// POST /api/aos/expenses — create a new expense.
|
|
// Computes grossCents = amountCents * (1 + taxBp/10000). Audit-logged (GoBD).
|
|
export async function POST(req: Request) {
|
|
try {
|
|
const parsed = createExpenseSchema.safeParse(await req.json().catch(() => ({})))
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
|
}
|
|
const body = parsed.data
|
|
|
|
const grossCents = Math.round(body.amountCents * (1 + body.taxBp / 10000))
|
|
|
|
// gapless expense number: AUS-YYYY-NNNN
|
|
const year = new Date().getFullYear()
|
|
const count = await db.expense.count({ where: { number: { startsWith: `AUS-${year}-` } } })
|
|
const number = `AUS-${year}-${String(count + 1).padStart(4, '0')}`
|
|
|
|
const expense = await db.expense.create({
|
|
data: {
|
|
number,
|
|
categoryId: body.categoryId,
|
|
vendor: body.vendor ?? null,
|
|
description: body.description,
|
|
amountCents: body.amountCents,
|
|
taxBp: body.taxBp,
|
|
grossCents,
|
|
date: body.date ? new Date(body.date) : new Date(),
|
|
companyId: body.companyId ?? null,
|
|
projectId: body.projectId ?? null,
|
|
billable: body.billable,
|
|
markupPct: body.markupPct,
|
|
recurring: body.recurring,
|
|
recurringInterval: body.recurring ? body.recurringInterval ?? null : null,
|
|
status: 'recorded',
|
|
},
|
|
})
|
|
|
|
await db.auditLog.create({
|
|
data: {
|
|
actor: 'admin:ui',
|
|
action: 'expense:create',
|
|
entity: 'Expense',
|
|
entityId: expense.id,
|
|
after: JSON.stringify({ number, description: expense.description, grossCents }),
|
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({ expense }, { status: 201 })
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
|
return NextResponse.json({ error: msg }, { status: 500 })
|
|
}
|
|
}
|