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
+2601
-23
No files matched your search
@@ -1,8 +1,28 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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
|
// GET /api/aos/crm/companies/[id] — full company detail with timeline
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
@@ -56,3 +76,153 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> = {}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -60,3 +61,73 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
@@ -23,3 +24,61 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({ projects, minutesThisWeek })
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats: Record<string, number> = {}
|
const stats: Record<string, number | string> = {}
|
||||||
|
|
||||||
// --- 1. Modules — activate all core modules ---
|
// --- 1. Modules — activate all core modules ---
|
||||||
const moduleDefs = [
|
const moduleDefs = [
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CompanyFormValue>(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<string, string | null> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Firma bearbeiten' : 'Neue Firma'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Stammdaten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
||||||
|
: 'Erfasse eine neue Firma im CRM. Audit-Log wird automatisch geschrieben.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Firmenname *" value={form.name} onChange={(v) => update('name', v)} className="col-span-2" />
|
||||||
|
<Field label="Rechtsform" value={form.legalForm} onChange={(v) => update('legalForm', v)} placeholder="GmbH, UG, e.K." />
|
||||||
|
<Field label="USt-IdNr." value={form.vatId} onChange={(v) => update('vatId', v)} placeholder="DE123456789" mono />
|
||||||
|
<Field label="Registergericht" value={form.registerCourt} onChange={(v) => update('registerCourt', v)} placeholder="Amtsgericht Hamburg" />
|
||||||
|
<Field label="HRB-Nummer" value={form.registerNumber} onChange={(v) => update('registerNumber', v)} mono />
|
||||||
|
<Field label="Website" value={form.website} onChange={(v) => update('website', v)} placeholder="mueller.de" />
|
||||||
|
<Field label="E-Mail" value={form.email} onChange={(v) => update('email', v)} placeholder="info@firma.de" />
|
||||||
|
<Field label="Telefon" value={form.phone} onChange={(v) => update('phone', v)} placeholder="+49 40 …" />
|
||||||
|
<Field label="Straße" value={form.street} onChange={(v) => update('street', v)} />
|
||||||
|
<Field label="PLZ" value={form.zip} onChange={(v) => update('zip', v)} />
|
||||||
|
<Field label="Ort" value={form.city} onChange={(v) => update('city', v)} className="col-span-2" />
|
||||||
|
<Field label="Tags (kommasepariert)" value={form.tags} onChange={(v) => update('tags', v)} placeholder="kunde, agency, vip" className="col-span-2" />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
mono,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
mono?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`space-y-1.5 ${className ?? ''}`}>
|
||||||
|
<Label className="text-xs">{label}</Label>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`h-9 text-xs ${mono ? 'font-mono' : ''}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<DealFormValue>(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<string, unknown> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Deal bearbeiten' : 'Neuer Deal'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Deal-Daten aktualisieren. Änderungen werden im Audit-Log protokolliert (GoBD).'
|
||||||
|
: 'Legt einen Deal in der Pipeline an. Audit-Log wird automatisch geschrieben.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Firma</Label>
|
||||||
|
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Titel</Label>
|
||||||
|
<Input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Wert (€)</Label>
|
||||||
|
<Input value={form.valueEuro} onChange={(e) => setForm((f) => ({ ...f, valueEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Wahrscheinlichkeit (%)</Label>
|
||||||
|
<Input value={form.probability} onChange={(e) => setForm((f) => ({ ...f, probability: e.target.value }))} placeholder="50" inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Phase</Label>
|
||||||
|
<Select value={form.stage} onValueChange={(v) => setForm((f) => ({ ...f, stage: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STAGES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Erwarteter Abschluss</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={form.expectedClose ? form.expectedClose.slice(0, 10) : ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, expectedClose: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.stage === 'lost' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Verlustgrund</Label>
|
||||||
|
<Input value={form.lossReason} onChange={(e) => setForm((f) => ({ ...f, lossReason: e.target.value }))} placeholder="z.B. Preis, Konkurrenz, Timing" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string | null>(null)
|
||||||
|
const [dependencies, setDependencies] = useState<Record<string, number> | 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<string, number>)
|
||||||
|
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 (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) reset()
|
||||||
|
onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-muted-foreground" />
|
||||||
|
{entityLabel} löschen
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{description ?? `Möchtest du „${entityName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-foreground" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="font-medium">Löschen nicht möglich</p>
|
||||||
|
<p className="text-muted-foreground">{error}</p>
|
||||||
|
{depEntries.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Abhängigkeiten:</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{depEntries.map(([k, n]) => (
|
||||||
|
<Badge key={k} variant="outline" className="text-[10px]">
|
||||||
|
{DEP_LABELS[k] ?? k}: {n}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Lösche oder ordne diese Datensätze zuerst zu, bevor du die {entityLabel.toLowerCase()} löschst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={busy}>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void confirm()
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
|
||||||
|
Löschen
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// German labels for the dependency keys returned by the DELETE endpoints.
|
||||||
|
const DEP_LABELS: Record<string, string> = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
@@ -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<ExpenseFormValue>(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<string, unknown> = {
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Ausgabe bearbeiten' : 'Neue Ausgabe'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Kategorie</Label>
|
||||||
|
<Select value={form.categoryId} onValueChange={(v) => setForm((f) => ({ ...f, categoryId: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORIES.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Lieferant</Label>
|
||||||
|
<Input value={form.vendor} onChange={(e) => setForm((f) => ({ ...f, vendor: e.target.value }))} placeholder="z.B. Amazon, O2, …" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Beschreibung *</Label>
|
||||||
|
<Textarea value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} placeholder="z.B. Adobe Creative Cloud — Jahresabo" className="min-h-[60px] text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Netto (€) *</Label>
|
||||||
|
<Input value={form.amountEuro} onChange={(e) => setForm((f) => ({ ...f, amountEuro: e.target.value }))} placeholder="59,40" inputMode="decimal" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Steuer</Label>
|
||||||
|
<Select value={form.taxBp} onValueChange={(v) => setForm((f) => ({ ...f, taxBp: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TAX_RATES.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Datum</Label>
|
||||||
|
<Input type="date" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Kunde (belastbar)</Label>
|
||||||
|
<Select value={form.companyId || 'none'} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v === 'none' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="— Eigenbeleg —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">— Eigenbeleg —</SelectItem>
|
||||||
|
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Projekt</Label>
|
||||||
|
<Select value={form.projectId || 'none'} onValueChange={(v) => setForm((f) => ({ ...f, projectId: v === 'none' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="—" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">—</SelectItem>
|
||||||
|
{projects.map((p) => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Markup %</Label>
|
||||||
|
<Input value={form.markupPct} onChange={(e) => setForm((f) => ({ ...f, markupPct: e.target.value }))} placeholder="0" inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Wiederkehrend?</Label>
|
||||||
|
<Select
|
||||||
|
value={form.recurring ? 'yes' : 'no'}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, recurring: v === 'yes' }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="no">Nein</SelectItem>
|
||||||
|
<SelectItem value="yes">Ja</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{form.recurring && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Intervall</Label>
|
||||||
|
<Select value={form.recurringInterval} onValueChange={(v) => setForm((f) => ({ ...f, recurringInterval: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RECURRING_INTERVALS.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.billable}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, billable: e.target.checked }))}
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
<span>Belastbar (an Kunde weiterberechnen)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{isEdit ? 'Speichern' : 'Erfassen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'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 ProjectFormValue {
|
||||||
|
id?: string
|
||||||
|
companyId: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
budgetType: string
|
||||||
|
budgetHours: string
|
||||||
|
fixedPriceEuro: string
|
||||||
|
startDate: string
|
||||||
|
dueDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
companies: { id: string; name: string }[]
|
||||||
|
// When `project` is null the dialog is in "create" mode.
|
||||||
|
project?: ProjectFormValue | null
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ value: 'website', label: 'Website' },
|
||||||
|
{ value: 'shop', label: 'Shop' },
|
||||||
|
{ value: 'app', label: 'App' },
|
||||||
|
{ value: 'retainer', label: 'Retainer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BUDGET_TYPES = [
|
||||||
|
{ value: 'fixed', label: 'Fixpreis' },
|
||||||
|
{ value: 'time-materials', label: 'Time & Materials' },
|
||||||
|
{ value: 'retainer', label: 'Retainer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY: ProjectFormValue = {
|
||||||
|
companyId: '',
|
||||||
|
name: '',
|
||||||
|
type: 'website',
|
||||||
|
budgetType: 'fixed',
|
||||||
|
budgetHours: '',
|
||||||
|
fixedPriceEuro: '',
|
||||||
|
startDate: '',
|
||||||
|
dueDate: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single dialog for create + edit of a Project.
|
||||||
|
// - create mode → POST /api/aos/projects
|
||||||
|
// - edit mode → PATCH /api/aos/projects/[id]
|
||||||
|
export function ProjectFormDialog({ open, onOpenChange, companies, project, onSaved }: Props) {
|
||||||
|
const [form, setForm] = useState<ProjectFormValue>(EMPTY)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const isEdit = !!project?.id
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(project ?? EMPTY)
|
||||||
|
}
|
||||||
|
}, [open, project])
|
||||||
|
|
||||||
|
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.name.trim()) { toast.error('Projektname eingeben'); return }
|
||||||
|
const fixedPriceCents = form.fixedPriceEuro
|
||||||
|
? Math.round((parseFloat(form.fixedPriceEuro.replace(',', '.')) || 0) * 100)
|
||||||
|
: null
|
||||||
|
const budgetHours = form.budgetHours ? parseInt(form.budgetHours, 10) || null : null
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
companyId: form.companyId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: form.type,
|
||||||
|
budgetType: form.budgetType,
|
||||||
|
budgetHours,
|
||||||
|
fixedPriceCents,
|
||||||
|
startDate: form.startDate || null,
|
||||||
|
dueDate: form.dueDate || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
const t = toast.loading(isEdit ? 'Projekt wird gespeichert…' : 'Projekt wird angelegt…')
|
||||||
|
try {
|
||||||
|
const url = isEdit ? `/api/aos/projects/${project!.id}` : '/api/aos/projects'
|
||||||
|
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 ? 'Projekt aktualisiert' : 'Projekt angelegt', { id: t })
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Projekt bearbeiten' : 'Neues Projekt'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? 'Projekt-Stammdaten aktualisieren. Audit-Log wird automatisch geschrieben (GoBD).'
|
||||||
|
: 'Legt ein neues Projekt an. Audit-Log wird automatisch geschrieben.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Firma</Label>
|
||||||
|
<Select value={form.companyId} onValueChange={(v) => setForm((f) => ({ ...f, companyId: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="Firma wählen" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{companies.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Projektname</Label>
|
||||||
|
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="z.B. Website-Relaunch" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Typ</Label>
|
||||||
|
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Budget-Typ</Label>
|
||||||
|
<Select value={form.budgetType} onValueChange={(v) => setForm((f) => ({ ...f, budgetType: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{BUDGET_TYPES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Fixpreis (€)</Label>
|
||||||
|
<Input value={form.fixedPriceEuro} onChange={(e) => setForm((f) => ({ ...f, fixedPriceEuro: e.target.value }))} placeholder="9.800" inputMode="decimal" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Budget-Stunden</Label>
|
||||||
|
<Input value={form.budgetHours} onChange={(e) => setForm((f) => ({ ...f, budgetHours: e.target.value }))} placeholder="120" inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Startdatum</Label>
|
||||||
|
<Input type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Fälligkeit</Label>
|
||||||
|
<Input type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
{isEdit ? 'Speichern' : 'Anlegen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface TicketFormValue {
|
||||||
|
id?: string
|
||||||
|
subject: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
categoryId: string
|
||||||
|
assignee: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
ticket?: TicketFormValue | null
|
||||||
|
onSaved: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ value: 'open', label: 'Offen' },
|
||||||
|
{ value: 'pending', label: 'Wartend' },
|
||||||
|
{ value: 'resolved', label: 'Gelöst' },
|
||||||
|
{ value: 'closed', label: 'Geschlossen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ value: 'low', label: 'Niedrig' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'high', label: 'Hoch' },
|
||||||
|
{ value: 'urgent', label: 'Dringend' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'bug', label: 'Bug' },
|
||||||
|
{ value: 'anfrage', label: 'Anfrage' },
|
||||||
|
{ value: 'aenderung', label: 'Änderung' },
|
||||||
|
{ value: 'konto', label: 'Konto' },
|
||||||
|
{ value: 'rechnung', label: 'Rechnung' },
|
||||||
|
{ value: 'hosting', label: 'Hosting' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY: TicketFormValue = {
|
||||||
|
subject: '',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'normal',
|
||||||
|
categoryId: 'anfrage',
|
||||||
|
assignee: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit-only dialog (creation is done via the public ticket creation flow).
|
||||||
|
// Patches via /api/aos/tickets/[id]. Audit-logged (GoBD).
|
||||||
|
//
|
||||||
|
// Side effects handled on the server:
|
||||||
|
// - status='resolved'|'closed' → slaResolvedAt set
|
||||||
|
// - first non-empty assignee → slaRespondedAt set
|
||||||
|
export function TicketEditDialog({ open, onOpenChange, ticket, onSaved }: Props) {
|
||||||
|
const [form, setForm] = useState<TicketFormValue>(EMPTY)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(ticket ?? EMPTY)
|
||||||
|
}
|
||||||
|
}, [open, ticket])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!form.subject.trim()) { toast.error('Betreff eingeben'); return }
|
||||||
|
if (!ticket?.id) { toast.error('Ticket-ID fehlt'); return }
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
subject: form.subject.trim(),
|
||||||
|
status: form.status,
|
||||||
|
priority: form.priority,
|
||||||
|
categoryId: form.categoryId,
|
||||||
|
assignee: form.assignee.trim() || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
const t = toast.loading('Ticket wird aktualisiert…')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/aos/tickets/${ticket.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error || 'PATCH failed')
|
||||||
|
}
|
||||||
|
toast.success('Ticket aktualisiert', { id: t })
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ticket bearbeiten</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Betreff, Status, Priorität, Kategorie und Assignee. Audit-Log wird automatisch geschrieben (GoBD).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Betreff</Label>
|
||||||
|
<Input value={form.subject} onChange={(e) => setForm((f) => ({ ...f, subject: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Status</Label>
|
||||||
|
<Select value={form.status} onValueChange={(v) => setForm((f) => ({ ...f, status: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUSES.map((s) => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Priorität</Label>
|
||||||
|
<Select value={form.priority} onValueChange={(v) => setForm((f) => ({ ...f, priority: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRIORITIES.map((p) => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Kategorie</Label>
|
||||||
|
<Select value={form.categoryId} onValueChange={(v) => setForm((f) => ({ ...f, categoryId: v }))}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORIES.map((c) => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Assignee</Label>
|
||||||
|
<Input value={form.assignee} onChange={(e) => setForm((f) => ({ ...f, assignee: e.target.value }))} placeholder="z.B. Aylin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={saving} className="gap-1.5">
|
||||||
|
{saving && <Loader2 className="size-3.5 animate-spin" />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText } from 'lucide-react'
|
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon, Upload, Loader2, Check, FileText, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -9,7 +9,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
import { CreateDealDialog } from '../create-deal-dialog'
|
import { DealFormDialog, type DealFormValue } from '../crud/deal-form-dialog'
|
||||||
|
import { CompanyFormDialog, type CompanyFormValue } from '../crud/company-form-dialog'
|
||||||
|
import { DeleteDialog } from '../crud/delete-dialog'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -45,7 +47,12 @@ const STAGE_COLORS: Record<string, string> = {
|
|||||||
export function CrmView() {
|
export function CrmView() {
|
||||||
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
const { data, loading, refetch } = useFetch<CrmData>('/api/aos/crm')
|
||||||
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
const setSelectedCompany = useAos((s) => s.setSelectedCompany)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [dealDialogOpen, setDealDialogOpen] = useState(false)
|
||||||
|
const [editingDeal, setEditingDeal] = useState<DealFormValue | null>(null)
|
||||||
|
const [companyDialogOpen, setCompanyDialogOpen] = useState(false)
|
||||||
|
const [editingCompany, setEditingCompany] = useState<CompanyFormValue | null>(null)
|
||||||
|
const [deleteCompany, setDeleteCompany] = useState<Company & { persons: Person[]; deals: Deal[] } | null>(null)
|
||||||
|
const [deleteDeal, setDeleteDeal] = useState<Deal & { company: Company } | null>(null)
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const [importStep, setImportStep] = useState<'upload' | 'map' | 'result'>('upload')
|
const [importStep, setImportStep] = useState<'upload' | 'map' | 'result'>('upload')
|
||||||
const [csvText, setCsvText] = useState('')
|
const [csvText, setCsvText] = useState('')
|
||||||
@@ -81,7 +88,11 @@ export function CrmView() {
|
|||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
CSV Import
|
CSV Import
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => { setEditingCompany(null); setCompanyDialogOpen(true) }}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Firma
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => { setEditingDeal(null); setDealDialogOpen(true) }}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Deal
|
Deal
|
||||||
</Button>
|
</Button>
|
||||||
@@ -116,11 +127,20 @@ export function CrmView() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pipeline" className="mt-4">
|
<TabsContent value="pipeline" className="mt-4">
|
||||||
<PipelineBoard pipeline={data.pipeline} />
|
<PipelineBoard
|
||||||
|
pipeline={data.pipeline}
|
||||||
|
onEditDeal={(d) => { setEditingDeal(dealToForm(d)); setDealDialogOpen(true) }}
|
||||||
|
onDeleteDeal={(d) => setDeleteDeal(d)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="companies" className="mt-4">
|
<TabsContent value="companies" className="mt-4">
|
||||||
<CompaniesTable companies={data.companies} onSelectCompany={setSelectedCompany} />
|
<CompaniesTable
|
||||||
|
companies={data.companies}
|
||||||
|
onSelectCompany={setSelectedCompany}
|
||||||
|
onEdit={(c) => { setEditingCompany(companyToForm(c)); setCompanyDialogOpen(true) }}
|
||||||
|
onDelete={(c) => setDeleteCompany(c)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="persons" className="mt-4">
|
<TabsContent value="persons" className="mt-4">
|
||||||
@@ -128,13 +148,43 @@ export function CrmView() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<CreateDealDialog
|
<DealFormDialog
|
||||||
open={createOpen}
|
open={dealDialogOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setDealDialogOpen}
|
||||||
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
companies={data.companies.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
onCreated={refetch}
|
deal={editingDeal}
|
||||||
|
onSaved={refetch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CompanyFormDialog
|
||||||
|
open={companyDialogOpen}
|
||||||
|
onOpenChange={setCompanyDialogOpen}
|
||||||
|
company={editingCompany}
|
||||||
|
onSaved={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteCompany && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteCompany}
|
||||||
|
onOpenChange={(o) => !o && setDeleteCompany(null)}
|
||||||
|
entityLabel="Firma"
|
||||||
|
entityName={deleteCompany.name}
|
||||||
|
endpoint={`/api/aos/crm/companies/${deleteCompany.id}`}
|
||||||
|
onDeleted={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteDeal && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteDeal}
|
||||||
|
onOpenChange={(o) => !o && setDeleteDeal(null)}
|
||||||
|
entityLabel="Deal"
|
||||||
|
entityName={deleteDeal.title}
|
||||||
|
endpoint={`/api/aos/crm/deals/${deleteDeal.id}`}
|
||||||
|
onDeleted={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CSV Import Dialog */}
|
{/* CSV Import Dialog */}
|
||||||
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}>
|
<Dialog open={importOpen} onOpenChange={(o) => { setImportOpen(o); if (!o) { setCsvText(''); setCsvHeaders([]); setMapping({ name: -1 }); setImportResult(null) } }}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-2xl">
|
||||||
@@ -295,7 +345,15 @@ export function CrmView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
function PipelineBoard({
|
||||||
|
pipeline,
|
||||||
|
onEditDeal,
|
||||||
|
onDeleteDeal,
|
||||||
|
}: {
|
||||||
|
pipeline: CrmData['pipeline']
|
||||||
|
onEditDeal: (d: Deal & { company: Company }) => void
|
||||||
|
onDeleteDeal: (d: Deal & { company: Company }) => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
|
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
|
||||||
{pipeline.map((col) => {
|
{pipeline.map((col) => {
|
||||||
@@ -316,8 +374,28 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
|
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
|
||||||
{col.deals.map((d) => (
|
{col.deals.map((d) => (
|
||||||
<Card key={d.id} className="cursor-pointer p-3 transition hover:border-foreground/30 hover:shadow-sm">
|
<Card key={d.id} className="group p-3 transition hover:border-foreground/30 hover:shadow-sm">
|
||||||
<p className="text-xs font-medium leading-snug">{d.title}</p>
|
<div className="flex items-start justify-between gap-1.5">
|
||||||
|
<p className="min-w-0 flex-1 text-xs font-medium leading-snug">{d.title}</p>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEditDeal(d) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDeleteDeal(d) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
|
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
|
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
|
||||||
@@ -348,7 +426,17 @@ function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['companies']; onSelectCompany: (id: string) => void }) {
|
function CompaniesTable({
|
||||||
|
companies,
|
||||||
|
onSelectCompany,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
companies: CrmData['companies']
|
||||||
|
onSelectCompany: (id: string) => void
|
||||||
|
onEdit: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||||
|
onDelete: (c: Company & { persons: Person[]; deals: Deal[] }) => void
|
||||||
|
}) {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const filtered = companies.filter(
|
const filtered = companies.filter(
|
||||||
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
|
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
|
||||||
@@ -376,11 +464,12 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
|||||||
<th className="px-4 py-2 font-medium">Kontakte</th>
|
<th className="px-4 py-2 font-medium">Kontakte</th>
|
||||||
<th className="px-4 py-2 font-medium">Deals</th>
|
<th className="px-4 py-2 font-medium">Deals</th>
|
||||||
<th className="px-4 py-2 font-medium">Tags</th>
|
<th className="px-4 py-2 font-medium">Tags</th>
|
||||||
|
<th className="w-20 px-4 py-2 font-medium text-right">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{filtered.map((c) => (
|
{filtered.map((c) => (
|
||||||
<tr key={c.id} className="cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
|
<tr key={c.id} className="group cursor-pointer transition hover:bg-muted/40" onClick={() => onSelectCompany(c.id)}>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="size-3.5 text-muted-foreground" />
|
<Building2 className="size-3.5 text-muted-foreground" />
|
||||||
@@ -418,6 +507,26 @@ function CompaniesTable({ companies, onSelectCompany }: { companies: CrmData['co
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center justify-end gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit(c) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(c) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -488,3 +597,39 @@ function parseCSVLine(line: string): string[] {
|
|||||||
fields.push(current)
|
fields.push(current)
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a Deal (CRM view shape) into the form's value shape.
|
||||||
|
function dealToForm(d: Deal & { company: Company }): DealFormValue {
|
||||||
|
const dateStr = d.expectedClose
|
||||||
|
? (typeof d.expectedClose === 'string' ? d.expectedClose : d.expectedClose.toISOString()).slice(0, 10)
|
||||||
|
: ''
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
companyId: d.company.id,
|
||||||
|
title: d.title,
|
||||||
|
valueEuro: (d.valueCents / 100).toString().replace('.', ','),
|
||||||
|
stage: d.stage,
|
||||||
|
probability: String(d.probability),
|
||||||
|
expectedClose: dateStr,
|
||||||
|
lossReason: d.lossReason ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a Company (CRM view shape) into the form's value shape.
|
||||||
|
function companyToForm(c: Company): CompanyFormValue {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
legalForm: c.legalForm ?? '',
|
||||||
|
vatId: c.vatId ?? '',
|
||||||
|
registerCourt: '',
|
||||||
|
registerNumber: '',
|
||||||
|
website: c.website ?? '',
|
||||||
|
email: c.email ?? '',
|
||||||
|
phone: c.phone ?? '',
|
||||||
|
city: c.city ?? '',
|
||||||
|
zip: c.zip ?? '',
|
||||||
|
street: c.street ?? '',
|
||||||
|
tags: c.tags ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Receipt,
|
Receipt,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -22,6 +24,8 @@ import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/form
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
|
||||||
|
import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog'
|
||||||
|
import { DeleteDialog } from '../crud/delete-dialog'
|
||||||
|
|
||||||
interface Expense {
|
interface Expense {
|
||||||
id: string
|
id: string
|
||||||
@@ -33,6 +37,8 @@ interface Expense {
|
|||||||
taxBp: number
|
taxBp: number
|
||||||
grossCents: number
|
grossCents: number
|
||||||
date: string
|
date: string
|
||||||
|
companyId: string | null
|
||||||
|
projectId: string | null
|
||||||
billable: boolean
|
billable: boolean
|
||||||
markupPct: number
|
markupPct: number
|
||||||
recurring: boolean
|
recurring: boolean
|
||||||
@@ -79,6 +85,15 @@ const STATUS_META: Record<string, { label: string; cls: string }> = {
|
|||||||
export function ExpensesView() {
|
export function ExpensesView() {
|
||||||
const { data, loading, refetch } = useFetch<ExpensesData>('/api/aos/expenses')
|
const { data, loading, refetch } = useFetch<ExpensesData>('/api/aos/expenses')
|
||||||
const [converting, setConverting] = useState<string | null>(null)
|
const [converting, setConverting] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<ExpenseFormValue | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Expense | null>(null)
|
||||||
|
|
||||||
|
// Companies + projects needed for the create/edit dialog (project linking).
|
||||||
|
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||||
|
const { data: projectsData } = useFetch<{ projects: { id: string; name: string }[] } | null>('/api/aos/projects')
|
||||||
|
const companies = crmData?.companies ?? []
|
||||||
|
const projectOptions = projectsData?.projects ?? []
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -121,7 +136,7 @@ export function ExpensesView() {
|
|||||||
title="Ausgaben"
|
title="Ausgaben"
|
||||||
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
|
description="Agentur-Ausgaben mit Projekt-/Kundenbezug, belastbare Posten (billable), Wiederkehrendes und Umwandlung in Rechnungen. DATEV-Export für den Steuerberater."
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true) }}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Ausgabe
|
Ausgabe
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,8 +206,10 @@ export function ExpensesView() {
|
|||||||
{expenses.map((e) => {
|
{expenses.map((e) => {
|
||||||
const st = STATUS_META[e.status] ?? STATUS_META.recorded
|
const st = STATUS_META[e.status] ?? STATUS_META.recorded
|
||||||
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
|
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
|
||||||
|
const canEdit = !e.datevExported && e.status !== 'invoiced'
|
||||||
|
const canDelete = !e.datevExported && e.status !== 'invoiced'
|
||||||
return (
|
return (
|
||||||
<tr key={e.id} className="transition hover:bg-muted/40">
|
<tr key={e.id} className="group transition hover:bg-muted/40">
|
||||||
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
|
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
|
<Badge variant="secondary" className="text-[10px]">{CATEGORY_LABELS[e.categoryId] ?? e.categoryId}</Badge>
|
||||||
@@ -214,6 +231,7 @@ export function ExpensesView() {
|
|||||||
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>{st.label}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
{canConvert ? (
|
{canConvert ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -229,9 +247,28 @@ export function ExpensesView() {
|
|||||||
<Badge variant="outline" className="gap-1 text-[9px]">
|
<Badge variant="outline" className="gap-1 text-[9px]">
|
||||||
<Receipt className="size-2.5" />Berechnet
|
<Receipt className="size-2.5" />Berechnet
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : null}
|
||||||
<span className="text-[10px] text-muted-foreground">—</span>
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditTarget(expenseToForm(e)); setDialogOpen(true) }}
|
||||||
|
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(e)}
|
||||||
|
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@@ -323,6 +360,45 @@ export function ExpensesView() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<ExpenseFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
companies={companies}
|
||||||
|
projects={projectOptions}
|
||||||
|
expense={editTarget}
|
||||||
|
onSaved={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||||
|
entityLabel="Ausgabe"
|
||||||
|
entityName={deleteTarget.description}
|
||||||
|
endpoint={`/api/aos/expenses/${deleteTarget.id}`}
|
||||||
|
onDeleted={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert an Expense (view shape) into the form's value shape.
|
||||||
|
function expenseToForm(e: Expense): ExpenseFormValue {
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
categoryId: e.categoryId,
|
||||||
|
vendor: e.vendor ?? '',
|
||||||
|
description: e.description,
|
||||||
|
amountEuro: (e.amountCents / 100).toString().replace('.', ','),
|
||||||
|
taxBp: String(e.taxBp),
|
||||||
|
date: e.date ? e.date.slice(0, 10) : '',
|
||||||
|
companyId: e.companyId ?? '',
|
||||||
|
projectId: e.projectId ?? '',
|
||||||
|
billable: e.billable,
|
||||||
|
markupPct: String(e.markupPct),
|
||||||
|
recurring: e.recurring,
|
||||||
|
recurringInterval: e.recurringInterval ?? 'monthly',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
Loader2,
|
Loader2,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -50,6 +51,7 @@ import { PageHeader } from '../primitives'
|
|||||||
import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { DeleteDialog } from '../crud/delete-dialog'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -197,6 +199,7 @@ export function LicensesView() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [suspendTarget, setSuspendTarget] = useState<License | null>(null)
|
const [suspendTarget, setSuspendTarget] = useState<License | null>(null)
|
||||||
const [cancelTarget, setCancelTarget] = useState<License | null>(null)
|
const [cancelTarget, setCancelTarget] = useState<License | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<License | null>(null)
|
||||||
const [filter, setFilter] = useState<FilterKey>('all')
|
const [filter, setFilter] = useState<FilterKey>('all')
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
@@ -350,6 +353,7 @@ export function LicensesView() {
|
|||||||
onSuspend={(lic) => setSuspendTarget(lic)}
|
onSuspend={(lic) => setSuspendTarget(lic)}
|
||||||
onCancel={(lic) => setCancelTarget(lic)}
|
onCancel={(lic) => setCancelTarget(lic)}
|
||||||
onPromote={handlePromote}
|
onPromote={handlePromote}
|
||||||
|
onDelete={(lic) => setDeleteTarget(lic)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -471,6 +475,17 @@ export function LicensesView() {
|
|||||||
onOpenChange={(o) => !o && setCancelTarget(null)}
|
onOpenChange={(o) => !o && setCancelTarget(null)}
|
||||||
onDone={refetch}
|
onDone={refetch}
|
||||||
/>
|
/>
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||||
|
entityLabel="Lizenz"
|
||||||
|
entityName={deleteTarget.key}
|
||||||
|
endpoint={`/api/aos/licenses/${deleteTarget.id}`}
|
||||||
|
description="Gekündigte Lizenzen ohne Aktivierungen, Instanzen oder Module können gelöscht werden. Audit-Log wird geschrieben (GoBD)."
|
||||||
|
onDeleted={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -484,11 +499,13 @@ function LicenseRow({
|
|||||||
onSuspend,
|
onSuspend,
|
||||||
onCancel,
|
onCancel,
|
||||||
onPromote,
|
onPromote,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
license: License
|
license: License
|
||||||
onSuspend: (l: License) => void
|
onSuspend: (l: License) => void
|
||||||
onCancel: (l: License) => void
|
onCancel: (l: License) => void
|
||||||
onPromote: (l: License) => void
|
onPromote: (l: License) => void
|
||||||
|
onDelete: (l: License) => void
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const st = LIC_STATUS[license.status] ?? LIC_STATUS.active
|
const st = LIC_STATUS[license.status] ?? LIC_STATUS.active
|
||||||
@@ -621,6 +638,11 @@ function LicenseRow({
|
|||||||
<Play className="size-3" />
|
<Play className="size-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{cancelled && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onDelete(license)} title="Löschen">
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -31,6 +33,8 @@ import { toast } from 'sonner'
|
|||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
|
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ProjectFormDialog, type ProjectFormValue } from '../crud/project-form-dialog'
|
||||||
|
import { DeleteDialog } from '../crud/delete-dialog'
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: string
|
id: string
|
||||||
@@ -81,6 +85,13 @@ const TASK_ICON = {
|
|||||||
export function ProjectsView() {
|
export function ProjectsView() {
|
||||||
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
|
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<ProjectFormValue | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||||
|
|
||||||
|
// Fetch companies for the create/edit dialog (CRM endpoint already returns them).
|
||||||
|
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
||||||
|
const companies = crmData?.companies ?? []
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -104,7 +115,7 @@ export function ProjectsView() {
|
|||||||
title="Projekte"
|
title="Projekte"
|
||||||
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
|
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={() => { setEditTarget(null); setCreateOpen(true) }}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Projekt
|
Projekt
|
||||||
</Button>
|
</Button>
|
||||||
@@ -139,7 +150,7 @@ export function ProjectsView() {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
className={cn('group cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
|
||||||
onClick={() => setSelectedId(p.id)}
|
onClick={() => setSelectedId(p.id)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
@@ -147,7 +158,27 @@ export function ProjectsView() {
|
|||||||
<p className="truncate text-sm font-semibold">{p.name}</p>
|
<p className="truncate text-sm font-semibold">{p.name}</p>
|
||||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
|
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setEditTarget(projectToForm(p)); setCreateOpen(true) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(p) }}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
@@ -193,6 +224,29 @@ export function ProjectsView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProjectFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
companies={companies}
|
||||||
|
project={editTarget}
|
||||||
|
onSaved={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||||
|
entityLabel="Projekt"
|
||||||
|
entityName={deleteTarget.name}
|
||||||
|
endpoint={`/api/aos/projects/${deleteTarget.id}`}
|
||||||
|
onDeleted={() => {
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -423,6 +477,21 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a Project (view shape) into the form's value shape.
|
||||||
|
function projectToForm(p: Project): ProjectFormValue {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
companyId: p.company.id,
|
||||||
|
name: p.name,
|
||||||
|
type: p.type,
|
||||||
|
budgetType: p.budgetType,
|
||||||
|
budgetHours: p.budgetHours ? String(p.budgetHours) : '',
|
||||||
|
fixedPriceEuro: p.fixedPriceCents ? (p.fixedPriceCents / 100).toString().replace('.', ',') : '',
|
||||||
|
startDate: p.startDate ? p.startDate.slice(0, 10) : '',
|
||||||
|
dueDate: p.dueDate ? p.dueDate.slice(0, 10) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) {
|
function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -23,6 +25,8 @@ import { PageHeader } from '../primitives'
|
|||||||
import { formatRelative, formatDateTime } from '@/lib/format'
|
import { formatRelative, formatDateTime } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { TicketEditDialog, type TicketFormValue } from '../crud/ticket-edit-dialog'
|
||||||
|
import { DeleteDialog } from '../crud/delete-dialog'
|
||||||
|
|
||||||
interface TicketMsg { id: string; fromType: string; fromName: string; body: string; createdAt: string }
|
interface TicketMsg { id: string; fromType: string; fromName: string; body: string; createdAt: string }
|
||||||
interface Ticket {
|
interface Ticket {
|
||||||
@@ -89,6 +93,8 @@ export function SupportView() {
|
|||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null)
|
const [aiBusy, setAiBusy] = useState<'summary' | 'reply' | null>(null)
|
||||||
const [replyDraft, setReplyDraft] = useState('')
|
const [replyDraft, setReplyDraft] = useState('')
|
||||||
|
const [editTarget, setEditTarget] = useState<TicketFormValue | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Ticket | null>(null)
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -185,7 +191,31 @@ export function SupportView() {
|
|||||||
{/* Ticket list */}
|
{/* Ticket list */}
|
||||||
<Card className="lg:col-span-2 overflow-hidden">
|
<Card className="lg:col-span-2 overflow-hidden">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">Inbox ({tickets.length})</CardTitle>
|
<CardTitle className="flex items-center justify-between text-sm">
|
||||||
|
<span>Inbox ({tickets.length})</span>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditTarget(ticketToForm(selected))}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<ScrollArea className="h-[72vh]">
|
<ScrollArea className="h-[72vh]">
|
||||||
<div className="space-y-1.5 p-2">
|
<div className="space-y-1.5 p-2">
|
||||||
@@ -356,6 +386,41 @@ export function SupportView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TicketEditDialog
|
||||||
|
open={!!editTarget}
|
||||||
|
onOpenChange={(o) => !o && setEditTarget(null)}
|
||||||
|
ticket={editTarget}
|
||||||
|
onSaved={refetch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||||
|
entityLabel="Ticket"
|
||||||
|
entityName={deleteTarget.subject}
|
||||||
|
endpoint={`/api/aos/tickets/${deleteTarget.id}`}
|
||||||
|
description="Tickets mit Nachrichten können nicht gelöscht werden — schließe das Ticket stattdessen. Audit-Log wird geschrieben (GoBD)."
|
||||||
|
onDeleted={() => {
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert a Ticket (view shape) into the form's value shape.
|
||||||
|
function ticketToForm(t: Ticket): TicketFormValue {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
subject: t.subject,
|
||||||
|
status: t.status,
|
||||||
|
priority: t.priority,
|
||||||
|
categoryId: t.categoryId,
|
||||||
|
assignee: t.assignee ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user