Rechnungsdatum und Belegupload ueberarbeiten
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "expenses" ADD COLUMN "invoice_date" TIMESTAMP(3);
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const CSV_HEADERS = [
|
|||||||
"approvalType",
|
"approvalType",
|
||||||
"recurrence",
|
"recurrence",
|
||||||
"recurrenceStartAt",
|
"recurrenceStartAt",
|
||||||
|
"invoiceDate",
|
||||||
"proofUrl",
|
"proofUrl",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"paidAt",
|
"paidAt",
|
||||||
@@ -205,6 +206,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
recurrenceStartAt: "",
|
recurrenceStartAt: "",
|
||||||
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -258,6 +260,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
recurrenceStartAt: "",
|
recurrenceStartAt: "",
|
||||||
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: period.createdAt.toISOString(),
|
createdAt: period.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -311,6 +314,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
recurrenceStartAt: "",
|
recurrenceStartAt: "",
|
||||||
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: group.createdAt.toISOString(),
|
createdAt: group.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -363,6 +367,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
recurrenceStartAt: "",
|
recurrenceStartAt: "",
|
||||||
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: budget.createdAt.toISOString(),
|
createdAt: budget.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
@@ -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,6 +530,7 @@ export async function GET() {
|
|||||||
approvalType: "",
|
approvalType: "",
|
||||||
recurrence: "",
|
recurrence: "",
|
||||||
recurrenceStartAt: "",
|
recurrenceStartAt: "",
|
||||||
|
invoiceDate: "",
|
||||||
proofUrl: "",
|
proofUrl: "",
|
||||||
createdAt: auditLog.createdAt.toISOString(),
|
createdAt: auditLog.createdAt.toISOString(),
|
||||||
paidAt: "",
|
paidAt: "",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,6 +756,7 @@ export function BudgetColumn({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{expense.proofUrl ? (
|
{expense.proofUrl ? (
|
||||||
|
<Stack spacing={0.4}>
|
||||||
<Link
|
<Link
|
||||||
href={expense.proofUrl}
|
href={expense.proofUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -764,8 +765,14 @@ export function BudgetColumn({
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ overflowWrap: "anywhere" }}
|
sx={{ overflowWrap: "anywhere" }}
|
||||||
>
|
>
|
||||||
{"Beleg \u00f6ffnen"}
|
{"Rechnungsdokument \u00f6ffnen"}
|
||||||
</Link>
|
</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}
|
||||||
|
|||||||
@@ -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,9 +753,14 @@ 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) {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set("file", file);
|
formData.set("file", file);
|
||||||
|
formData.set("invoiceDate", invoiceDate);
|
||||||
|
|
||||||
const result = (await parseResponse(
|
const result = (await parseResponse(
|
||||||
await fetch(`/api/expenses/${expenseId}/proof`, {
|
await fetch(`/api/expenses/${expenseId}/proof`, {
|
||||||
@@ -779,6 +770,13 @@ export function DashboardShell({
|
|||||||
)) as { proofUrl: string };
|
)) as { proofUrl: string };
|
||||||
|
|
||||||
return result.proofUrl;
|
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%" }}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user