Initial commit
This commit is contained in:
743
src/components/dashboard/budget-column.tsx
Normal file
743
src/components/dashboard/budget-column.tsx
Normal file
@@ -0,0 +1,743 @@
|
||||
"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 EuroRoundedIcon from "@mui/icons-material/EuroRounded";
|
||||
import ReceiptLongRoundedIcon from "@mui/icons-material/ReceiptLongRounded";
|
||||
import TaskAltRoundedIcon from "@mui/icons-material/TaskAltRounded";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
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;
|
||||
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
||||
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||
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 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.amount : 0), 0);
|
||||
}
|
||||
|
||||
function getPendingSpend(expenses: DashboardExpense[]) {
|
||||
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.amount : 0), 0);
|
||||
}
|
||||
|
||||
export function BudgetColumn({
|
||||
group,
|
||||
viewer,
|
||||
busy,
|
||||
onApprove,
|
||||
onMarkPaid,
|
||||
onDocument,
|
||||
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 [proofUrlDrafts, setProofUrlDrafts] = useState<Record<string, string>>({});
|
||||
|
||||
const budgetCardWidth = 352;
|
||||
const groupCardWidth = Math.min(
|
||||
Math.max(group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * 16 + 48, 440),
|
||||
1160
|
||||
);
|
||||
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 pendingSpend = useMemo(
|
||||
() => group.budgets.reduce((sum, budget) => sum + getPendingSpend(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 loeschen, wenn keine Mitglieder, Budgets oder Ausgaben mehr daran haengen.
|
||||
</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 loeschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onDeleteWorkingGroup(group.id, group.name);
|
||||
setIsEditingGroup(false);
|
||||
}}
|
||||
>
|
||||
AG loeschen
|
||||
</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={<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" }}
|
||||
/>
|
||||
</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}
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
sx={{ overflowX: "auto", pb: 1.5, alignItems: "stretch", scrollSnapType: "x proximity" }}
|
||||
>
|
||||
{group.budgets.map((budget) => {
|
||||
const draft = getDraft(budget);
|
||||
const isEditing = editingBudgetId === budget.id;
|
||||
const budgetApproved = getApprovedSpend(budget.expenses);
|
||||
const budgetPending = getPendingSpend(budget.expenses);
|
||||
const budgetCommitted = budgetApproved + budgetPending;
|
||||
const budgetRemaining = budget.totalBudget - budgetCommitted;
|
||||
const approvedPercent = budget.totalBudget > 0 ? Math.min((budgetApproved / budget.totalBudget) * 100, 100) : 0;
|
||||
const cumulativePercent =
|
||||
budget.totalBudget > 0 ? Math.min((budgetCommitted / 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: `${approvedPercent}%`,
|
||||
backgroundColor: budget.colorCode,
|
||||
transition: "height 220ms ease"
|
||||
}}
|
||||
/>
|
||||
</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={<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" }}
|
||||
/>
|
||||
<Typography color="text.secondary">
|
||||
{"Unter 50 EUR werden sofort freigegeben. Gr\u00f6\u00dfere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen."}
|
||||
</Typography>
|
||||
</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)
|
||||
? getAvailableApprovalTypes(viewer.role, viewer.approvalPreference, doneApprovalTypes)
|
||||
: [];
|
||||
|
||||
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" }}>
|
||||
{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}
|
||||
|
||||
{requiresManualApproval(expense.amount) ? (
|
||||
<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 ? (
|
||||
<Link
|
||||
href={expense.proofUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
underline="hover"
|
||||
variant="body2"
|
||||
sx={{ overflowWrap: "anywhere" }}
|
||||
>
|
||||
{"Beleg \u00f6ffnen"}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
{availableApprovals.map((approvalType) => (
|
||||
<Button
|
||||
key={approvalType}
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={busy}
|
||||
onClick={() => onApprove(expense.id, approvalType)}
|
||||
>
|
||||
Freigeben als {approvalLabel(approvalType)}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{!expense.paidAt && expense.approvalStatus === "APPROVED" && 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.documentedAt && canDocumentExpense(viewer.role) ? (
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||
<TextField
|
||||
label="Beleg-URL"
|
||||
value={proofUrlDrafts[expense.id] ?? expense.proofUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
setProofUrlDrafts((current) => ({
|
||||
...current,
|
||||
[expense.id]: event.target.value
|
||||
}))
|
||||
}
|
||||
size="small"
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
onDocument(expense.id, proofUrlDrafts[expense.id] ?? expense.proofUrl ?? undefined)
|
||||
}
|
||||
>
|
||||
Dokumentieren
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/dashboard/color-picker-field.tsx
Normal file
46
src/components/dashboard/color-picker-field.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { COLOR_PRESETS } from "@/lib/domain";
|
||||
|
||||
type ColorPickerFieldProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ColorPickerField({ label, value, onChange }: ColorPickerFieldProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack spacing={1.2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
{COLOR_PRESETS.map((preset) => (
|
||||
<Box
|
||||
key={preset}
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label={`Farbe ${preset}`}
|
||||
onClick={() => onChange(preset)}
|
||||
sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
border:
|
||||
value === preset
|
||||
? `3px solid ${theme.palette.text.primary}`
|
||||
: `2px solid ${alpha(theme.palette.text.primary, 0.14)}`,
|
||||
bgcolor: preset,
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
1835
src/components/dashboard/dashboard-shell.tsx
Normal file
1835
src/components/dashboard/dashboard-shell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
109
src/components/login-form.tsx
Normal file
109
src/components/login-form.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Box, Button, Card, CardContent, Chip, Stack, TextField, Typography } from "@mui/material";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
const demoAccounts = [
|
||||
{ label: "Vorstand A", username: "vorstand-a" },
|
||||
{ label: "Vorstand B", username: "vorstand-b" },
|
||||
{ label: "Finanz-AG", username: "finanzen" },
|
||||
{ label: "Deko Mitglied", username: "deko" }
|
||||
];
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [identifier, setIdentifier] = useState("vorstand-a");
|
||||
const [password, setPassword] = useState("demo123!");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
identifier,
|
||||
password,
|
||||
redirect: false
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (result?.error) {
|
||||
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pr\u00fcfen.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
maxWidth: 520,
|
||||
width: "100%",
|
||||
mx: "auto",
|
||||
overflow: "visible"
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: { xs: 3, md: 4 } }}>
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography variant="h2" gutterBottom>
|
||||
Anmeldung
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Melde dich mit deinem Login-Namen und Passwort an.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" gap={1}>
|
||||
{demoAccounts.map((account) => (
|
||||
<Chip
|
||||
key={account.username}
|
||||
label={account.label}
|
||||
onClick={() => {
|
||||
setIdentifier(account.username);
|
||||
setPassword("demo123!");
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Alert severity="info">{"Demo-Passwort f\u00fcr alle Konten: demo123!"}</Alert>
|
||||
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Login-Name"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Passwort"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="large" variant="contained" disabled={isLoading}>
|
||||
{isLoading ? "Anmeldung l\u00e4uft..." : "Einloggen"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/providers/app-providers.tsx
Normal file
46
src/components/providers/app-providers.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import type { PaletteMode } from "@mui/material";
|
||||
import { Box, CssBaseline, ThemeProvider } from "@mui/material";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAppTheme } from "@/theme";
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
const [mode, setMode] = useState<PaletteMode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const applyMode = () => {
|
||||
setMode(mediaQuery.matches ? "dark" : "light");
|
||||
};
|
||||
|
||||
applyMode();
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", applyMode);
|
||||
return () => mediaQuery.removeEventListener("change", applyMode);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(applyMode);
|
||||
return () => mediaQuery.removeListener(applyMode);
|
||||
}, []);
|
||||
|
||||
const theme = useMemo(() => getAppTheme(mode ?? "dark"), [mode]);
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline enableColorScheme />
|
||||
{mode ? children : <Box sx={{ minHeight: "100vh" }} />}
|
||||
</ThemeProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
18
src/components/service-worker-registration.tsx
Normal file
18
src/components/service-worker-registration.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
// Registrierung darf die App nicht blockieren.
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user