AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s

This commit is contained in:
jan
2026-05-05 21:57:20 +02:00
parent 99d4f6dd22
commit f87a82e02f
21 changed files with 885 additions and 323 deletions

View File

@@ -32,7 +32,6 @@ import { useEffect, useMemo, useState } from "react";
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
import {
APPROVAL_FLOW,
approvalLabel,
canDeleteExpense,
canDocumentExpense,
@@ -48,6 +47,7 @@ type BudgetColumnProps = {
viewer: DashboardViewer;
busy: boolean;
approvalThreshold: number;
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
@@ -140,6 +140,7 @@ export function BudgetColumn({
viewer,
busy,
approvalThreshold,
requiredApprovalTypes,
onApprove,
onMarkPaid,
onDocument,
@@ -156,8 +157,7 @@ export function BudgetColumn({
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupDraftName, setGroupDraftName] = useState(group.name);
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({});
const [invoiceDateDrafts, setInvoiceDateDrafts] = useState<Record<string, string>>({});
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const budgetCardWidth = 352;
@@ -223,6 +223,34 @@ export function BudgetColumn({
}));
}
function addProofFiles(expenseId: string, files: FileList | null) {
if (!files || files.length === 0) {
return;
}
const nextFiles = Array.from(files).map((file) => ({ file, invoiceDate: "" }));
setProofFileDrafts((current) => ({
...current,
[expenseId]: [...(current[expenseId] ?? []), ...nextFiles]
}));
}
function updateProofInvoiceDate(expenseId: string, index: number, invoiceDate: string) {
setProofFileDrafts((current) => ({
...current,
[expenseId]: (current[expenseId] ?? []).map((entry, entryIndex) =>
entryIndex === index ? { ...entry, invoiceDate } : entry
)
}));
}
function removeProofDraft(expenseId: string, index: number) {
setProofFileDrafts((current) => ({
...current,
[expenseId]: (current[expenseId] ?? []).filter((_, entryIndex) => entryIndex !== index)
}));
}
return (
<Card
sx={{
@@ -231,7 +259,8 @@ export function BudgetColumn({
maxWidth: "none",
flexShrink: 0,
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98),
backgroundImage: "none"
backgroundImage: "none",
touchAction: "pan-y"
}}
>
<CardContent sx={{ p: 3 }}>
@@ -384,20 +413,16 @@ export function BudgetColumn({
</Box>
) : null}
<Box
sx={{
display: "flex",
gap: 2,
overflowX: { xs: "auto", md: "visible" },
overflowY: "hidden",
pb: { xs: 1.5, md: 0 },
alignItems: "stretch",
scrollSnapType: { xs: "x proximity", md: "none" },
scrollbarGutter: { xs: "stable both-edges", md: "auto" },
overscrollBehaviorX: "contain",
width: { md: desktopBudgetListWidth },
minWidth: { md: desktopBudgetListWidth }
}}
<Box
sx={{
display: "flex",
gap: 2,
overflow: "visible",
pb: 0,
alignItems: "stretch",
width: { md: desktopBudgetListWidth },
minWidth: { md: desktopBudgetListWidth }
}}
>
{group.budgets.map((budget) => {
const draft = getDraft(budget);
@@ -644,10 +669,10 @@ export function BudgetColumn({
) : null}
{budget.expenses.map((expense) => {
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
: [];
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes, requiredApprovalTypes)
: [];
const isRecurringSeries = expense.recurrence === "MONTHLY";
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
@@ -655,15 +680,16 @@ export function BudgetColumn({
return (
<Box
key={expense.id}
sx={{
p: 2.25,
borderRadius: "18px",
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
backgroundColor:
expense.approvalStatus === "APPROVED"
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
: alpha(budget.colorCode, isDark ? 0.1 : 0.04)
}}
sx={{
p: 2.25,
borderRadius: "18px",
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
backgroundColor:
expense.approvalStatus === "APPROVED"
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
: alpha(budget.colorCode, isDark ? 0.1 : 0.04),
touchAction: "pan-y"
}}
>
<Stack spacing={1.4}>
<Stack spacing={1}>
@@ -753,7 +779,7 @@ export function BudgetColumn({
{requiresManualApproval(expense.amount, approvalThreshold) ? (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => {
{requiredApprovalTypes.map((approvalType) => {
const matchingApproval = expense.approvals.find(
(approval) => approval.approvalType === approvalType
);
@@ -776,25 +802,27 @@ export function BudgetColumn({
</Stack>
) : null}
{expense.proofUrl ? (
<Stack spacing={0.4}>
<Link
href={expense.proofUrl}
target="_blank"
rel="noreferrer"
underline="hover"
variant="body2"
sx={{ overflowWrap: "anywhere" }}
>
{"Rechnungsdokument \u00f6ffnen"}
</Link>
{expense.invoiceDate ? (
<Typography variant="caption" color="text.secondary">
Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))}
</Typography>
) : null}
</Stack>
) : null}
{expense.documents.length > 0 ? (
<Stack spacing={0.5}>
{expense.documents.map((document, documentIndex) => (
<Box key={document.id}>
<Link
href={document.proofUrl}
target="_blank"
rel="noreferrer"
underline="hover"
variant="body2"
sx={{ overflowWrap: "anywhere" }}
>
{`Rechnung ${documentIndex + 1}: ${document.storedFileName}`}
</Link>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
Rechnung vom {dateFormatter.format(new Date(document.invoiceDate))}
</Typography>
</Box>
))}
</Stack>
) : null}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{availableApprovals.map((approvalType) => (
@@ -869,28 +897,10 @@ export function BudgetColumn({
) : null}
</Stack>
{!expense.paidAt &&
expense.approvalStatus === "APPROVED" &&
!expense.proofUrl &&
canUploadProof ? (
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
<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
fullWidth
/>
<Stack direction="row" spacing={1}>
<Button
{expense.approvalStatus === "APPROVED" && canUploadProof ? (
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
<Stack direction="row" spacing={1}>
<Button
component="label"
size="small"
variant="outlined"
@@ -900,18 +910,14 @@ export function BudgetColumn({
sx={{ minWidth: 0 }}
>
Datei
<input
hidden
type="file"
accept="image/*,application/pdf"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
<input
hidden
type="file"
multiple
accept="image/*,application/pdf"
onChange={(event) => addProofFiles(expense.id, event.target.files)}
/>
</Button>
<Button
component="label"
size="small"
@@ -924,28 +930,42 @@ export function BudgetColumn({
Kamera
<input
hidden
type="file"
accept="image/*"
capture="environment"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
</Stack>
{proofFileDrafts[expense.id]?.name ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", overflowWrap: "anywhere" }}
>
{proofFileDrafts[expense.id]?.name}
</Typography>
) : null}
<Button
type="file"
accept="image/*"
capture="environment"
onChange={(event) => addProofFiles(expense.id, event.target.files)}
/>
</Button>
</Stack>
{(proofFileDrafts[expense.id] ?? []).map((entry, entryIndex) => (
<Stack key={`${entry.file.name}-${entryIndex}`} spacing={0.7}>
<Typography variant="caption" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
{entry.file.name}
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
label="Rechnungsdatum"
type="date"
value={entry.invoiceDate}
onChange={(event) => updateProofInvoiceDate(expense.id, entryIndex, event.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
required
fullWidth
/>
<Button
type="button"
size="small"
variant="text"
color="error"
onClick={() => removeProofDraft(expense.id, entryIndex)}
>
Entfernen
</Button>
</Stack>
</Stack>
))}
<Button
size="medium"
variant="contained"
color="success"
@@ -960,18 +980,24 @@ export function BudgetColumn({
textAlign: "center"
}}
onClick={async () => {
const proofFile = proofFileDrafts[expense.id];
const invoiceDate = invoiceDateDrafts[expense.id] ?? "";
const proofDrafts = proofFileDrafts[expense.id] ?? [];
if (!proofFile || !invoiceDate) {
return;
}
if (proofDrafts.length === 0 || proofDrafts.some((entry) => !entry.invoiceDate)) {
return;
}
await onUploadProof(expense.id, proofFile, invoiceDate);
}}
>
Rechnung abgeben und bezahlt setzen
</Button>
for (const entry of proofDrafts) {
await onUploadProof(expense.id, entry.file, entry.invoiceDate);
}
setProofFileDrafts((current) => ({
...current,
[expense.id]: []
}));
}}
>
{expense.paidAt ? "Rechnung nachreichen" : "Rechnung abgeben und bezahlt setzen"}
</Button>
</Stack>
) : null}