diff --git a/prisma/migrations/202605011330_invoice_date/migration.sql b/prisma/migrations/202605011330_invoice_date/migration.sql new file mode 100644 index 0000000..3f662f0 --- /dev/null +++ b/prisma/migrations/202605011330_invoice_date/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "expenses" ADD COLUMN "invoice_date" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 526504e..72d7433 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,7 @@ model Expense { recurrenceStartAt DateTime? @map("recurrence_start_at") paidAt DateTime? @map("paid_at") documentedAt DateTime? @map("documented_at") + invoiceDate DateTime? @map("invoice_date") proofUrl String? @map("proof_url") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index bde2513..5ad6128 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -516,6 +516,7 @@ export async function POST(_: Request, { params }: Context) { approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED", recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY", recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"), + invoiceDate: asDate(deleted.invoiceDate, "Rechnungsdatum"), proofUrl: asNullableString(deleted.proofUrl), createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(), paidAt: asDate(deleted.paidAt, "Bezahlt am"), @@ -574,6 +575,7 @@ export async function POST(_: Request, { params }: Context) { }, data: { proofUrl: asNullableString(rollback.previousProofUrl), + invoiceDate: asDate(rollback.previousInvoiceDate, "Vorheriges Rechnungsdatum"), documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt") } }); diff --git a/src/app/api/expenses/[id]/paid/route.ts b/src/app/api/expenses/[id]/paid/route.ts index 210e2f2..3857b50 100644 --- a/src/app/api/expenses/[id]/paid/route.ts +++ b/src/app/api/expenses/[id]/paid/route.ts @@ -35,6 +35,10 @@ export async function POST(_: Request, { params }: Context) { return NextResponse.json({ error: "Bezahlt ist erst nach Freigabe moeglich." }, { status: 400 }); } + if (!expense.proofUrl || !expense.invoiceDate || !expense.documentedAt) { + return NextResponse.json({ error: "Bitte zuerst Rechnung mit Rechnungsdatum abgeben." }, { status: 400 }); + } + const updatedExpense = await prisma.expense.update({ where: { id }, data: { diff --git a/src/app/api/expenses/[id]/proof/route.ts b/src/app/api/expenses/[id]/proof/route.ts index 62942f6..992d5dd 100644 --- a/src/app/api/expenses/[id]/proof/route.ts +++ b/src/app/api/expenses/[id]/proof/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { createAuditLog } from "@/lib/audit-log"; import { canDocumentExpense } from "@/lib/domain"; import { uploadExpenseProofToDrive } from "@/lib/google-drive"; import prisma from "@/lib/prisma"; @@ -8,6 +9,15 @@ import { getCurrentViewer } from "@/lib/session"; const ACCEPTED_MIME_TYPES = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]); const MAX_FILE_SIZE = 12 * 1024 * 1024; +function parseInvoiceDate(value: FormDataEntryValue | null) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return null; + } + + const parsed = new Date(`${value}T12:00:00.000Z`); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + type Context = { params: Promise<{ id: string; @@ -34,8 +44,17 @@ export async function POST(request: Request, { params }: Context) { return NextResponse.json({ error: "Du darfst fuer diese Ausgabe keinen Beleg hochladen." }, { status: 403 }); } + if (expense.approvalStatus !== "APPROVED") { + return NextResponse.json({ error: "Belegabgabe ist erst nach Freigabe moeglich." }, { status: 400 }); + } + const formData = await request.formData().catch(() => null); const file = formData?.get("file"); + const invoiceDate = parseInvoiceDate(formData?.get("invoiceDate") ?? null); + + if (!invoiceDate) { + return NextResponse.json({ error: "Bitte ein gueltiges Rechnungsdatum angeben." }, { status: 400 }); + } if (!(file instanceof File)) { return NextResponse.json({ error: "Bitte einen Beleg als Bild oder PDF auswaehlen." }, { status: 400 }); @@ -51,6 +70,7 @@ export async function POST(request: Request, { params }: Context) { const proofUrl = await uploadExpenseProofToDrive({ title: expense.title, + invoiceDate: invoiceDate.toISOString().slice(0, 10), fileName: file.name, mimeType: file.type, buffer: Buffer.from(await file.arrayBuffer()) @@ -59,7 +79,32 @@ export async function POST(request: Request, { params }: Context) { const updatedExpense = await prisma.expense.update({ where: { id: expense.id }, data: { - proofUrl + proofUrl, + invoiceDate, + documentedAt: expense.documentedAt ?? new Date() + } + }); + + await createAuditLog(prisma, { + actorId: viewer.id, + action: "expense.document", + entityType: "expense", + entityId: updatedExpense.id, + entityLabel: updatedExpense.title, + summary: `Rechnung fuer ${updatedExpense.title} wurde abgegeben.`, + metadata: { + proofUrl: updatedExpense.proofUrl, + invoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null, + rollback: { + kind: "expense.document", + expenseId: updatedExpense.id, + previousProofUrl: expense.proofUrl, + previousInvoiceDate: expense.invoiceDate?.toISOString() ?? null, + previousDocumentedAt: expense.documentedAt?.toISOString() ?? null, + nextProofUrl: updatedExpense.proofUrl, + nextInvoiceDate: updatedExpense.invoiceDate?.toISOString() ?? null, + nextDocumentedAt: updatedExpense.documentedAt?.toISOString() ?? null + } } }); diff --git a/src/app/api/expenses/route.ts b/src/app/api/expenses/route.ts index 3942d1e..ead85f6 100644 --- a/src/app/api/expenses/route.ts +++ b/src/app/api/expenses/route.ts @@ -43,7 +43,8 @@ const expenseSchema = z }), proofUrl: z .union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) - .transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)) + .optional() + .transform(() => undefined) }) .superRefine((value, ctx) => { if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) { @@ -111,7 +112,6 @@ export async function POST(request: Request) { budgetId: parsed.data.budgetId, periodId: budget.periodId, creatorId: viewer.id, - proofUrl: parsed.data.proofUrl, recurrence: parsed.data.recurrence, recurrenceStartAt, approvalStatus: needsManualApproval ? "PENDING" : "APPROVED" diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index 062b20e..005a34c 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -39,6 +39,7 @@ const CSV_HEADERS = [ "approvalType", "recurrence", "recurrenceStartAt", + "invoiceDate", "proofUrl", "createdAt", "paidAt", @@ -205,7 +206,8 @@ export async function GET() { approvalType: "", recurrence: "", recurrenceStartAt: "", - proofUrl: "", + invoiceDate: "", + proofUrl: "", createdAt: user.createdAt.toISOString(), paidAt: "", documentedAt: "", @@ -258,7 +260,8 @@ export async function GET() { approvalType: "", recurrence: "", recurrenceStartAt: "", - proofUrl: "", + invoiceDate: "", + proofUrl: "", createdAt: period.createdAt.toISOString(), paidAt: "", documentedAt: "", @@ -311,7 +314,8 @@ export async function GET() { approvalType: "", recurrence: "", recurrenceStartAt: "", - proofUrl: "", + invoiceDate: "", + proofUrl: "", createdAt: group.createdAt.toISOString(), paidAt: "", documentedAt: "", @@ -363,7 +367,8 @@ export async function GET() { approvalType: "", recurrence: "", recurrenceStartAt: "", - proofUrl: "", + invoiceDate: "", + proofUrl: "", createdAt: budget.createdAt.toISOString(), paidAt: "", documentedAt: "", @@ -415,6 +420,7 @@ export async function GET() { approvalType: "", recurrence: expense.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "", + invoiceDate: expense.invoiceDate?.toISOString() ?? "", proofUrl: expense.proofUrl ?? "", createdAt: expense.createdAt.toISOString(), paidAt: expense.paidAt?.toISOString() ?? "", @@ -467,6 +473,7 @@ export async function GET() { approvalType: approval.approvalType, recurrence: expense.recurrence, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "", + invoiceDate: expense.invoiceDate?.toISOString() ?? "", proofUrl: "", createdAt: approval.timestamp.toISOString(), paidAt: "", @@ -523,7 +530,8 @@ export async function GET() { approvalType: "", recurrence: "", recurrenceStartAt: "", - proofUrl: "", + invoiceDate: "", + proofUrl: "", createdAt: auditLog.createdAt.toISOString(), paidAt: "", documentedAt: "", diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 96f1135..58f829c 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -226,6 +226,7 @@ export async function POST(request: Request) { approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING", recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE", recurrenceStartAt: toDate(row.recurrenceStartAt), + invoiceDate: toDate(row.invoiceDate), proofUrl: toNullable(row.proofUrl), createdAt: toDate(row.createdAt) ?? new Date(), paidAt: toDate(row.paidAt), diff --git a/src/app/page.tsx b/src/app/page.tsx index 27c8c1c..4a089d0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -185,6 +185,7 @@ export default async function DashboardPage() { recurrenceStartAt, paidAt: expense.paidAt?.toISOString() ?? null, documentedAt: expense.documentedAt?.toISOString() ?? null, + invoiceDate: expense.invoiceDate?.toISOString() ?? null, proofUrl: expense.proofUrl, createdAt: expense.createdAt.toISOString(), creator: { diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index e32c45f..d98d2e6 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -50,7 +50,7 @@ type BudgetColumnProps = { onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; - onUploadProof: (expenseId: string, file: File) => Promise; + onUploadProof: (expenseId: string, file: File, invoiceDate: string) => Promise; onSaveWorkingGroup: (groupId: string, name: string) => Promise; onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise; onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise; @@ -155,8 +155,8 @@ export function BudgetColumn({ const [editingBudgetId, setEditingBudgetId] = useState(null); const [isEditingGroup, setIsEditingGroup] = useState(false); const [groupDraftName, setGroupDraftName] = useState(group.name); - const [proofUrlDrafts, setProofUrlDrafts] = useState>({}); const [proofFileDrafts, setProofFileDrafts] = useState>({}); + const [invoiceDateDrafts, setInvoiceDateDrafts] = useState>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); const budgetCardWidth = 352; @@ -756,16 +756,23 @@ export function BudgetColumn({ ) : null} {expense.proofUrl ? ( - - {"Beleg \u00f6ffnen"} - + + + {"Rechnungsdokument \u00f6ffnen"} + + {expense.invoiceDate ? ( + + Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))} + + ) : null} + ) : null} @@ -791,7 +798,12 @@ export function BudgetColumn({ ))} - {!expense.paidAt && expense.approvalStatus === "APPROVED" && canMarkPaid(viewer.role) ? ( + {!expense.paidAt && + expense.approvalStatus === "APPROVED" && + expense.proofUrl && + expense.invoiceDate && + expense.documentedAt && + canMarkPaid(viewer.role) ? ( ) : null} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 2df6b96..501987c 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -325,7 +325,6 @@ export function DashboardShell({ const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); - const [expenseProofFile, setExpenseProofFile] = useState(null); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); useEffect(() => { if (visibleGroups.length === 0) { @@ -626,7 +625,7 @@ export function DashboardShell({ } await runAction(async () => { - const result = (await parseResponse( + await parseResponse( await fetch("/api/expenses", { method: "POST", headers: { @@ -642,19 +641,7 @@ export function DashboardShell({ recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "" }) }) - )) as { expense?: { id: string } }; - - if (expenseProofFile && result.expense?.id) { - const formData = new FormData(); - formData.set("file", expenseProofFile); - - await parseResponse( - await fetch(`/api/expenses/${result.expense.id}/proof`, { - method: "POST", - body: formData - }) - ); - } + ); const resetGroup = defaultEditableGroup?.id ?? ""; const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; @@ -668,7 +655,6 @@ export function DashboardShell({ recurrence: "NONE", recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) }); - setExpenseProofFile(null); }, "Ausgabe wurde gespeichert."); } @@ -767,18 +753,30 @@ export function DashboardShell({ }, "Ausgabe wurde dokumentiert."); } - async function handleUploadProof(expenseId: string, file: File) { - const formData = new FormData(); - formData.set("file", file); + async function handleUploadProof(expenseId: string, file: File, invoiceDate: string) { + setBusy(true); + setMessage(null); - const result = (await parseResponse( - await fetch(`/api/expenses/${expenseId}/proof`, { - method: "POST", - body: formData - }) - )) as { proofUrl: string }; + try { + const formData = new FormData(); + formData.set("file", file); + formData.set("invoiceDate", invoiceDate); - return result.proofUrl; + const result = (await parseResponse( + await fetch(`/api/expenses/${expenseId}/proof`, { + method: "POST", + body: formData + }) + )) as { proofUrl: string }; + + return result.proofUrl; + } catch (error) { + const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden."; + setMessage({ type: "error", text }); + throw error; + } finally { + setBusy(false); + } } async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) { @@ -1485,17 +1483,6 @@ export function DashboardShell({ Neue Ausgabe - {viewer.approvalPermissions.length > 0 ? ( - - ) : null} @@ -1594,34 +1581,6 @@ export function DashboardShell({ ))} - - - - - + ) : null}