Rechnungsdatum und Belegupload ueberarbeiten
All checks were successful
CI / Build (push) Successful in 1m59s
CI / Deploy (push) Successful in 2m2s

This commit is contained in:
jan
2026-05-01 16:51:27 +02:00
parent 549c8f16c6
commit 796e134ea2
14 changed files with 165 additions and 95 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "expenses" ADD COLUMN "invoice_date" TIMESTAMP(3);

View File

@@ -133,6 +133,7 @@ model Expense {
recurrenceStartAt DateTime? @map("recurrence_start_at") recurrenceStartAt DateTime? @map("recurrence_start_at")
paidAt DateTime? @map("paid_at") paidAt DateTime? @map("paid_at")
documentedAt DateTime? @map("documented_at") documentedAt DateTime? @map("documented_at")
invoiceDate DateTime? @map("invoice_date")
proofUrl String? @map("proof_url") proofUrl String? @map("proof_url")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -516,6 +516,7 @@ export async function POST(_: Request, { params }: Context) {
approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED", approvalStatus: asString(deleted.approvalStatus, "Freigabestatus") as "PENDING" | "APPROVED",
recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY", recurrence: asString(deleted.recurrence, "Wiederholung") as "NONE" | "MONTHLY",
recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"), recurrenceStartAt: asDate(deleted.recurrenceStartAt, "Abo-Startdatum"),
invoiceDate: asDate(deleted.invoiceDate, "Rechnungsdatum"),
proofUrl: asNullableString(deleted.proofUrl), proofUrl: asNullableString(deleted.proofUrl),
createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(), createdAt: asDate(deleted.createdAt, "Ausgabe erstellt am") ?? new Date(),
paidAt: asDate(deleted.paidAt, "Bezahlt am"), paidAt: asDate(deleted.paidAt, "Bezahlt am"),
@@ -574,6 +575,7 @@ export async function POST(_: Request, { params }: Context) {
}, },
data: { data: {
proofUrl: asNullableString(rollback.previousProofUrl), proofUrl: asNullableString(rollback.previousProofUrl),
invoiceDate: asDate(rollback.previousInvoiceDate, "Vorheriges Rechnungsdatum"),
documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt") documentedAt: asDate(rollback.previousDocumentedAt, "Vorheriger Dokumentationszeitpunkt")
} }
}); });

View File

@@ -35,6 +35,10 @@ export async function POST(_: Request, { params }: Context) {
return NextResponse.json({ error: "Bezahlt ist erst nach Freigabe moeglich." }, { status: 400 }); 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({ const updatedExpense = await prisma.expense.update({
where: { id }, where: { id },
data: { data: {

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createAuditLog } from "@/lib/audit-log";
import { canDocumentExpense } from "@/lib/domain"; import { canDocumentExpense } from "@/lib/domain";
import { uploadExpenseProofToDrive } from "@/lib/google-drive"; import { uploadExpenseProofToDrive } from "@/lib/google-drive";
import prisma from "@/lib/prisma"; 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 ACCEPTED_MIME_TYPES = new Set(["application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"]);
const MAX_FILE_SIZE = 12 * 1024 * 1024; 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 = { type Context = {
params: Promise<{ params: Promise<{
id: string; 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 }); 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 formData = await request.formData().catch(() => null);
const file = formData?.get("file"); 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)) { if (!(file instanceof File)) {
return NextResponse.json({ error: "Bitte einen Beleg als Bild oder PDF auswaehlen." }, { status: 400 }); 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({ const proofUrl = await uploadExpenseProofToDrive({
title: expense.title, title: expense.title,
invoiceDate: invoiceDate.toISOString().slice(0, 10),
fileName: file.name, fileName: file.name,
mimeType: file.type, mimeType: file.type,
buffer: Buffer.from(await file.arrayBuffer()) buffer: Buffer.from(await file.arrayBuffer())
@@ -59,7 +79,32 @@ export async function POST(request: Request, { params }: Context) {
const updatedExpense = await prisma.expense.update({ const updatedExpense = await prisma.expense.update({
where: { id: expense.id }, where: { id: expense.id },
data: { 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
}
} }
}); });

View File

@@ -43,7 +43,8 @@ const expenseSchema = z
}), }),
proofUrl: z proofUrl: z
.union([z.string().trim().url(), z.literal(""), z.null(), z.undefined()]) .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) => { .superRefine((value, ctx) => {
if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) { if (value.recurrence === "MONTHLY" && !value.recurrenceStartAt) {
@@ -111,7 +112,6 @@ export async function POST(request: Request) {
budgetId: parsed.data.budgetId, budgetId: parsed.data.budgetId,
periodId: budget.periodId, periodId: budget.periodId,
creatorId: viewer.id, creatorId: viewer.id,
proofUrl: parsed.data.proofUrl,
recurrence: parsed.data.recurrence, recurrence: parsed.data.recurrence,
recurrenceStartAt, recurrenceStartAt,
approvalStatus: needsManualApproval ? "PENDING" : "APPROVED" approvalStatus: needsManualApproval ? "PENDING" : "APPROVED"

View File

@@ -39,6 +39,7 @@ const CSV_HEADERS = [
"approvalType", "approvalType",
"recurrence", "recurrence",
"recurrenceStartAt", "recurrenceStartAt",
"invoiceDate",
"proofUrl", "proofUrl",
"createdAt", "createdAt",
"paidAt", "paidAt",
@@ -205,7 +206,8 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: "", recurrence: "",
recurrenceStartAt: "", recurrenceStartAt: "",
proofUrl: "", invoiceDate: "",
proofUrl: "",
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
paidAt: "", paidAt: "",
documentedAt: "", documentedAt: "",
@@ -258,7 +260,8 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: "", recurrence: "",
recurrenceStartAt: "", recurrenceStartAt: "",
proofUrl: "", invoiceDate: "",
proofUrl: "",
createdAt: period.createdAt.toISOString(), createdAt: period.createdAt.toISOString(),
paidAt: "", paidAt: "",
documentedAt: "", documentedAt: "",
@@ -311,7 +314,8 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: "", recurrence: "",
recurrenceStartAt: "", recurrenceStartAt: "",
proofUrl: "", invoiceDate: "",
proofUrl: "",
createdAt: group.createdAt.toISOString(), createdAt: group.createdAt.toISOString(),
paidAt: "", paidAt: "",
documentedAt: "", documentedAt: "",
@@ -363,7 +367,8 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: "", recurrence: "",
recurrenceStartAt: "", recurrenceStartAt: "",
proofUrl: "", invoiceDate: "",
proofUrl: "",
createdAt: budget.createdAt.toISOString(), createdAt: budget.createdAt.toISOString(),
paidAt: "", paidAt: "",
documentedAt: "", documentedAt: "",
@@ -415,6 +420,7 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: expense.recurrence, recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "", recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
proofUrl: expense.proofUrl ?? "", proofUrl: expense.proofUrl ?? "",
createdAt: expense.createdAt.toISOString(), createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? "", paidAt: expense.paidAt?.toISOString() ?? "",
@@ -467,6 +473,7 @@ export async function GET() {
approvalType: approval.approvalType, approvalType: approval.approvalType,
recurrence: expense.recurrence, recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "", recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? "",
invoiceDate: expense.invoiceDate?.toISOString() ?? "",
proofUrl: "", proofUrl: "",
createdAt: approval.timestamp.toISOString(), createdAt: approval.timestamp.toISOString(),
paidAt: "", paidAt: "",
@@ -523,7 +530,8 @@ export async function GET() {
approvalType: "", approvalType: "",
recurrence: "", recurrence: "",
recurrenceStartAt: "", recurrenceStartAt: "",
proofUrl: "", invoiceDate: "",
proofUrl: "",
createdAt: auditLog.createdAt.toISOString(), createdAt: auditLog.createdAt.toISOString(),
paidAt: "", paidAt: "",
documentedAt: "", documentedAt: "",

View File

@@ -226,6 +226,7 @@ export async function POST(request: Request) {
approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING", approvalStatus: row.approvalStatus === "APPROVED" ? "APPROVED" : "PENDING",
recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE", recurrence: row.recurrence === "MONTHLY" ? "MONTHLY" : "NONE",
recurrenceStartAt: toDate(row.recurrenceStartAt), recurrenceStartAt: toDate(row.recurrenceStartAt),
invoiceDate: toDate(row.invoiceDate),
proofUrl: toNullable(row.proofUrl), proofUrl: toNullable(row.proofUrl),
createdAt: toDate(row.createdAt) ?? new Date(), createdAt: toDate(row.createdAt) ?? new Date(),
paidAt: toDate(row.paidAt), paidAt: toDate(row.paidAt),

View File

@@ -185,6 +185,7 @@ export default async function DashboardPage() {
recurrenceStartAt, recurrenceStartAt,
paidAt: expense.paidAt?.toISOString() ?? null, paidAt: expense.paidAt?.toISOString() ?? null,
documentedAt: expense.documentedAt?.toISOString() ?? null, documentedAt: expense.documentedAt?.toISOString() ?? null,
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
proofUrl: expense.proofUrl, proofUrl: expense.proofUrl,
createdAt: expense.createdAt.toISOString(), createdAt: expense.createdAt.toISOString(),
creator: { creator: {

View File

@@ -50,7 +50,7 @@ type BudgetColumnProps = {
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>; onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>; onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>; onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
onUploadProof: (expenseId: string, file: File) => Promise<string>; onUploadProof: (expenseId: string, file: File, invoiceDate: string) => Promise<string>;
onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>; onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>;
onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>; onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>;
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>; onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
@@ -155,8 +155,8 @@ export function BudgetColumn({
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null); const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
const [isEditingGroup, setIsEditingGroup] = useState(false); const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupDraftName, setGroupDraftName] = useState(group.name); const [groupDraftName, setGroupDraftName] = useState(group.name);
const [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({}); const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({});
const [invoiceDateDrafts, setInvoiceDateDrafts] = useState<Record<string, string>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const budgetCardWidth = 352; const budgetCardWidth = 352;
@@ -756,16 +756,23 @@ export function BudgetColumn({
) : null} ) : null}
{expense.proofUrl ? ( {expense.proofUrl ? (
<Link <Stack spacing={0.4}>
href={expense.proofUrl} <Link
target="_blank" href={expense.proofUrl}
rel="noreferrer" target="_blank"
underline="hover" rel="noreferrer"
variant="body2" underline="hover"
sx={{ overflowWrap: "anywhere" }} variant="body2"
> sx={{ overflowWrap: "anywhere" }}
{"Beleg \u00f6ffnen"} >
</Link> {"Rechnungsdokument \u00f6ffnen"}
</Link>
{expense.invoiceDate ? (
<Typography variant="caption" color="text.secondary">
Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))}
</Typography>
) : null}
</Stack>
) : null} ) : null}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap"> <Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
@@ -791,7 +798,12 @@ export function BudgetColumn({
</Button> </Button>
))} ))}
{!expense.paidAt && expense.approvalStatus === "APPROVED" && canMarkPaid(viewer.role) ? ( {!expense.paidAt &&
expense.approvalStatus === "APPROVED" &&
expense.proofUrl &&
expense.invoiceDate &&
expense.documentedAt &&
canMarkPaid(viewer.role) ? (
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
@@ -829,8 +841,25 @@ export function BudgetColumn({
) : null} ) : null}
</Stack> </Stack>
{expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? ( {!expense.paidAt &&
<Stack direction={{ xs: "column", sm: "row" }} gap={1}> expense.approvalStatus === "APPROVED" &&
!expense.proofUrl &&
canDocumentExpense(viewer.role) ? (
<Stack direction={{ xs: "column", sm: "row" }} gap={1} alignItems={{ sm: "center" }}>
<TextField
label="Rechnungsdatum"
type="date"
value={invoiceDateDrafts[expense.id] ?? ""}
onChange={(event) =>
setInvoiceDateDrafts((current) => ({
...current,
[expense.id]: event.target.value
}))
}
InputLabelProps={{ shrink: true }}
size="small"
required
/>
<TextField <TextField
label="Beleg" label="Beleg"
value={proofFileDrafts[expense.id]?.name ?? expense.proofUrl ?? ""} value={proofFileDrafts[expense.id]?.name ?? expense.proofUrl ?? ""}
@@ -874,14 +903,17 @@ export function BudgetColumn({
disabled={busy} disabled={busy}
onClick={async () => { onClick={async () => {
const proofFile = proofFileDrafts[expense.id]; const proofFile = proofFileDrafts[expense.id];
const proofUrl = proofFile const invoiceDate = invoiceDateDrafts[expense.id] ?? "";
? await onUploadProof(expense.id, proofFile)
: proofUrlDrafts[expense.id] ?? expense.proofUrl ?? undefined;
await onDocument(expense.id, proofUrl); if (!proofFile || !invoiceDate) {
return;
}
await onUploadProof(expense.id, proofFile, invoiceDate);
await onMarkPaid(expense.id);
}} }}
> >
Dokumentieren Rechnung abgeben und bezahlt setzen
</Button> </Button>
</Stack> </Stack>
) : null} ) : null}

View File

@@ -325,7 +325,6 @@ export function DashboardShell({
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2)); const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod)); const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [expenseProofFile, setExpenseProofFile] = useState<File | null>(null);
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
useEffect(() => { useEffect(() => {
if (visibleGroups.length === 0) { if (visibleGroups.length === 0) {
@@ -626,7 +625,7 @@ export function DashboardShell({
} }
await runAction(async () => { await runAction(async () => {
const result = (await parseResponse( await parseResponse(
await fetch("/api/expenses", { await fetch("/api/expenses", {
method: "POST", method: "POST",
headers: { headers: {
@@ -642,19 +641,7 @@ export function DashboardShell({
recurrenceStartAt: expenseForm.recurrence === "MONTHLY" ? expenseForm.recurrenceStartAt : "" 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 resetGroup = defaultEditableGroup?.id ?? "";
const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? ""; const resetBudget = defaultEditableGroup?.budgets[0]?.id ?? "";
@@ -668,7 +655,6 @@ export function DashboardShell({
recurrence: "NONE", recurrence: "NONE",
recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString()) recurrenceStartAt: toDateInputValue(currentPeriod?.startsAt ?? new Date().toISOString())
}); });
setExpenseProofFile(null);
}, "Ausgabe wurde gespeichert."); }, "Ausgabe wurde gespeichert.");
} }
@@ -767,18 +753,30 @@ export function DashboardShell({
}, "Ausgabe wurde dokumentiert."); }, "Ausgabe wurde dokumentiert.");
} }
async function handleUploadProof(expenseId: string, file: File) { async function handleUploadProof(expenseId: string, file: File, invoiceDate: string) {
const formData = new FormData(); setBusy(true);
formData.set("file", file); setMessage(null);
const result = (await parseResponse( try {
await fetch(`/api/expenses/${expenseId}/proof`, { const formData = new FormData();
method: "POST", formData.set("file", file);
body: formData formData.set("invoiceDate", invoiceDate);
})
)) as { proofUrl: string };
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) { async function handleSaveBudget(budgetId: string, name: string, totalBudget: string, colorCode: string) {
@@ -1485,17 +1483,6 @@ export function DashboardShell({
Neue Ausgabe Neue Ausgabe
</Typography> </Typography>
</Box> </Box>
{viewer.approvalPermissions.length > 0 ? (
<Button
type="button"
variant={pushStatus === "enabled" ? "contained" : "outlined"}
startIcon={<NotificationsActiveRoundedIcon />}
disabled={busy || pushStatus === "unsupported"}
onClick={handleEnablePushNotifications}
>
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push aktivieren"}
</Button>
) : null}
<Box component="form" onSubmit={handleCreateExpense}> <Box component="form" onSubmit={handleCreateExpense}>
<Stack spacing={2}> <Stack spacing={2}>
@@ -1594,34 +1581,6 @@ export function DashboardShell({
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
<TextField
label="Beleg"
value={expenseProofFile?.name ?? ""}
fullWidth
InputProps={{ readOnly: true }}
helperText="Optional: Bild oder PDF auswählen. Auf Mobilgeräten kann die Kamera angeboten werden."
/>
<Stack direction={{ xs: "column", sm: "row" }} gap={1} useFlexGap flexWrap="wrap">
<Button component="label" variant="outlined" startIcon={<UploadFileRoundedIcon />} disabled={busy}>
Beleg auswählen
<input
hidden
type="file"
accept="image/*,application/pdf"
onChange={(event) => setExpenseProofFile(event.target.files?.[0] ?? null)}
/>
</Button>
<Button component="label" variant="outlined" disabled={busy}>
Kamera öffnen
<input
hidden
type="file"
accept="image/*"
capture="environment"
onChange={(event) => setExpenseProofFile(event.target.files?.[0] ?? null)}
/>
</Button>
</Stack>
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
@@ -2534,6 +2493,19 @@ export function DashboardShell({
</Typography> </Typography>
</Box> </Box>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}> <Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
{viewer.approvalPermissions.length > 0 ? (
<Button
type="button"
size="small"
variant={pushStatus === "enabled" ? "contained" : "outlined"}
startIcon={<NotificationsActiveRoundedIcon />}
disabled={busy || pushStatus === "unsupported"}
sx={{ borderColor: alpha("#FFFFFF", 0.28), color: "white" }}
onClick={handleEnablePushNotifications}
>
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
</Button>
) : null}
<Chip <Chip
label={`${viewer.username} - ${roleLabel(viewer.role)}`} label={`${viewer.username} - ${roleLabel(viewer.role)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }} sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}

View File

@@ -56,6 +56,7 @@ export function snapshotExpense(
| "approvalStatus" | "approvalStatus"
| "recurrence" | "recurrence"
| "recurrenceStartAt" | "recurrenceStartAt"
| "invoiceDate"
| "proofUrl" | "proofUrl"
| "createdAt" | "createdAt"
| "paidAt" | "paidAt"
@@ -74,6 +75,7 @@ export function snapshotExpense(
approvalStatus: expense.approvalStatus, approvalStatus: expense.approvalStatus,
recurrence: expense.recurrence, recurrence: expense.recurrence,
recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null, recurrenceStartAt: expense.recurrenceStartAt?.toISOString() ?? null,
invoiceDate: expense.invoiceDate?.toISOString() ?? null,
proofUrl: expense.proofUrl, proofUrl: expense.proofUrl,
createdAt: expense.createdAt.toISOString(), createdAt: expense.createdAt.toISOString(),
paidAt: expense.paidAt?.toISOString() ?? null, paidAt: expense.paidAt?.toISOString() ?? null,

View File

@@ -49,6 +49,7 @@ export type DashboardExpense = {
recurrenceStartAt: string | null; recurrenceStartAt: string | null;
paidAt: string | null; paidAt: string | null;
documentedAt: string | null; documentedAt: string | null;
invoiceDate: string | null;
proofUrl: string | null; proofUrl: string | null;
createdAt: string; createdAt: string;
creator: { creator: {

View File

@@ -33,6 +33,7 @@ export function sanitizeDriveFileName(title: string, fallback = "beleg") {
export async function uploadExpenseProofToDrive(input: { export async function uploadExpenseProofToDrive(input: {
title: string; title: string;
invoiceDate: string;
fileName: string; fileName: string;
mimeType: string; mimeType: string;
buffer: Buffer; buffer: Buffer;
@@ -41,8 +42,7 @@ export async function uploadExpenseProofToDrive(input: {
const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID; const folderId = process.env.GOOGLE_DRIVE_FOLDER_ID || DEFAULT_DRIVE_FOLDER_ID;
const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : ""; const extension = input.fileName.includes(".") ? `.${input.fileName.split(".").pop()}` : "";
const baseName = sanitizeDriveFileName(input.title); const baseName = sanitizeDriveFileName(input.title);
const uniqueSuffix = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const name = `${input.invoiceDate}-${baseName}${extension}`;
const name = `${baseName}-${uniqueSuffix}${extension}`;
const response = await drive.files.create({ const response = await drive.files.create({
requestBody: { requestBody: {