Files
RFP_Finanzen/src/components/dashboard/budget-column.tsx
jan bd59e50a51
All checks were successful
CI / Build (push) Successful in 2m1s
CI / Deploy (push) Successful in 2m1s
Belegupload nach Freigabe und Bezahlt-Logik korrigieren
2026-05-01 17:16:18 +02:00

960 lines
44 KiB
TypeScript

"use client";
import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded";
import CloseRoundedIcon from "@mui/icons-material/CloseRounded";
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
import DoneAllRoundedIcon from "@mui/icons-material/DoneAllRounded";
import EditRoundedIcon from "@mui/icons-material/EditRounded";
import ExpandLessRoundedIcon from "@mui/icons-material/ExpandLessRounded";
import ExpandMoreRoundedIcon from "@mui/icons-material/ExpandMoreRounded";
import EuroRoundedIcon from "@mui/icons-material/EuroRounded";
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
import {
Box,
Button,
Card,
CardContent,
Chip,
Collapse,
Divider,
IconButton,
Link,
Stack,
TextField,
Typography
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
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,
canManageBudgets,
canMarkPaid,
getAvailableApprovalTypes,
recurrenceLabel,
requiresManualApproval
} from "@/lib/domain";
type BudgetColumnProps = {
group: DashboardWorkingGroup;
viewer: DashboardViewer;
busy: boolean;
approvalThreshold: number;
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
onUploadProof: (expenseId: string, file: File, invoiceDate: string) => Promise<string>;
onSaveWorkingGroup: (groupId: string, name: string) => Promise<void>;
onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise<void>;
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
onDeleteBudget: (budgetId: string) => Promise<void>;
onDeleteExpense: (expenseId: string) => Promise<void>;
};
type BudgetDraft = {
name: string;
totalBudget: string;
colorCode: string;
};
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR"
});
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium"
});
const wrappingChipSx = {
height: "auto",
"& .MuiChip-label": {
display: "block",
whiteSpace: "normal",
py: 0.5
}
} as const;
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function createDraft(budget: DashboardBudget): BudgetDraft {
return {
name: budget.name,
totalBudget: budget.totalBudget.toFixed(2),
colorCode: budget.colorCode
};
}
function StatusChips({ expense }: { expense: DashboardExpense }) {
return (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip
label={expense.approvalStatus === "APPROVED" ? "Freigegeben" : "Geplant"}
color={expense.approvalStatus === "APPROVED" ? "primary" : "default"}
variant={expense.approvalStatus === "APPROVED" ? "filled" : "outlined"}
size="small"
sx={wrappingChipSx}
/>
{expense.paidAt ? (
<Chip label="Bezahlt" color="info" size="small" icon={<CheckCircleRoundedIcon />} sx={wrappingChipSx} />
) : null}
{expense.documentedAt ? (
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
) : null}
{expense.recurrence === "MONTHLY" ? (
<Chip
label={recurrenceLabel(expense.recurrence)}
color="secondary"
variant="outlined"
size="small"
sx={wrappingChipSx}
/>
) : null}
</Stack>
);
}
function getApprovedSpend(expenses: DashboardExpense[]) {
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.periodAmount : 0), 0);
}
function getPendingSpend(expenses: DashboardExpense[]) {
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0);
}
function getPaidSpend(expenses: DashboardExpense[]) {
return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0);
}
export function BudgetColumn({
group,
viewer,
busy,
approvalThreshold,
onApprove,
onMarkPaid,
onDocument,
onUploadProof,
onSaveWorkingGroup,
onDeleteWorkingGroup,
onSaveBudget,
onDeleteBudget,
onDeleteExpense
}: BudgetColumnProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [budgetDrafts, setBudgetDrafts] = useState<Record<string, BudgetDraft>>({});
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 [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const budgetCardWidth = 352;
const desktopBudgetGap = 16;
const desktopBudgetListWidth =
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
const groupCardWidth = Math.max(desktopBudgetListWidth + 64, 456);
const canEditBudgets = canManageBudgets(viewer.role);
useEffect(() => {
setBudgetDrafts(
Object.fromEntries(group.budgets.map((budget) => [budget.id, createDraft(budget)]))
);
}, [group.budgets]);
useEffect(() => {
if (editingBudgetId && !group.budgets.some((budget) => budget.id === editingBudgetId)) {
setEditingBudgetId(null);
}
}, [editingBudgetId, group.budgets]);
useEffect(() => {
setGroupDraftName(group.name);
}, [group.name]);
const approvedSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
[group.budgets]
);
const paidSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + getPaidSpend(budget.expenses), 0),
[group.budgets]
);
const pendingSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + getPendingSpend(budget.expenses), 0),
[group.budgets]
);
const releasedSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + budget.releasedAmount + getPaidSpend(budget.expenses), 0),
[group.budgets]
);
const totalCommitted = approvedSpend + pendingSpend;
const remainingBudget = group.totalBudget - totalCommitted;
function getDraft(budget: DashboardBudget) {
return budgetDrafts[budget.id] ?? createDraft(budget);
}
function updateDraft(budget: DashboardBudget, patch: Partial<BudgetDraft>) {
setBudgetDrafts((current) => ({
...current,
[budget.id]: {
...getDraft(budget),
...patch
}
}));
}
function resetDraft(budget: DashboardBudget) {
setBudgetDrafts((current) => ({
...current,
[budget.id]: createDraft(budget)
}));
}
return (
<Card
sx={{
minWidth: { xs: 0, md: groupCardWidth },
width: { xs: "100%", md: groupCardWidth },
maxWidth: "none",
flexShrink: 0,
background: alpha(theme.palette.background.paper, isDark ? 0.84 : 0.84),
backdropFilter: "blur(8px)"
}}
>
<CardContent sx={{ p: 3 }}>
<Stack spacing={3}>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
alignItems={{ xs: "flex-start", sm: "flex-start" }}
gap={1.5}
>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h3" sx={{ fontSize: "1.45rem", overflowWrap: "break-word" }}>
{group.name}
</Typography>
<Typography color="text.secondary">
Gesamtbudgets: {formatCurrency(group.totalBudget)}
</Typography>
</Box>
<Stack direction="row" gap={1} alignItems="center">
{totalCommitted > group.totalBudget ? <Chip label={"\u00dcber Budget"} color="error" size="small" /> : null}
{canEditBudgets ? (
<IconButton
size="small"
disabled={busy}
onClick={() => {
if (isEditingGroup) {
setGroupDraftName(group.name);
setIsEditingGroup(false);
return;
}
setIsEditingGroup(true);
}}
sx={{
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
bgcolor: alpha(theme.palette.background.default, isDark ? 0.72 : 0.65)
}}
>
{isEditingGroup ? <CloseRoundedIcon fontSize="small" /> : <EditRoundedIcon fontSize="small" />}
</IconButton>
) : null}
</Stack>
</Stack>
{isEditingGroup ? (
<Box
component="form"
onSubmit={async (event) => {
event.preventDefault();
await onSaveWorkingGroup(group.id, groupDraftName);
setIsEditingGroup(false);
}}
sx={{
p: 2,
borderRadius: "18px",
border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`,
background: `linear-gradient(180deg, ${alpha(theme.palette.background.paper, isDark ? 0.86 : 0.94)} 0%, ${alpha(theme.palette.text.primary, isDark ? 0.05 : 0.02)} 100%)`
}}
>
<Stack spacing={1.4}>
<TextField
label="AG-Name"
size="small"
value={groupDraftName}
onChange={(event) => setGroupDraftName(event.target.value)}
fullWidth
/>
<Typography variant="body2" color="text.secondary">
AGs lassen sich nur löschen, wenn keine Mitglieder, Budgets oder Ausgaben mehr daran hängen.
</Typography>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button type="submit" variant="contained" disabled={busy}>
Speichern
</Button>
<Button
type="button"
variant="outlined"
disabled={busy}
onClick={() => {
setGroupDraftName(group.name);
setIsEditingGroup(false);
}}
>
Abbrechen
</Button>
<Button
type="button"
color="error"
variant="outlined"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy}
onClick={async () => {
if (!window.confirm(`AG "${group.name}" wirklich löschen?`)) {
return;
}
await onDeleteWorkingGroup(group.id, group.name);
setIsEditingGroup(false);
}}
>
AG löschen
</Button>
</Stack>
</Stack>
</Box>
) : null}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Chip
icon={<DoneAllRoundedIcon />}
label={`Freigegeben: ${formatCurrency(approvedSpend)}`}
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
icon={<CheckCircleRoundedIcon />}
label={`Bezahlt: ${formatCurrency(paidSpend)}`}
color="info"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
icon={<ReceiptLongRoundedIcon />}
label={`Geplant: ${formatCurrency(pendingSpend)}`}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
icon={<EuroRoundedIcon />}
label={`Rest: ${formatCurrency(remainingBudget)}`}
color={remainingBudget < 0 ? "error" : "default"}
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
label={`An AG übergeben: ${formatCurrency(releasedSpend)}`}
color={releasedSpend > group.totalBudget ? "error" : "default"}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
</Stack>
{group.budgets.length === 0 ? (
<Box
sx={{
p: 2.5,
borderRadius: "18px",
backgroundColor: alpha(theme.palette.text.primary, isDark ? 0.08 : 0.04)
}}
>
<Typography color="text.secondary">In dieser AG gibt es noch keine Budgets.</Typography>
</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 }
}}
>
{group.budgets.map((budget) => {
const draft = getDraft(budget);
const isEditing = editingBudgetId === budget.id;
const budgetApproved = getApprovedSpend(budget.expenses);
const budgetPaid = getPaidSpend(budget.expenses);
const budgetPending = getPendingSpend(budget.expenses);
const budgetReleasedByPayments = getPaidSpend(budget.expenses);
const budgetReleasedTotal = budget.releasedAmount + budgetReleasedByPayments;
const budgetCommitted = budgetApproved + budgetPending;
const budgetRemaining = budget.totalBudget - budgetCommitted;
const paidPercent = budget.totalBudget > 0 ? Math.min((budgetPaid / budget.totalBudget) * 100, 100) : 0;
const cumulativePercent =
budget.totalBudget > 0 ? Math.min((budgetCommitted / budget.totalBudget) * 100, 100) : 0;
const releasedPercent =
budget.totalBudget > 0 ? Math.min((budgetReleasedTotal / budget.totalBudget) * 100, 100) : 0;
return (
<Box
key={budget.id}
sx={{
minWidth: { xs: 280, sm: 312, md: budgetCardWidth },
width: { xs: "84vw", sm: 312, md: budgetCardWidth },
maxWidth: { xs: 360, md: budgetCardWidth },
flex: "0 0 auto",
scrollSnapAlign: "start"
}}
>
<Box
sx={{
p: 2.25,
borderRadius: "18px",
border: `1px solid ${alpha(budget.colorCode, 0.22)}`,
background: `linear-gradient(180deg, ${alpha(budget.colorCode, isDark ? 0.18 : 0.12)} 0%, ${alpha(theme.palette.background.paper, isDark ? 0.96 : 0.92)} 28%)`,
overflow: "hidden",
height: "100%"
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1.5}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" gap={1.2} alignItems="center">
<Box sx={{ width: 18, height: 18, borderRadius: "50%", bgcolor: budget.colorCode, flexShrink: 0 }} />
<Typography variant="h4" sx={{ fontSize: "1.15rem", overflowWrap: "break-word" }}>
{budget.name}
</Typography>
</Stack>
<Typography color="text.secondary">Budget: {formatCurrency(budget.totalBudget)}</Typography>
</Box>
{canEditBudgets ? (
<IconButton
size="small"
disabled={busy}
onClick={() => {
if (isEditing) {
resetDraft(budget);
setEditingBudgetId(null);
return;
}
setEditingBudgetId(budget.id);
}}
sx={{
border: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
bgcolor: alpha(theme.palette.background.default, isDark ? 0.72 : 0.65)
}}
>
{isEditing ? <CloseRoundedIcon fontSize="small" /> : <EditRoundedIcon fontSize="small" />}
</IconButton>
) : null}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} gap={2} alignItems="stretch">
<Box sx={{ width: { xs: "100%", sm: 110 }, display: "grid", placeItems: "center" }}>
<Box
sx={{
position: "relative",
width: 96,
height: 232,
borderRadius: "30px",
border: `3px solid ${alpha(budget.colorCode, 0.82)}`,
backgroundColor: alpha(budget.colorCode, isDark ? 0.12 : 0.06),
overflow: "hidden"
}}
>
<Box
sx={{
position: "absolute",
insetInline: 0,
bottom: 0,
height: `${cumulativePercent}%`,
backgroundColor: alpha(budget.colorCode, 0.3),
transition: "height 220ms ease"
}}
/>
<Box
sx={{
position: "absolute",
insetInline: 0,
bottom: 0,
height: `${paidPercent}%`,
backgroundColor: budget.colorCode,
transition: "height 220ms ease"
}}
/>
{budgetReleasedTotal > 0 ? (
<Box
sx={{
position: "absolute",
left: 8,
right: 8,
bottom: `calc(${releasedPercent}% - 1px)`,
borderTop: `2px dashed ${alpha(isDark ? "#FFFFFF" : budget.colorCode, isDark ? 0.82 : 0.9)}`,
zIndex: 2,
pointerEvents: "none"
}}
/>
) : null}
</Box>
</Box>
<Stack spacing={1.2} sx={{ flex: 1, minWidth: 0 }}>
<Chip
icon={<DoneAllRoundedIcon />}
label={`Freigegeben: ${formatCurrency(budgetApproved)}`}
sx={{ ...wrappingChipSx, width: "fit-content", bgcolor: alpha(budget.colorCode, 0.14) }}
/>
<Chip
icon={<CheckCircleRoundedIcon />}
label={`Bezahlt: ${formatCurrency(budgetPaid)}`}
color="info"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
icon={<ReceiptLongRoundedIcon />}
label={`Geplant: ${formatCurrency(budgetPending)}`}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
icon={<EuroRoundedIcon />}
label={`Rest: ${formatCurrency(budgetRemaining)}`}
color={budgetRemaining < 0 ? "error" : "default"}
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Chip
label={`An AG übergeben: ${formatCurrency(budgetReleasedTotal)}`}
color={budgetReleasedTotal > budget.totalBudget ? "error" : "default"}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
{budget.releasedAmount > 0 ? (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: "0.76rem" }}>
{`Manuell erg\u00e4nzt: ${formatCurrency(budget.releasedAmount)}`}
</Typography>
) : null}
</Stack>
</Stack>
{isEditing ? (
<Box
component="form"
onSubmit={async (event) => {
event.preventDefault();
await onSaveBudget(budget.id, draft.name, draft.totalBudget, draft.colorCode);
setEditingBudgetId(null);
}}
>
<Stack spacing={1.4}>
<TextField
label="Budget-Name"
value={draft.name}
size="small"
onChange={(event) => updateDraft(budget, { name: event.target.value })}
fullWidth
/>
<TextField
label="Budgetbetrag"
value={draft.totalBudget}
size="small"
type="number"
inputProps={{ min: 0, step: 0.01 }}
onChange={(event) => updateDraft(budget, { totalBudget: event.target.value })}
fullWidth
/>
<ColorPickerField
label="Budgetfarbe"
value={draft.colorCode}
onChange={(value) => updateDraft(budget, { colorCode: value })}
/>
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
<Button type="submit" variant="contained" disabled={busy}>
Speichern
</Button>
<Button
type="button"
variant="outlined"
disabled={busy}
onClick={() => {
resetDraft(budget);
setEditingBudgetId(null);
}}
>
Abbrechen
</Button>
<Button
type="button"
color="error"
variant="outlined"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy}
onClick={async () => {
if (!window.confirm(`Budget "${budget.name}" wirklich l\u00f6schen?`)) {
return;
}
await onDeleteBudget(budget.id);
setEditingBudgetId(null);
}}
>
{"Budget l\u00f6schen"}
</Button>
</Stack>
</Stack>
</Box>
) : null}
<Divider />
<Stack spacing={1.5}>
{budget.expenses.length === 0 ? (
<Box
sx={{
p: 2,
borderRadius: "16px",
backgroundColor: alpha(budget.colorCode, isDark ? 0.14 : 0.08)
}}
>
<Typography variant="body2" color="text.secondary">
Noch keine Ausgaben in diesem Budget.
</Typography>
</Box>
) : null}
{budget.expenses.map((expense) => {
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
: [];
const isRecurringSeries = expense.recurrence === "MONTHLY";
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
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)
}}
>
<Stack spacing={1.4}>
<Stack spacing={1}>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
sx={{ fontWeight: 700, overflowWrap: "break-word" }}
>
{expense.title}
</Typography>
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
{isRecurringSeries
? expense.occurrenceCount > 0
? `${formatCurrency(expense.periodAmount)} im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}`
: `${formatCurrency(expense.amount)} von ${expense.creator.name}`}
</Typography>
</Box>
<StatusChips expense={expense} />
</Stack>
{expense.description ? (
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
{expense.description}
</Typography>
) : null}
{isRecurringSeries ? (
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{`Abo-Start: ${dateFormatter.format(new Date(expense.recurrenceStartAt ?? expense.createdAt))}`}
</Typography>
{expense.occurrenceCount > 0 ? (
<>
<Button
type="button"
size="small"
variant="text"
onClick={() =>
setExpandedRecurringExpenses((current) => ({
...current,
[expense.id]: !isRecurringExpanded
}))
}
endIcon={isRecurringExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
sx={{ alignSelf: "flex-start", px: 0 }}
>
{isRecurringExpanded
? "Monatsraten ausblenden"
: `Monatsraten anzeigen (${expense.occurrenceCount})`}
</Button>
<Collapse in={isRecurringExpanded} unmountOnExit>
<Stack spacing={0.8}>
{expense.occurrences.map((occurrence) => (
<Box
key={occurrence.id}
sx={{
p: 1.2,
borderRadius: "14px",
backgroundColor: alpha(budget.colorCode, isDark ? 0.12 : 0.06)
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
justifyContent="space-between"
gap={0.75}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{occurrence.label}
</Typography>
<Typography variant="body2" color="text.secondary">
{formatCurrency(occurrence.amount)} · fällig {dateFormatter.format(new Date(occurrence.dueAt))}
</Typography>
</Stack>
</Box>
))}
</Stack>
</Collapse>
</>
) : (
<Typography variant="body2" color="text.secondary">
In diesem Zeitraum fällt noch keine Monatsrate an.
</Typography>
)}
</Stack>
) : null}
{requiresManualApproval(expense.amount, approvalThreshold) ? (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => {
const matchingApproval = expense.approvals.find(
(approval) => approval.approvalType === approvalType
);
return (
<Chip
key={approvalType}
label={
matchingApproval
? `${approvalLabel(approvalType)}: ${matchingApproval.user.name}`
: `${approvalLabel(approvalType)}: offen`
}
color={matchingApproval ? "primary" : "default"}
variant={matchingApproval ? "filled" : "outlined"}
size="small"
sx={wrappingChipSx}
/>
);
})}
</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}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{availableApprovals.map((approvalType) => (
<Button
key={approvalType}
size="small"
variant="contained"
disabled={busy}
onClick={() => {
if (
!window.confirm(
`Freigabe wirklich setzen?\n\nAusgabe: ${expense.title}\nBetrag: ${formatCurrency(expense.amount)}\nRolle: ${approvalLabel(approvalType)}\n\nMit deiner Freigabe bestaetigst du, dass du die Ausgabe plausibel geprueft hast und die Verantwortung fuer diesen Freigabeschritt uebernimmst.`
)
) {
return;
}
onApprove(expense.id, approvalType);
}}
>
Freigeben als {approvalLabel(approvalType)}
</Button>
))}
{!expense.paidAt &&
expense.approvalStatus === "APPROVED" &&
expense.proofUrl &&
expense.invoiceDate &&
expense.documentedAt &&
canMarkPaid(viewer.role) ? (
<Button
size="small"
variant="outlined"
disabled={busy}
onClick={() => onMarkPaid(expense.id)}
>
Bezahlt setzen
</Button>
) : null}
{canDeleteExpense(
viewer.role,
viewer.id,
expense.creator.id,
expense.approvalStatus,
expense.paidAt,
expense.documentedAt
) ? (
<Button
size="small"
color="error"
variant="outlined"
startIcon={<DeleteOutlineRoundedIcon />}
disabled={busy}
onClick={async () => {
if (!window.confirm(`Ausgabe "${expense.title}" wirklich l\u00f6schen?`)) {
return;
}
await onDeleteExpense(expense.id);
}}
>
{"L\u00f6schen"}
</Button>
) : null}
</Stack>
{!expense.paidAt &&
expense.approvalStatus === "APPROVED" &&
!expense.proofUrl &&
canUploadProof ? (
<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
label="Beleg"
value={proofFileDrafts[expense.id]?.name ?? expense.proofUrl ?? ""}
InputProps={{ readOnly: true }}
size="small"
fullWidth
/>
<Button component="label" size="small" variant="outlined" startIcon={<UploadFileRoundedIcon />} disabled={busy}>
Datei
<input
hidden
type="file"
accept="image/*,application/pdf"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
<Button component="label" size="small" variant="outlined" disabled={busy}>
Kamera
<input
hidden
type="file"
accept="image/*"
capture="environment"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
<Button
size="small"
variant="contained"
color="success"
disabled={busy}
onClick={async () => {
const proofFile = proofFileDrafts[expense.id];
const invoiceDate = invoiceDateDrafts[expense.id] ?? "";
if (!proofFile || !invoiceDate) {
return;
}
await onUploadProof(expense.id, proofFile, invoiceDate);
}}
>
Rechnung abgeben und bezahlt setzen
</Button>
</Stack>
) : null}
<Typography variant="caption" color="text.secondary">
Angelegt am{" "}
{new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(
new Date(expense.createdAt)
)}
</Typography>
</Stack>
</Box>
);
})}
</Stack>
</Stack>
</Box>
</Box>
);
})}
</Box>
</Stack>
</CardContent>
</Card>
);
}