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

+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const updateTicketSchema = z.object({
status: z.enum(['open', 'pending', 'resolved', 'closed']).optional(),
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
assignee: z.string().max(200).nullable().optional(),
categoryId: z.string().max(50).optional(),
subject: z.string().min(1).max(200).optional(),
})
// PATCH /api/aos/tickets/[id] — update a ticket.
// Side effects:
// - status='resolved' → set slaResolvedAt (if not already set)
// - status='closed' → set slaResolvedAt (if not already set) — close implies resolution
// - First non-empty assignee → set slaRespondedAt (if not already set) — assignment counts as response
//
// Audit-logged (GoBD).
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const parsed = updateTicketSchema.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 existing = await db.ticket.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
}
const data: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
data[k] = v
}
const now = new Date()
// SLA resolution timestamp.
if (
(data.status === 'resolved' || data.status === 'closed') &&
!existing.slaResolvedAt
) {
data.slaResolvedAt = now
}
// SLA response timestamp — assignment counts as first response.
if (
typeof data.assignee === 'string' &&
data.assignee.trim().length > 0 &&
!existing.slaRespondedAt
) {
data.slaRespondedAt = now
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
}
const updated = await db.ticket.update({ where: { id }, data })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'ticket:update',
entity: 'Ticket',
entityId: id,
before: JSON.stringify(existing),
after: JSON.stringify(updated),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ticket: updated })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/tickets/[id] — delete a ticket.
//
// Cascade policy: tickets with messages cannot be hard-deleted (the
// conversation is part of the audit trail). Tickets in status 'closed' or
// 'resolved' with NO messages can be deleted. Tickets that have been
// escalated (escalatedToAdmin=true) cannot be deleted.
//
// GoBD: never silently cascade-delete ticket messages.
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.ticket.findUnique({
where: { id },
include: { messages: { select: { id: true } } },
})
if (!existing) {
return NextResponse.json({ error: 'ticket not found' }, { status: 404 })
}
// Block if escalated to admin — those records stay for compliance.
if (existing.escalatedToAdmin) {
return NextResponse.json(
{ error: 'ticket escalated to admin — cannot be deleted' },
{ status: 409 }
)
}
// Block if ticket has messages — conversation must be retained.
if (existing.messages.length > 0) {
return NextResponse.json(
{
error: 'ticket has messages — close the ticket instead of deleting it',
messageCount: existing.messages.length,
},
{ status: 409 }
)
}
// Only allow deletion of empty (no message) tickets in resolved/closed state.
// (Open/pending tickets with no messages were created in error and may be cleaned up.)
await db.ticket.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'ticket:delete',
entity: 'Ticket',
entityId: id,
before: JSON.stringify(existing),
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 })
}
}