1bc42b25-8a92-4b1e-9e45-a4dba3d1ac57
This commit is contained in:
1 parent
5855f699f3
commit
d4c3b0f5ca
21 files changed
+1140
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// PATCH /api/aos/contracts/[id]/sign — e-signature hook (mock)
|
||||
// Transitions draft/sent → signed, records signer + IP + token, writes audit.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = (await req.json().catch(() => ({}))) as { signedBy?: string }
|
||||
|
||||
const contract = await db.contract.findUnique({ where: { id } })
|
||||
if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
if (contract.status === 'signed' || contract.status === 'active') {
|
||||
return NextResponse.json({ error: 'already signed' }, { status: 409 })
|
||||
}
|
||||
|
||||
const token = 'sig_' + Math.random().toString(36).slice(2, 14)
|
||||
const updated = await db.contract.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'signed',
|
||||
signedAt: new Date(),
|
||||
signedBy: body.signedBy ?? 'Kunde (via Portal)',
|
||||
signedIp: '91.64.x.x (portal)',
|
||||
signToken: token,
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'nora@nordlicht.digital',
|
||||
action: 'contract.sign',
|
||||
entity: 'Contract',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ status: contract.status }),
|
||||
after: JSON.stringify({ status: 'signed', signedBy: updated.signedBy, token }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ contract: updated })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/contracts — contracts list with company, expiry stats
|
||||
export async function GET() {
|
||||
const contracts = await db.contract.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
// enrich with company names (separate query to keep SQLite happy)
|
||||
const companyIds = [...new Set(contracts.map((c) => c.companyId))]
|
||||
const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } })
|
||||
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
|
||||
|
||||
const enriched = contracts.map((c) => ({ ...c, companyName: companyMap.get(c.companyId) ?? '—' }))
|
||||
|
||||
const stats = {
|
||||
total: contracts.length,
|
||||
active: contracts.filter((c) => c.status === 'active').length,
|
||||
draft: contracts.filter((c) => c.status === 'draft').length,
|
||||
sent: contracts.filter((c) => c.status === 'sent').length,
|
||||
signed: contracts.filter((c) => c.status === 'signed' || c.status === 'active').length,
|
||||
expired: contracts.filter((c) => c.status === 'expired').length,
|
||||
expiringSoon: contracts.filter((c) => {
|
||||
if (!c.endDate || c.status === 'expired') return false
|
||||
const days = (c.endDate.getTime() - Date.now()) / 86400000
|
||||
return days <= 30 && days >= 0
|
||||
}).length,
|
||||
totalValueCents: contracts.filter((c) => c.status === 'active').reduce((s, c) => s + c.valueCents, 0),
|
||||
}
|
||||
|
||||
return NextResponse.json({ contracts: enriched, stats })
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// POST /api/aos/expenses/[id]/convert — convert a billable expense into an invoice
|
||||
// (adapted from PerfEx "Converting Billable Expense to Invoice")
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const expense = await db.expense.findUnique({ where: { id } })
|
||||
if (!expense) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
if (!expense.billable) return NextResponse.json({ error: 'expense not billable' }, { status: 409 })
|
||||
if (expense.status === 'invoiced') return NextResponse.json({ error: 'already invoiced' }, { status: 409 })
|
||||
if (!expense.companyId) return NextResponse.json({ error: 'no customer linked' }, { status: 409 })
|
||||
|
||||
// compute re-bill amount with markup
|
||||
const markupMultiplier = 1 + expense.markupPct / 100
|
||||
const netCents = Math.round(expense.amountCents * markupMultiplier)
|
||||
const taxBp = expense.taxBp
|
||||
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
|
||||
|
||||
// gapless invoice number
|
||||
const year = new Date().getFullYear()
|
||||
const count = await db.invoice.count({ where: { number: { startsWith: `RE-${year}-` } } })
|
||||
const number = `RE-${year}-${String(count + 1).padStart(4, '0')}`
|
||||
|
||||
const dueDate = new Date()
|
||||
dueDate.setDate(dueDate.getDate() + 14)
|
||||
|
||||
const invoice = await db.invoice.create({
|
||||
data: {
|
||||
number,
|
||||
companyId: expense.companyId,
|
||||
status: 'draft',
|
||||
type: 'final',
|
||||
issueDate: new Date(),
|
||||
dueDate,
|
||||
netCents,
|
||||
taxBp,
|
||||
grossCents,
|
||||
zugferd: false,
|
||||
xrechnung: false,
|
||||
datevExported: false,
|
||||
projectId: expense.projectId,
|
||||
},
|
||||
})
|
||||
|
||||
// one line item describing the expense
|
||||
const desc = expense.markupPct > 0
|
||||
? `${expense.description} (Fremdleistung +${expense.markupPct}% Service-Pauschale)`
|
||||
: expense.description
|
||||
|
||||
await db.invoiceLine.create({
|
||||
data: {
|
||||
invoiceId: invoice.id,
|
||||
description: desc,
|
||||
quantity: 1,
|
||||
unitCents: netCents,
|
||||
totalCents: netCents,
|
||||
},
|
||||
})
|
||||
|
||||
// mark expense as invoiced
|
||||
const updated = await db.expense.update({
|
||||
where: { id },
|
||||
data: { status: 'invoiced', convertedInvoiceId: invoice.id },
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'nora@nordlicht.digital',
|
||||
action: 'expense.convert:invoice',
|
||||
entity: 'Expense',
|
||||
entityId: id,
|
||||
before: JSON.stringify({ status: expense.status, amountCents: expense.amountCents }),
|
||||
after: JSON.stringify({ status: 'invoiced', invoiceId: invoice.id, invoiceNumber: number, netCents }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ expense: updated, invoice })
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// GET /api/aos/expenses — expenses list with company/project, stats, reports
|
||||
export async function GET() {
|
||||
const expenses = await db.expense.findMany({ orderBy: { date: 'desc' } })
|
||||
|
||||
const companyIds = [...new Set(expenses.map((e) => e.companyId).filter(Boolean) as string[])]
|
||||
const projectIds = [...new Set(expenses.map((e) => e.projectId).filter(Boolean) as string[])]
|
||||
const [companies, projects] = await Promise.all([
|
||||
db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }),
|
||||
db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, name: true } }),
|
||||
])
|
||||
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
|
||||
const projectMap = new Map(projects.map((p) => [p.id, p.name]))
|
||||
|
||||
const enriched = expenses.map((e) => ({
|
||||
...e,
|
||||
companyName: e.companyId ? companyMap.get(e.companyId) ?? null : null,
|
||||
projectName: e.projectId ? projectMap.get(e.projectId) ?? null : null,
|
||||
}))
|
||||
|
||||
const stats = {
|
||||
total: expenses.length,
|
||||
billable: expenses.filter((e) => e.billable).length,
|
||||
recurring: expenses.filter((e) => e.recurring).length,
|
||||
billableCents: expenses.filter((e) => e.billable && e.status !== 'invoiced').reduce((s, e) => s + e.grossCents, 0),
|
||||
totalGrossCents: expenses.reduce((s, e) => s + e.grossCents, 0),
|
||||
invoiced: expenses.filter((e) => e.status === 'invoiced').length,
|
||||
pendingApproval: expenses.filter((e) => e.status === 'recorded').length,
|
||||
datevExported: expenses.filter((e) => e.datevExported).length,
|
||||
}
|
||||
|
||||
// expense report: by category
|
||||
const byCategory: Record<string, { count: number; grossCents: number }> = {}
|
||||
for (const e of expenses) {
|
||||
if (!byCategory[e.categoryId]) byCategory[e.categoryId] = { count: 0, grossCents: 0 }
|
||||
byCategory[e.categoryId].count++
|
||||
byCategory[e.categoryId].grossCents += e.grossCents
|
||||
}
|
||||
|
||||
// monthly (last 6 months)
|
||||
const now = new Date()
|
||||
const monthly: { label: string; cents: number }[] = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const label = m.toLocaleDateString('de-DE', { month: 'short' })
|
||||
const cents = expenses
|
||||
.filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear())
|
||||
.reduce((s, e) => s + e.grossCents, 0)
|
||||
monthly.push({ label, cents })
|
||||
}
|
||||
|
||||
return NextResponse.json({ expenses: enriched, stats, report: { byCategory, monthly } })
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export async function POST() {
|
||||
try {
|
||||
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
|
||||
const { db } = await import('@/lib/db')
|
||||
await db.expense.deleteMany()
|
||||
await db.contract.deleteMany()
|
||||
await db.invoiceLine.deleteMany()
|
||||
await db.invoice.deleteMany()
|
||||
await db.asset.deleteMany()
|
||||
|
||||
Reference in new issue
Block a user