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

+170
View File
@@ -1,8 +1,28 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// Allowed fields for an inline update. All optional — only the keys
// actually present in the body are touched. This makes the endpoint
// safe for partial edits (e.g. only changing the email).
const updateCompanySchema = z.object({
name: z.string().min(1).max(200).optional(),
legalForm: z.string().max(50).nullable().optional(),
vatId: z.string().max(50).nullable().optional(),
registerCourt: z.string().max(100).nullable().optional(),
registerNumber: z.string().max(100).nullable().optional(),
website: z.string().max(200).nullable().optional(),
email: z.string().max(200).nullable().optional(),
phone: z.string().max(100).nullable().optional(),
city: z.string().max(100).nullable().optional(),
zip: z.string().max(20).nullable().optional(),
street: z.string().max(200).nullable().optional(),
source: z.string().max(100).nullable().optional(),
tags: z.string().max(500).nullable().optional(),
})
// GET /api/aos/crm/companies/[id] — full company detail with timeline
export async function GET(
_req: Request,
@@ -56,3 +76,153 @@ export async function GET(
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// PATCH /api/aos/crm/companies/[id] — update company master data
// Audit-logged (GoBD). Agency-only fields (isAgency, agencyStatus,
// parentAgencyId, agencyDiscountPct) are intentionally NOT editable here —
// those go through /api/aos/agency/[id]/approve|reject.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const parsed = updateCompanySchema.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.company.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// Only write keys that are actually present — avoids clobbering
// fields the UI didn't submit.
const data: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
data[k] = v
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
}
const updated = await db.company.update({
where: { id },
data,
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'crm.company:update',
entity: 'Company',
entityId: id,
before: JSON.stringify(existing),
after: JSON.stringify(updated),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ company: updated })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/crm/companies/[id] — delete company.
//
// Cascade policy: a company may only be hard-deleted when it has NO related
// records (invoices, licenses, deals, projects, contracts, expenses, tickets,
// instances, leads, persons). If related records exist, the call returns 409
// with the dependency counts so the UI can show a dialog explaining what
// blocks the deletion.
//
// This is the GoBD-safe path: never silently cascade-delete invoices or
// licenses (those have their own retention rules).
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.company.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'company not found' }, { status: 404 })
}
// Check all FK relations on Company (see prisma/schema.prisma).
const [
invoices,
licenses,
deals,
projects,
contracts,
expenses,
tickets,
instances,
leads,
persons,
agencyClients,
] = await Promise.all([
db.invoice.count({ where: { companyId: id } }),
db.license.count({ where: { companyId: id } }),
db.deal.count({ where: { companyId: id } }),
db.project.count({ where: { companyId: id } }),
db.contract.count({ where: { companyId: id } }),
db.expense.count({ where: { companyId: id } }),
db.ticket.count({ where: { companyId: id } }),
db.instance.count({ where: { companyId: id } }),
db.lead.count({ where: { companyId: id } }),
db.person.count({ where: { companyId: id } }),
db.company.count({ where: { parentAgencyId: id } }),
])
const dependencies: Record<string, number> = {
invoices,
licenses,
deals,
projects,
contracts,
expenses,
tickets,
instances,
leads,
persons,
agencyClients,
}
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
if (totalDeps > 0) {
return NextResponse.json(
{
error: 'company has related records — delete or reassign them first',
dependencies,
total: totalDeps,
},
{ status: 409 }
)
}
// No related records — safe to hard delete.
await db.company.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'crm.company:delete',
entity: 'Company',
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 })
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const createCompanySchema = z.object({
name: z.string().min(1).max(200),
legalForm: z.string().max(50).nullable().optional(),
vatId: z.string().max(50).nullable().optional(),
registerCourt: z.string().max(100).nullable().optional(),
registerNumber: z.string().max(100).nullable().optional(),
website: z.string().max(200).nullable().optional(),
email: z.string().max(200).nullable().optional(),
phone: z.string().max(100).nullable().optional(),
city: z.string().max(100).nullable().optional(),
zip: z.string().max(20).nullable().optional(),
street: z.string().max(200).nullable().optional(),
source: z.string().max(100).optional().default('manual'),
tags: z.string().max(500).nullable().optional(),
})
// POST /api/aos/crm/companies — create a new CRM company.
// Audit-logged (GoBD). The UI's "Neue Firma" button posts here.
export async function POST(req: Request) {
try {
const parsed = createCompanySchema.safeParse(await req.json().catch(() => ({})))
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
// Trim website (strip protocol — schema stores bare host).
let website = body.website ?? null
if (website) {
website = website.replace(/^https?:\/\//, '').replace(/\/+$/, '')
}
const company = await db.company.create({
data: {
name: body.name.trim(),
legalForm: body.legalForm ?? null,
vatId: body.vatId ?? null,
registerCourt: body.registerCourt ?? null,
registerNumber: body.registerNumber ?? null,
website,
email: body.email ?? null,
phone: body.phone ?? null,
city: body.city ?? null,
zip: body.zip ?? null,
street: body.street ?? null,
source: body.source,
tags: body.tags ?? null,
},
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'crm.company:create',
entity: 'Company',
entityId: company.id,
after: JSON.stringify({ name: company.name, source: company.source }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ company }, { status: 201 })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const updateDealSchema = z.object({
title: z.string().min(1).max(200).optional(),
valueCents: z.number().int().min(0).optional(),
stage: z.enum(['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']).optional(),
probability: z.number().int().min(0).max(100).optional(),
expectedClose: z.string().nullable().optional(),
lossReason: z.string().max(500).nullable().optional(),
ownerId: z.string().nullable().optional(),
})
// PATCH /api/aos/crm/deals/[id] — update a deal in the pipeline.
// Audit-logged (GoBD). Used by the deal edit dialog (title, value, stage,
// probability, expected close, loss reason).
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const parsed = updateDealSchema.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.deal.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
}
const data: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
if (k === 'expectedClose' && typeof v === 'string') {
data[k] = new Date(v)
} else {
data[k] = v
}
}
if (Object.keys(data).length === 0) {
return NextResponse.json({ error: 'no fields to update' }, { status: 400 })
}
const updated = await db.deal.update({ where: { id }, data })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'crm.deal:update',
entity: 'Deal',
entityId: id,
before: JSON.stringify(existing),
after: JSON.stringify(updated),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ deal: updated })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/crm/deals/[id] — delete a deal.
// Deals have no downstream FKs (no invoice/contract references in the
// schema), so this is a hard delete. Audit-logged for GoBD.
// If a deal has been converted into a contract or invoice, the UI should
// block the deletion — the contract/invoice still references the company,
// not the deal, so we don't enforce it here at the schema level.
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.deal.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'deal not found' }, { status: 404 })
}
await db.deal.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'crm.deal:delete',
entity: 'Deal',
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 })
}
}