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

+84
View File
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// DELETE /api/aos/licenses/[id] — hard-delete a license.
//
// Cascade policy: a license may only be hard-deleted when:
// - it has NO activations, AND
// - it has NO linked instance (License 1:1 Instance), AND
// - it has NO child module licenses (parentId === this id), AND
// - it is in status 'cancelled' (cancelled licenses that have reached their
// effective date are safe to remove from active bookkeeping).
//
// Otherwise the call returns 409 with the dependency counts.
//
// GoBD: if the license has been issued, activated, or used to bill a
// customer, do NOT delete — cancel (cancel/route.ts) keeps the audit record.
// This endpoint exists for genuine cleanup (e.g. demo licenses that never
// started a subscription, or test fixtures).
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.license.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
const [activations, instances, modules] = await Promise.all([
db.licenseActivation.count({ where: { licenseId: id } }),
db.instance.count({ where: { licenseId: id } }),
db.license.count({ where: { parentId: id } }),
])
const dependencies: Record<string, number> = { activations, instances, modules }
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
if (totalDeps > 0) {
return NextResponse.json(
{
error: 'license has related records — cancel the license instead of deleting it',
dependencies,
total: totalDeps,
},
{ status: 409 }
)
}
// Only allow deletion when the license is already cancelled or never started.
// Active/trial/suspended licenses must go through the cancel flow.
if (
existing.status !== 'cancelled' &&
existing.status !== 'revoked' &&
existing.status !== 'expired'
) {
return NextResponse.json(
{ error: `license is ${existing.status} — cancel before deleting (GoBD retention)` },
{ status: 409 }
)
}
await db.license.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license:delete',
entity: 'License',
entityId: id,
before: JSON.stringify({ key: existing.key, status: existing.status, product: existing.product }),
after: JSON.stringify({ deleted: true }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, deleted: true, id })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}