"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 { 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; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; onSaveWorkingGroup: (groupId: string, name: string) => Promise; onDeleteWorkingGroup: (groupId: string, groupName: string) => Promise; onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise; onDeleteBudget: (budgetId: string) => Promise; onDeleteExpense: (expenseId: string) => Promise; }; 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 ( {expense.paidAt ? ( } sx={wrappingChipSx} /> ) : null} {expense.documentedAt ? ( } sx={wrappingChipSx} /> ) : null} {expense.recurrence === "MONTHLY" ? ( ) : null} ); } 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); } export function BudgetColumn({ group, viewer, busy, approvalThreshold, onApprove, onMarkPaid, onDocument, onSaveWorkingGroup, onDeleteWorkingGroup, onSaveBudget, onDeleteBudget, onDeleteExpense }: BudgetColumnProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; const [budgetDrafts, setBudgetDrafts] = useState>({}); const [editingBudgetId, setEditingBudgetId] = useState(null); const [isEditingGroup, setIsEditingGroup] = useState(false); const [groupDraftName, setGroupDraftName] = useState(group.name); const [proofUrlDrafts, setProofUrlDrafts] = useState>({}); const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState>({}); 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) { setBudgetDrafts((current) => ({ ...current, [budget.id]: { ...getDraft(budget), ...patch } })); } function resetDraft(budget: DashboardBudget) { setBudgetDrafts((current) => ({ ...current, [budget.id]: createDraft(budget) })); } return ( {group.name} Gesamtbudgets: {formatCurrency(group.totalBudget)} {totalCommitted > group.totalBudget ? : null} {canEditBudgets ? ( { 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 ? : } ) : null} {isEditingGroup ? ( { 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%)` }} > setGroupDraftName(event.target.value)} fullWidth /> AGs lassen sich nur löschen, wenn keine Mitglieder, Budgets oder Ausgaben mehr daran hängen. ) : null} } label={`Freigegeben: ${formatCurrency(approvedSpend)}`} sx={{ ...wrappingChipSx, width: "fit-content" }} /> } label={`Geplant: ${formatCurrency(pendingSpend)}`} variant="outlined" sx={{ ...wrappingChipSx, width: "fit-content" }} /> } label={`Rest: ${formatCurrency(remainingBudget)}`} color={remainingBudget < 0 ? "error" : "default"} sx={{ ...wrappingChipSx, width: "fit-content" }} /> {group.budgets.length === 0 ? ( In dieser AG gibt es noch keine Budgets. ) : null} {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 ( {budget.name} Budget: {formatCurrency(budget.totalBudget)} {canEditBudgets ? ( { 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 ? : } ) : null} } label={`Freigegeben: ${formatCurrency(budgetApproved)}`} sx={{ ...wrappingChipSx, width: "fit-content", bgcolor: alpha(budget.colorCode, 0.14) }} /> } label={`Geplant: ${formatCurrency(budgetPending)}`} variant="outlined" sx={{ ...wrappingChipSx, width: "fit-content" }} /> } label={`Rest: ${formatCurrency(budgetRemaining)}`} color={budgetRemaining < 0 ? "error" : "default"} sx={{ ...wrappingChipSx, width: "fit-content" }} /> {`Unter ${formatCurrency(approvalThreshold)} werden sofort freigegeben. Größere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen.`} {isEditing ? ( { event.preventDefault(); await onSaveBudget(budget.id, draft.name, draft.totalBudget, draft.colorCode); setEditingBudgetId(null); }} > updateDraft(budget, { name: event.target.value })} fullWidth /> updateDraft(budget, { totalBudget: event.target.value })} fullWidth /> updateDraft(budget, { colorCode: value })} /> ) : null} {budget.expenses.length === 0 ? ( Noch keine Ausgaben in diesem Budget. ) : 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; return ( {expense.title} {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}`} {expense.description ? ( {expense.description} ) : null} {isRecurringSeries ? ( {`Abo-Start: ${dateFormatter.format(new Date(expense.recurrenceStartAt ?? expense.createdAt))}`} {expense.occurrenceCount > 0 ? ( <> {expense.occurrences.map((occurrence) => ( {occurrence.label} {formatCurrency(occurrence.amount)} · fällig {dateFormatter.format(new Date(occurrence.dueAt))} ))} ) : ( In diesem Zeitraum fällt noch keine Monatsrate an. )} ) : null} {requiresManualApproval(expense.amount, approvalThreshold) ? ( {APPROVAL_FLOW.map((approvalType) => { const matchingApproval = expense.approvals.find( (approval) => approval.approvalType === approvalType ); return ( ); })} ) : null} {expense.proofUrl ? ( {"Beleg \u00f6ffnen"} ) : null} {availableApprovals.map((approvalType) => ( ))} {!expense.paidAt && expense.approvalStatus === "APPROVED" && canMarkPaid(viewer.role) ? ( ) : null} {canDeleteExpense( viewer.role, viewer.id, expense.creator.id, expense.approvalStatus, expense.paidAt, expense.documentedAt ) ? ( ) : null} {expense.paidAt && !expense.documentedAt && canDocumentExpense(viewer.role) ? ( setProofUrlDrafts((current) => ({ ...current, [expense.id]: event.target.value })) } size="small" fullWidth /> ) : null} Angelegt am{" "} {new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format( new Date(expense.createdAt) )} ); })} ); })} ); }