960 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|