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:
1 parent
27669adc21
commit
aa075762b2
21 files changed
+2618
-40
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).optional(),
|
||||
status: z.enum(['planning', 'active', 'review', 'done', 'on-hold', 'cancelled']).optional(),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).optional(),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
spentHours: z.number().int().min(0).optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
})
|
||||
|
||||
// PATCH /api/aos/projects/[id] — update project master data.
|
||||
// Audit-logged (GoBD). Used by the project edit dialog (name, type,
|
||||
// budget, dates, progress).
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = updateProjectSchema.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.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if ((k === 'startDate' || k === 'dueDate') && 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.project.update({ where: { id }, data })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:update',
|
||||
entity: 'Project',
|
||||
entityId: id,
|
||||
before: JSON.stringify(existing),
|
||||
after: JSON.stringify(updated),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project: updated })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/projects/[id] — delete a project.
|
||||
//
|
||||
// Cascade policy: a project with tasks, time entries, or portal approvals
|
||||
// cannot be hard-deleted (those records belong to the agency's audit trail
|
||||
// and may have been invoiced). When the project has any of these
|
||||
// dependencies, the call returns 409 with the dependency counts so the
|
||||
// UI can show a dialog explaining what blocks the deletion.
|
||||
//
|
||||
// GoBD: time entries already invoiced are never deleted through this path.
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const existing = await db.project.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'project not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [tasks, timeEntries, approvals, invoices] = await Promise.all([
|
||||
db.task.count({ where: { projectId: id } }),
|
||||
db.timeEntry.count({ where: { projectId: id } }),
|
||||
db.portalApproval.count({ where: { projectId: id } }),
|
||||
db.invoice.count({ where: { projectId: id } }),
|
||||
])
|
||||
|
||||
const dependencies: Record<string, number> = { tasks, timeEntries, approvals, invoices }
|
||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
||||
if (totalDeps > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'project has related records — delete or reassign them first',
|
||||
dependencies,
|
||||
total: totalDeps,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.project.delete({ where: { id } })
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:delete',
|
||||
entity: 'Project',
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -23,3 +24,61 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({ projects, minutesThisWeek })
|
||||
}
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(['website', 'shop', 'app', 'retainer']).default('website'),
|
||||
budgetType: z.enum(['fixed', 'time-materials', 'retainer']).default('fixed'),
|
||||
budgetHours: z.number().int().min(0).nullable().optional(),
|
||||
fixedPriceCents: z.number().int().min(0).nullable().optional(),
|
||||
startDate: z.string().nullable().optional(),
|
||||
dueDate: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// POST /api/aos/projects — create a new project.
|
||||
// Audit-logged (GoBD). The UI's "Projekt" button posts here.
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createProjectSchema.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 company = await db.company.findUnique({ where: { id: body.companyId } })
|
||||
if (!company) {
|
||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
companyId: body.companyId,
|
||||
name: body.name.trim(),
|
||||
type: body.type,
|
||||
status: 'planning',
|
||||
budgetType: body.budgetType,
|
||||
budgetHours: body.budgetHours ?? null,
|
||||
fixedPriceCents: body.fixedPriceCents ?? null,
|
||||
startDate: body.startDate ? new Date(body.startDate) : null,
|
||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'project:create',
|
||||
entity: 'Project',
|
||||
entityId: project.id,
|
||||
after: JSON.stringify({ name: project.name, companyId: body.companyId, type: project.type }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ project }, { status: 201 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user