feat: full CRUD support — companies, deals, projects, expenses, tickets, licenses

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
This commit is contained in:
Z User committed 2026-10-29 13:32:17 +00:00
1 parent 27669adc21
commit aa075762b2
21 files changed
+2618 -40

No files matched your search

+71
View File
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
@@ -60,3 +61,73 @@ export async function GET() {
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 })
}
}