diff --git a/src/app/api/aos/crm/companies/[id]/route.ts b/src/app/api/aos/crm/companies/[id]/route.ts index 094ff52..ddb7a4f 100755 --- a/src/app/api/aos/crm/companies/[id]/route.ts +++ b/src/app/api/aos/crm/companies/[id]/route.ts @@ -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 = {} + 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 = { + 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 }) + } +} diff --git a/src/app/api/aos/crm/companies/route.ts b/src/app/api/aos/crm/companies/route.ts new file mode 100644 index 0000000..3720d37 --- /dev/null +++ b/src/app/api/aos/crm/companies/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/crm/deals/[id]/route.ts b/src/app/api/aos/crm/deals/[id]/route.ts new file mode 100644 index 0000000..49a15f6 --- /dev/null +++ b/src/app/api/aos/crm/deals/[id]/route.ts @@ -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 = {} + 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 }) + } +} diff --git a/src/app/api/aos/expenses/[id]/route.ts b/src/app/api/aos/expenses/[id]/route.ts new file mode 100644 index 0000000..b39d3fe --- /dev/null +++ b/src/app/api/aos/expenses/[id]/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +const updateExpenseSchema = z.object({ + categoryId: z.string().max(50).optional(), + vendor: z.string().max(200).nullable().optional(), + description: z.string().min(1).max(8000).optional(), + amountCents: z.number().int().min(0).optional(), + taxBp: z.number().int().min(0).max(10000).optional(), + grossCents: z.number().int().min(0).optional(), + billable: z.boolean().optional(), + markupPct: z.number().int().min(0).max(500).optional(), + recurring: z.boolean().optional(), + recurringInterval: z.enum(['monthly', 'quarterly', 'yearly']).nullable().optional(), + status: z.enum(['recorded', 'approved', 'invoiced', 'rejected']).optional(), + companyId: z.string().nullable().optional(), + projectId: z.string().nullable().optional(), + date: z.string().optional(), +}) + +// PATCH /api/aos/expenses/[id] — update an expense. +// Recomputes grossCents from amountCents + taxBp when amount or tax changes. +// Audit-logged (GoBD). +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const parsed = updateExpenseSchema.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.expense.findUnique({ where: { id } }) + if (!existing) { + return NextResponse.json({ error: 'expense not found' }, { status: 404 }) + } + + // Block edits once the expense has been exported to DATEV (GoBD lock). + if (existing.datevExported) { + return NextResponse.json( + { error: 'expense is DATEV-exported and locked (GoBD)' }, + { status: 409 } + ) + } + + const data: Record = {} + for (const [k, v] of Object.entries(body)) { + if (k === 'date' && 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 }) + } + + // Recompute grossCents if amount or tax changed. + const newAmount = typeof data.amountCents === 'number' ? data.amountCents : existing.amountCents + const newTaxBp = typeof data.taxBp === 'number' ? data.taxBp : existing.taxBp + if ( + typeof data.amountCents === 'number' || + typeof data.taxBp === 'number' + ) { + data.grossCents = Math.round(newAmount * (1 + newTaxBp / 10000)) + } + + const updated = await db.expense.update({ where: { id }, data }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'expense:update', + entity: 'Expense', + entityId: id, + before: JSON.stringify(existing), + after: JSON.stringify(updated), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ expense: updated }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// DELETE /api/aos/expenses/[id] — delete an expense. +// +// Cascade policy: +// - DATEV-exported expenses cannot be deleted (GoBD lock). Returns 409. +// - Expenses already converted to an invoice cannot be deleted. Returns 409. +// - Otherwise hard delete — expenses are typically just bookkeeping drafts +// and don't have downstream FKs. +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const existing = await db.expense.findUnique({ where: { id } }) + if (!existing) { + return NextResponse.json({ error: 'expense not found' }, { status: 404 }) + } + + if (existing.datevExported) { + return NextResponse.json( + { error: 'expense is DATEV-exported and cannot be deleted (GoBD)' }, + { status: 409 } + ) + } + if (existing.status === 'invoiced' || existing.convertedInvoiceId) { + return NextResponse.json( + { error: 'expense already converted to an invoice — cancel the invoice first' }, + { status: 409 } + ) + } + + await db.expense.delete({ where: { id } }) + + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'expense:delete', + entity: 'Expense', + 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 }) + } +} diff --git a/src/app/api/aos/expenses/route.ts b/src/app/api/aos/expenses/route.ts index b3e7058..33dc282 100755 --- a/src/app/api/aos/expenses/route.ts +++ b/src/app/api/aos/expenses/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/[id]/route.ts b/src/app/api/aos/licenses/[id]/route.ts new file mode 100644 index 0000000..a8430a8 --- /dev/null +++ b/src/app/api/aos/licenses/[id]/route.ts @@ -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 = { 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 }) + } +} diff --git a/src/app/api/aos/projects/[id]/route.ts b/src/app/api/aos/projects/[id]/route.ts new file mode 100644 index 0000000..517467a --- /dev/null +++ b/src/app/api/aos/projects/[id]/route.ts @@ -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 = {} + 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 = { 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 }) + } +} diff --git a/src/app/api/aos/projects/route.ts b/src/app/api/aos/projects/route.ts index 97c8f45..2f478f7 100755 --- a/src/app/api/aos/projects/route.ts +++ b/src/app/api/aos/projects/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/seed-prod/route.ts b/src/app/api/aos/seed-prod/route.ts index 9278ca0..b3eeed2 100644 --- a/src/app/api/aos/seed-prod/route.ts +++ b/src/app/api/aos/seed-prod/route.ts @@ -39,7 +39,7 @@ export async function POST(req: Request) { } try { - const stats: Record = {} + const stats: Record = {} // --- 1. Modules — activate all core modules --- const moduleDefs = [ diff --git a/src/app/api/aos/tickets/[id]/route.ts b/src/app/api/aos/tickets/[id]/route.ts new file mode 100644 index 0000000..cba42dc --- /dev/null +++ b/src/app/api/aos/tickets/[id]/route.ts @@ -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 = {} + 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 }) + } +} diff --git a/src/components/aos/crud/company-form-dialog.tsx b/src/components/aos/crud/company-form-dialog.tsx new file mode 100644 index 0000000..1ce62ff --- /dev/null +++ b/src/components/aos/crud/company-form-dialog.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' + +// Shape shared between the table row and the edit form. Optional fields +// are stringified so the form has a single value type. +export interface CompanyFormValue { + id?: string + name: string + legalForm: string + vatId: string + registerCourt: string + registerNumber: string + website: string + email: string + phone: string + city: string + zip: string + street: string + tags: string +} + +export const EMPTY_COMPANY: CompanyFormValue = { + name: '', + legalForm: '', + vatId: '', + registerCourt: '', + registerNumber: '', + website: '', + email: '', + phone: '', + city: '', + zip: '', + street: '', + tags: '', +} + +interface Props { + open: boolean + onOpenChange: (o: boolean) => void + // When `company` is null, the dialog is in "create" mode. + company?: CompanyFormValue | null + onSaved: () => void +} + +// Single dialog for create + edit of a CRM Company. +// - create mode → POST /api/aos/crm/companies +// - edit mode → PATCH /api/aos/crm/companies/[id] +// Both write an audit entry on the server side (GoBD). +export function CompanyFormDialog({ open, onOpenChange, company, onSaved }: Props) { + const [form, setForm] = useState(EMPTY_COMPANY) + const [saving, setSaving] = useState(false) + const isEdit = !!company?.id + + useEffect(() => { + if (open) { + setForm(company ?? EMPTY_COMPANY) + } + }, [open, company]) + + const update = (k: keyof CompanyFormValue, v: string) => + setForm((f) => ({ ...f, [k]: v })) + + const submit = async () => { + if (!form.name.trim()) { toast.error('Firmenname ist Pflicht'); return } + setSaving(true) + const t = toast.loading(isEdit ? 'Firma wird gespeichert…' : 'Firma wird angelegt…') + try { + // Normalise empty strings → null on the server side (Prisma schema is + // nullable; the PATCH handler accepts strings or null). + const payload: Record = { + name: form.name.trim(), + legalForm: form.legalForm.trim() || null, + vatId: form.vatId.trim() || null, + registerCourt: form.registerCourt.trim() || null, + registerNumber: form.registerNumber.trim() || null, + website: form.website.trim() || null, + email: form.email.trim() || null, + phone: form.phone.trim() || null, + city: form.city.trim() || null, + zip: form.zip.trim() || null, + street: form.street.trim() || null, + tags: form.tags.trim() || null, + } + const url = isEdit ? `/api/aos/crm/companies/${company!.id}` : '/api/aos/crm/companies' + const method = isEdit ? 'PATCH' : 'POST' + const r = await fetch(url, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || `${method} failed`) + } + toast.success(isEdit ? 'Firma aktualisiert' : 'Firma angelegt', { id: t }) + onOpenChange(false) + onSaved() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + {isEdit ? 'Firma bearbeiten' : 'Neue Firma'} + + {isEdit + ? 'Stammdaten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).' + : 'Erfasse eine neue Firma im CRM. Audit-Log wird automatisch geschrieben.'} + + +
+ update('name', v)} className="col-span-2" /> + update('legalForm', v)} placeholder="GmbH, UG, e.K." /> + update('vatId', v)} placeholder="DE123456789" mono /> + update('registerCourt', v)} placeholder="Amtsgericht Hamburg" /> + update('registerNumber', v)} mono /> + update('website', v)} placeholder="mueller.de" /> + update('email', v)} placeholder="info@firma.de" /> + update('phone', v)} placeholder="+49 40 …" /> + update('street', v)} /> + update('zip', v)} /> + update('city', v)} className="col-span-2" /> + update('tags', v)} placeholder="kunde, agency, vip" className="col-span-2" /> +
+ + + + +
+
+ ) +} + +function Field({ + label, + value, + onChange, + placeholder, + mono, + className, +}: { + label: string + value: string + onChange: (v: string) => void + placeholder?: string + mono?: boolean + className?: string +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={`h-9 text-xs ${mono ? 'font-mono' : ''}`} + /> +
+ ) +} diff --git a/src/components/aos/crud/deal-form-dialog.tsx b/src/components/aos/crud/deal-form-dialog.tsx new file mode 100644 index 0000000..4978451 --- /dev/null +++ b/src/components/aos/crud/deal-form-dialog.tsx @@ -0,0 +1,197 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' + +export interface DealFormValue { + id?: string + companyId: string + title: string + valueEuro: string + stage: string + probability: string + expectedClose: string + lossReason: string +} + +interface Props { + open: boolean + onOpenChange: (o: boolean) => void + companies: { id: string; name: string }[] + // When `deal` is null the dialog is in "create" mode. + deal?: DealFormValue | null + onSaved: () => void +} + +const STAGES = [ + { value: 'lead', label: 'Lead' }, + { value: 'qualified', label: 'Qualifiziert' }, + { value: 'proposal', label: 'Angebot' }, + { value: 'negotiation', label: 'Verhandlung' }, + { value: 'won', label: 'Gewonnen' }, + { value: 'lost', label: 'Verloren' }, +] + +const EMPTY: DealFormValue = { + companyId: '', + title: '', + valueEuro: '', + stage: 'lead', + probability: '10', + expectedClose: '', + lossReason: '', +} + +// Single dialog for create + edit of a CRM Deal. +// - create mode → POST /api/aos/crm/deals +// - edit mode → PATCH /api/aos/crm/deals/[id] +export function DealFormDialog({ open, onOpenChange, companies, deal, onSaved }: Props) { + const [form, setForm] = useState(EMPTY) + const [saving, setSaving] = useState(false) + const isEdit = !!deal?.id + + useEffect(() => { + if (open) { + setForm(deal ?? EMPTY) + } + }, [open, deal]) + + // Auto-pick the first company for create mode if none selected. + useEffect(() => { + if (open && !isEdit && !form.companyId && companies[0]) { + setForm((f) => ({ ...f, companyId: companies[0].id })) + } + }, [open, isEdit, form.companyId, companies]) + + const submit = async () => { + if (!form.companyId) { toast.error('Firma auswählen'); return } + if (!form.title.trim()) { toast.error('Titel eingeben'); return } + const valueCents = Math.round((parseFloat(form.valueEuro.replace(',', '.')) || 0) * 100) + const probability = Math.min(100, Math.max(0, parseInt(form.probability, 10) || 0)) + const payload: Record = { + companyId: form.companyId, + title: form.title.trim(), + valueCents, + stage: form.stage, + probability, + expectedClose: form.expectedClose || null, + } + if (form.stage === 'lost' && form.lossReason.trim()) { + payload.lossReason = form.lossReason.trim() + } + + setSaving(true) + const t = toast.loading(isEdit ? 'Deal wird gespeichert…' : 'Deal wird angelegt…') + try { + const url = isEdit ? `/api/aos/crm/deals/${deal!.id}` : '/api/aos/crm/deals' + const method = isEdit ? 'PATCH' : 'POST' + const r = await fetch(url, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || `${method} failed`) + } + toast.success(isEdit ? 'Deal aktualisiert' : 'Deal angelegt', { id: t }) + onOpenChange(false) + onSaved() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + {isEdit ? 'Deal bearbeiten' : 'Neuer Deal'} + + {isEdit + ? 'Deal-Daten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).' + : 'Legt einen Deal in der Pipeline an. Audit-Log wird automatisch geschrieben.'} + + +
+
+ + +
+
+ + setForm((f) => ({ ...f, title: e.target.value }))} placeholder="z.B. Website-Relaunch" /> +
+
+
+ + setForm((f) => ({ ...f, valueEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" /> +
+
+ + setForm((f) => ({ ...f, probability: e.target.value }))} placeholder="50" inputMode="numeric" /> +
+
+
+
+ + +
+
+ + setForm((f) => ({ ...f, expectedClose: e.target.value }))} + /> +
+
+ {form.stage === 'lost' && ( +
+ + setForm((f) => ({ ...f, lossReason: e.target.value }))} placeholder="z.B. Preis, Konkurrenz, Timing" /> +
+ )} +
+ + + + +
+
+ ) +} diff --git a/src/components/aos/crud/delete-dialog.tsx b/src/components/aos/crud/delete-dialog.tsx new file mode 100644 index 0000000..29b317b --- /dev/null +++ b/src/components/aos/crud/delete-dialog.tsx @@ -0,0 +1,169 @@ +'use client' + +import { useState } from 'react' +import { Loader2, Trash2, AlertTriangle } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { toast } from 'sonner' + +interface DeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + // Human label for the entity, e.g. "Firma", "Deal". + entityLabel: string + // Display name shown in the body, e.g. "Müller GmbH". + entityName: string + // DELETE endpoint URL. + endpoint: string + // Called after a successful delete (usually refetch). + onDeleted?: () => void + // Optional description override. Default explains that this is irreversible. + description?: string +} + +// Reusable confirmation dialog for delete actions. +// +// Talks to the standardised DELETE endpoints in /api/aos/.../[id]/route.ts. +// Those endpoints return 409 with a `dependencies` object when the entity +// still has related records that block deletion — we render that list +// inside the dialog so the user knows exactly what to clean up first. +// +// GoBD: callers must NOT use this for invoices (no DELETE endpoint exists). +export function DeleteDialog({ + open, + onOpenChange, + entityLabel, + entityName, + endpoint, + onDeleted, + description, +}: DeleteDialogProps) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [dependencies, setDependencies] = useState | null>(null) + + const reset = () => { + setError(null) + setDependencies(null) + } + + const confirm = async () => { + setBusy(true) + setError(null) + setDependencies(null) + const t = toast.loading(`${entityLabel} wird gelöscht…`) + try { + const r = await fetch(endpoint, { method: 'DELETE' }) + const data = await r.json().catch(() => ({})) + if (!r.ok) { + // Surface dependency info if the API gave us any. + if (data.dependencies) setDependencies(data.dependencies as Record) + throw new Error(data.error || `HTTP ${r.status}`) + } + toast.success(`${entityLabel} gelöscht`, { id: t }) + onOpenChange(false) + reset() + onDeleted?.() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t }) + setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen') + } finally { + setBusy(false) + } + } + + const depEntries = dependencies ? Object.entries(dependencies).filter(([, n]) => n > 0) : [] + + return ( + { + if (!o) reset() + onOpenChange(o) + }} + > + + + + + {entityLabel} löschen + + + {description ?? `Möchtest du „${entityName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`} + + + + {error && ( +
+
+ +
+

Löschen nicht möglich

+

{error}

+ {depEntries.length > 0 && ( +
+

Abhängigkeiten:

+
+ {depEntries.map(([k, n]) => ( + + {DEP_LABELS[k] ?? k}: {n} + + ))} +
+

+ Lösche oder ordne diese Datensätze zuerst zu, bevor du die {entityLabel.toLowerCase()} löschst. +

+
+ )} +
+
+
+ )} + + + Abbrechen + { + e.preventDefault() + void confirm() + }} + disabled={busy} + className="gap-1.5 bg-foreground text-background hover:bg-foreground/90" + > + {busy ? : } + Löschen + + +
+
+ ) +} + +// German labels for the dependency keys returned by the DELETE endpoints. +const DEP_LABELS: Record = { + invoices: 'Rechnungen', + licenses: 'Lizenzen', + deals: 'Deals', + projects: 'Projekte', + contracts: 'Verträge', + expenses: 'Ausgaben', + tickets: 'Tickets', + instances: 'Instanzen', + leads: 'Leads', + persons: 'Ansprechpartner', + agencyClients: 'Agentur-Kunden', + tasks: 'Aufgaben', + timeEntries: 'Zeiteinträge', + approvals: 'Freigaben', + activations: 'Aktivierungen', + modules: 'Module', +} diff --git a/src/components/aos/crud/expense-form-dialog.tsx b/src/components/aos/crud/expense-form-dialog.tsx new file mode 100644 index 0000000..5e1f6d2 --- /dev/null +++ b/src/components/aos/crud/expense-form-dialog.tsx @@ -0,0 +1,273 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' + +export interface ExpenseFormValue { + id?: string + categoryId: string + vendor: string + description: string + amountEuro: string + taxBp: string + date: string + companyId: string + projectId: string + billable: boolean + markupPct: string + recurring: boolean + recurringInterval: string +} + +interface Props { + open: boolean + onOpenChange: (o: boolean) => void + companies: { id: string; name: string }[] + projects: { id: string; name: string }[] + // When `expense` is null the dialog is in "create" mode. + expense?: ExpenseFormValue | null + onSaved: () => void +} + +const CATEGORIES = [ + { value: 'reise', label: 'Reise' }, + { value: 'software', label: 'Software' }, + { value: 'hardware', label: 'Hardware' }, + { value: 'fremdleistung', label: 'Fremdleistung' }, + { value: 'buero', label: 'Büro' }, + { value: 'marketing', label: 'Marketing' }, +] + +const TAX_RATES = [ + { value: '0', label: '0% (steuerfrei)' }, + { value: '700', label: '7% (ermäßigt)' }, + { value: '1900', label: '19% (Regelsatz)' }, +] + +const RECURRING_INTERVALS = [ + { value: 'monthly', label: 'Monatlich' }, + { value: 'quarterly', label: 'Quartalsweise' }, + { value: 'yearly', label: 'Jährlich' }, +] + +const EMPTY: ExpenseFormValue = { + categoryId: 'software', + vendor: '', + description: '', + amountEuro: '', + taxBp: '1900', + date: '', + companyId: '', + projectId: '', + billable: false, + markupPct: '0', + recurring: false, + recurringInterval: 'monthly', +} + +// Single dialog for create + edit of an Expense. +// - create mode → POST /api/aos/expenses +// - edit mode → PATCH /api/aos/expenses/[id] +// +// Server computes grossCents = amountCents * (1 + taxBp/10000); the UI does +// not need to send grossCents. On edit, if amount or tax changes the server +// recomputes; otherwise the existing grossCents is preserved. +export function ExpenseFormDialog({ open, onOpenChange, companies, projects, expense, onSaved }: Props) { + const [form, setForm] = useState(EMPTY) + const [saving, setSaving] = useState(false) + const isEdit = !!expense?.id + + useEffect(() => { + if (open) { + setForm(expense ?? EMPTY) + } + }, [open, expense]) + + const submit = async () => { + if (!form.description.trim()) { toast.error('Beschreibung eingeben'); return } + if (!form.amountEuro.trim()) { toast.error('Betrag eingeben'); return } + const amountCents = Math.round((parseFloat(form.amountEuro.replace(',', '.')) || 0) * 100) + if (amountCents <= 0) { toast.error('Betrag muss größer als 0 sein'); return } + + const payload: Record = { + categoryId: form.categoryId, + vendor: form.vendor.trim() || null, + description: form.description.trim(), + amountCents, + taxBp: parseInt(form.taxBp, 10) || 1900, + date: form.date || undefined, + companyId: form.companyId || null, + projectId: form.projectId || null, + billable: form.billable, + markupPct: parseInt(form.markupPct, 10) || 0, + recurring: form.recurring, + recurringInterval: form.recurring ? form.recurringInterval : null, + } + + setSaving(true) + const t = toast.loading(isEdit ? 'Ausgabe wird gespeichert…' : 'Ausgabe wird erfasst…') + try { + const url = isEdit ? `/api/aos/expenses/${expense!.id}` : '/api/aos/expenses' + const method = isEdit ? 'PATCH' : 'POST' + const r = await fetch(url, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || `${method} failed`) + } + toast.success(isEdit ? 'Ausgabe aktualisiert' : 'Ausgabe erfasst', { id: t }) + onOpenChange(false) + onSaved() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t }) + } finally { + setSaving(false) + } + } + + return ( + + + + {isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe'} + + {isEdit + ? 'Ausgaben-Daten aktualisieren. Bei Betrags- oder Steueränderung wird der Bruttobetrag neu berechnet. Audit-Log wird automatisch geschrieben (GoBD).' + : 'Erfasse eine neue Ausgabe. Der Bruttobetrag wird aus Nettobetrag und Steuersatz berechnet. Audit-Log wird automatisch geschrieben.'} + + +
+
+
+ + +
+
+ + setForm((f) => ({ ...f, vendor: e.target.value }))} placeholder="z.B. Amazon, O2, …" /> +
+
+
+ +