Ausgaben und Spenden bearbeitbar machen
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
All checks were successful
CI / Build and Deploy (push) Successful in 2m44s
This commit is contained in:
193
src/app/api/donations/[id]/route.ts
Normal file
193
src/app/api/donations/[id]/route.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { snapshotDonation } from "@/lib/audit-snapshots";
|
||||||
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
|
import { canManageBudgets } from "@/lib/domain";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getCurrentViewer } from "@/lib/session";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DonationRow = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
amount: unknown;
|
||||||
|
donated_at: Date;
|
||||||
|
period_id: string;
|
||||||
|
expense_id: string | null;
|
||||||
|
creator_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDateInput(value: string) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
const parsed = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 12, 0, 0, 0));
|
||||||
|
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDonation(row: DonationRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
amount: row.amount,
|
||||||
|
donatedAt: row.donated_at,
|
||||||
|
periodId: row.period_id,
|
||||||
|
expenseId: row.expense_id,
|
||||||
|
creatorId: row.creator_id,
|
||||||
|
createdAt: row.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDonation(id: string) {
|
||||||
|
const rows = await prisma.$queryRaw<DonationRow[]>`
|
||||||
|
SELECT id, title, description, amount, donated_at, period_id, expense_id, creator_id, created_at
|
||||||
|
FROM donations
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const donationSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(2).max(120),
|
||||||
|
description: z
|
||||||
|
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||||
|
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null)),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
donatedAt: z.string().trim().transform((value) => parseDateInput(value) ?? "invalid"),
|
||||||
|
expenseId: z
|
||||||
|
.union([z.string().trim().min(1), z.literal(""), z.null(), z.undefined()])
|
||||||
|
.transform((value) => (typeof value === "string" && value.length > 0 ? value : null))
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.donatedAt === "invalid") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Bitte ein gültiges Spendendatum angeben.",
|
||||||
|
path: ["donatedAt"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageBudgets(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden bearbeiten." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const donation = await getDonation(id);
|
||||||
|
|
||||||
|
if (!donation) {
|
||||||
|
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = donationSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success || !(parsed.data.donatedAt instanceof Date)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.success ? "Bitte Spendendaten korrekt ausfüllen." : parsed.error.issues[0]?.message ?? "Bitte Spendendaten korrekt ausfüllen." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = parsed.data.expenseId
|
||||||
|
? await prisma.expense.findUnique({
|
||||||
|
where: { id: parsed.data.expenseId }
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parsed.data.expenseId && (!expense || expense.periodId !== donation.period_id)) {
|
||||||
|
return NextResponse.json({ error: "Die ausgewählte Ausgabe passt nicht zum Zeitraum der Spende." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
UPDATE donations
|
||||||
|
SET title = ${parsed.data.title},
|
||||||
|
description = ${parsed.data.description},
|
||||||
|
amount = ${parsed.data.amount},
|
||||||
|
donated_at = ${parsed.data.donatedAt},
|
||||||
|
expense_id = ${expense?.id ?? null},
|
||||||
|
updated_at = ${new Date()}
|
||||||
|
WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updatedDonation = await getDonation(id);
|
||||||
|
|
||||||
|
await createAuditLog(prisma, {
|
||||||
|
actorId: viewer.id,
|
||||||
|
action: "donation.update",
|
||||||
|
entityType: "donation",
|
||||||
|
entityId: id,
|
||||||
|
entityLabel: parsed.data.title,
|
||||||
|
summary: `Spende ${parsed.data.title} wurde bearbeitet.`,
|
||||||
|
metadata: {
|
||||||
|
rollback: {
|
||||||
|
kind: "donation.update",
|
||||||
|
previous: snapshotDonation(normalizeDonation(donation)),
|
||||||
|
next: updatedDonation ? snapshotDonation(normalizeDonation(updatedDonation)) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canManageBudgets(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Spenden löschen." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const donation = await getDonation(id);
|
||||||
|
|
||||||
|
if (!donation) {
|
||||||
|
return NextResponse.json({ error: "Spende nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$executeRaw`DELETE FROM donations WHERE id = ${id}`;
|
||||||
|
|
||||||
|
await createAuditLog(prisma, {
|
||||||
|
actorId: viewer.id,
|
||||||
|
action: "donation.delete",
|
||||||
|
entityType: "donation",
|
||||||
|
entityId: id,
|
||||||
|
entityLabel: donation.title,
|
||||||
|
summary: `Spende ${donation.title} wurde gelöscht.`,
|
||||||
|
metadata: {
|
||||||
|
rollback: {
|
||||||
|
kind: "donation.delete",
|
||||||
|
deleted: snapshotDonation(normalizeDonation(donation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { snapshotExpense } from "@/lib/audit-snapshots";
|
import { snapshotExpense } from "@/lib/audit-snapshots";
|
||||||
import { createAuditLog } from "@/lib/audit-log";
|
import { createAuditLog } from "@/lib/audit-log";
|
||||||
@@ -12,6 +13,95 @@ type Context = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExpenseSchema = z.object({
|
||||||
|
title: z.string().trim().min(2).max(120),
|
||||||
|
description: z
|
||||||
|
.union([z.string().trim().max(1000), z.literal(""), z.null(), z.undefined()])
|
||||||
|
.transform((value) => (typeof value === "string" && value.length > 0 ? value : undefined)),
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
agId: z.string().trim().min(1),
|
||||||
|
budgetId: z.string().trim().min(1),
|
||||||
|
cutoffPhase: z.enum(["PRE", "POST"])
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Context) {
|
||||||
|
const { id } = await params;
|
||||||
|
const viewer = await getCurrentViewer();
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAdministrativeAccess(viewer.role)) {
|
||||||
|
return NextResponse.json({ error: "Nur Vorstand allgemein, AG Orga oder AG Finanzen dürfen Ausgaben bearbeiten." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = updateExpenseSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Bitte Ausgabendaten korrekt ausfüllen." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await prisma.expense.findUnique({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return NextResponse.json({ error: "Ausgabe nicht gefunden." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = await prisma.budget.findUnique({
|
||||||
|
where: { id: parsed.data.budgetId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!budget || budget.workingGroupId !== parsed.data.agId || budget.periodId !== expense.periodId) {
|
||||||
|
return NextResponse.json({ error: "Das ausgewählte Budget passt nicht zur AG oder zum Zeitraum." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCutoffRows = await prisma.$queryRaw<{ cutoff_phase: "PRE" | "POST" }[]>`
|
||||||
|
SELECT cutoff_phase FROM expenses WHERE id = ${id}
|
||||||
|
`;
|
||||||
|
const previousSnapshot = snapshotExpense({
|
||||||
|
...expense,
|
||||||
|
cutoffPhase: previousCutoffRows[0]?.cutoff_phase ?? "PRE"
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedExpense = await prisma.expense.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
title: parsed.data.title,
|
||||||
|
description: parsed.data.description,
|
||||||
|
amount: parsed.data.amount,
|
||||||
|
agId: parsed.data.agId,
|
||||||
|
budgetId: parsed.data.budgetId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await prisma.$executeRaw`UPDATE expenses SET cutoff_phase = ${parsed.data.cutoffPhase}::"CutoffPhase" WHERE id = ${id}`;
|
||||||
|
|
||||||
|
await createAuditLog(prisma, {
|
||||||
|
actorId: viewer.id,
|
||||||
|
action: "expense.update",
|
||||||
|
entityType: "expense",
|
||||||
|
entityId: updatedExpense.id,
|
||||||
|
entityLabel: updatedExpense.title,
|
||||||
|
summary: `Ausgabe ${updatedExpense.title} wurde bearbeitet.`,
|
||||||
|
metadata: {
|
||||||
|
amount: Number(updatedExpense.amount),
|
||||||
|
budgetId: updatedExpense.budgetId,
|
||||||
|
workingGroupId: updatedExpense.agId,
|
||||||
|
cutoffPhase: parsed.data.cutoffPhase,
|
||||||
|
rollback: {
|
||||||
|
kind: "expense.update",
|
||||||
|
previous: previousSnapshot,
|
||||||
|
next: snapshotExpense({ ...updatedExpense, cutoffPhase: parsed.data.cutoffPhase })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ expense: updatedExpense });
|
||||||
|
}
|
||||||
|
|
||||||
export async function DELETE(_: Request, { params }: Context) {
|
export async function DELETE(_: Request, { params }: Context) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const viewer = await getCurrentViewer();
|
const viewer = await getCurrentViewer();
|
||||||
|
|||||||
@@ -160,12 +160,18 @@ export default async function DashboardPage() {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
creator_id: string;
|
creator_id: string;
|
||||||
creator_name: string;
|
creator_name: string;
|
||||||
|
working_group_id: string | null;
|
||||||
|
working_group_name: string | null;
|
||||||
|
expense_title: string | null;
|
||||||
}[]
|
}[]
|
||||||
>`
|
>`
|
||||||
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
|
SELECT d.id, d.title, d.description, d.amount, d.donated_at, d.period_id, d.expense_id, d.created_at,
|
||||||
u.id AS creator_id, u.username AS creator_name
|
u.id AS creator_id, u.username AS creator_name,
|
||||||
|
wg.id AS working_group_id, wg.name AS working_group_name, e.title AS expense_title
|
||||||
FROM donations d
|
FROM donations d
|
||||||
JOIN users u ON u.id = d.creator_id
|
JOIN users u ON u.id = d.creator_id
|
||||||
|
LEFT JOIN expenses e ON e.id = d.expense_id
|
||||||
|
LEFT JOIN working_groups wg ON wg.id = e.ag_id
|
||||||
WHERE d.period_id = ${currentPeriod.id}
|
WHERE d.period_id = ${currentPeriod.id}
|
||||||
ORDER BY d.donated_at DESC
|
ORDER BY d.donated_at DESC
|
||||||
`;
|
`;
|
||||||
@@ -309,6 +315,9 @@ export default async function DashboardPage() {
|
|||||||
donatedAt: donation.donated_at.toISOString(),
|
donatedAt: donation.donated_at.toISOString(),
|
||||||
periodId: donation.period_id,
|
periodId: donation.period_id,
|
||||||
expenseId: donation.expense_id,
|
expenseId: donation.expense_id,
|
||||||
|
workingGroupId: donation.working_group_id,
|
||||||
|
workingGroupName: donation.working_group_name,
|
||||||
|
expenseTitle: donation.expense_title,
|
||||||
createdAt: donation.created_at.toISOString(),
|
createdAt: donation.created_at.toISOString(),
|
||||||
creator: {
|
creator: {
|
||||||
id: donation.creator_id,
|
id: donation.creator_id,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
|
|
||||||
type BudgetColumnProps = {
|
type BudgetColumnProps = {
|
||||||
group: DashboardWorkingGroup;
|
group: DashboardWorkingGroup;
|
||||||
|
workingGroups: DashboardWorkingGroup[];
|
||||||
viewer: DashboardViewer;
|
viewer: DashboardViewer;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
approvalThreshold: number;
|
approvalThreshold: number;
|
||||||
@@ -60,6 +61,17 @@ type BudgetColumnProps = {
|
|||||||
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
onSaveBudget: (budgetId: string, name: string, totalBudget: string, colorCode: string) => Promise<void>;
|
||||||
onDeleteBudget: (budgetId: string) => Promise<void>;
|
onDeleteBudget: (budgetId: string) => Promise<void>;
|
||||||
onDeleteExpense: (expenseId: string) => Promise<void>;
|
onDeleteExpense: (expenseId: string) => Promise<void>;
|
||||||
|
onUpdateExpense: (
|
||||||
|
expenseId: string,
|
||||||
|
draft: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
agId: string;
|
||||||
|
budgetId: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
|
}
|
||||||
|
) => Promise<void>;
|
||||||
};
|
};
|
||||||
type BudgetDraft = {
|
type BudgetDraft = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -113,6 +125,18 @@ function StatusChips({ expense }: { expense: DashboardExpense }) {
|
|||||||
{expense.documentedAt ? (
|
{expense.documentedAt ? (
|
||||||
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
<Chip label="Dokumentiert" color="success" size="small" icon={<TaskAltRoundedIcon />} sx={wrappingChipSx} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{expense.donationAmount > 0 ? (
|
||||||
|
<Chip
|
||||||
|
label={`Spende: ${formatCurrency(expense.donationAmount)}`}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
...wrappingChipSx,
|
||||||
|
bgcolor: "#F6C343",
|
||||||
|
color: "#1F1600",
|
||||||
|
fontWeight: 700
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{expense.recurrence === "MONTHLY" ? (
|
{expense.recurrence === "MONTHLY" ? (
|
||||||
<Chip
|
<Chip
|
||||||
label={recurrenceLabel(expense.recurrence)}
|
label={recurrenceLabel(expense.recurrence)}
|
||||||
@@ -140,6 +164,7 @@ function getPaidSpend(expenses: DashboardExpense[]) {
|
|||||||
|
|
||||||
export function BudgetColumn({
|
export function BudgetColumn({
|
||||||
group,
|
group,
|
||||||
|
workingGroups,
|
||||||
viewer,
|
viewer,
|
||||||
busy,
|
busy,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
@@ -153,7 +178,8 @@ export function BudgetColumn({
|
|||||||
onDeleteWorkingGroup,
|
onDeleteWorkingGroup,
|
||||||
onSaveBudget,
|
onSaveBudget,
|
||||||
onDeleteBudget,
|
onDeleteBudget,
|
||||||
onDeleteExpense
|
onDeleteExpense,
|
||||||
|
onUpdateExpense
|
||||||
}: BudgetColumnProps) {
|
}: BudgetColumnProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.palette.mode === "dark";
|
const isDark = theme.palette.mode === "dark";
|
||||||
@@ -165,13 +191,19 @@ export function BudgetColumn({
|
|||||||
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
|
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
|
||||||
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
|
||||||
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
|
||||||
|
const [expandedExpenseDetails, setExpandedExpenseDetails] = useState<Record<string, boolean>>({});
|
||||||
|
const [editingExpenseId, setEditingExpenseId] = useState<string | null>(null);
|
||||||
|
const [expenseDrafts, setExpenseDrafts] = useState<
|
||||||
|
Record<string, { title: string; description: string; amount: string; agId: string; budgetId: string; cutoffPhase: "PRE" | "POST" }>
|
||||||
|
>({});
|
||||||
|
|
||||||
const budgetCardWidth = 318;
|
const budgetCardWidth = 286;
|
||||||
const desktopBudgetGap = 14;
|
const desktopBudgetGap = 12;
|
||||||
const desktopBudgetListWidth =
|
const desktopBudgetListWidth =
|
||||||
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
group.budgets.length * budgetCardWidth + Math.max(group.budgets.length - 1, 0) * desktopBudgetGap;
|
||||||
const groupCardWidth = Math.max(desktopBudgetListWidth + 58, 410);
|
const groupCardWidth = Math.max(desktopBudgetListWidth + 48, 372);
|
||||||
const canEditBudgets = canManageBudgets(viewer.role);
|
const canEditBudgets = canManageBudgets(viewer.role);
|
||||||
|
const canEditExpenses = canManageBudgets(viewer.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBudgetDrafts(
|
setBudgetDrafts(
|
||||||
@@ -234,6 +266,27 @@ export function BudgetColumn({
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExpenseDraft(expense: DashboardExpense) {
|
||||||
|
return expenseDrafts[expense.id] ?? {
|
||||||
|
title: expense.title,
|
||||||
|
description: expense.description ?? "",
|
||||||
|
amount: expense.amount.toFixed(2),
|
||||||
|
agId: group.id,
|
||||||
|
budgetId: expense.budgetId,
|
||||||
|
cutoffPhase: expense.cutoffPhase
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExpenseDraft(expense: DashboardExpense, patch: Partial<ReturnType<typeof getExpenseDraft>>) {
|
||||||
|
setExpenseDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: {
|
||||||
|
...getExpenseDraft(expense),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function resetDraft(budget: DashboardBudget) {
|
function resetDraft(budget: DashboardBudget) {
|
||||||
setBudgetDrafts((current) => ({
|
setBudgetDrafts((current) => ({
|
||||||
...current,
|
...current,
|
||||||
@@ -738,14 +791,15 @@ export function BudgetColumn({
|
|||||||
: [];
|
: [];
|
||||||
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
const isRecurringSeries = expense.recurrence === "MONTHLY";
|
||||||
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
|
||||||
|
const isDetailsExpanded = expandedExpenseDetails[expense.id] ?? false;
|
||||||
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={expense.id}
|
key={expense.id}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2.25,
|
p: 1.55,
|
||||||
borderRadius: "18px",
|
borderRadius: "14px",
|
||||||
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
expense.approvalStatus === "APPROVED"
|
expense.approvalStatus === "APPROVED"
|
||||||
@@ -754,8 +808,8 @@ export function BudgetColumn({
|
|||||||
touchAction: "pan-x pan-y"
|
touchAction: "pan-x pan-y"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.4}>
|
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
|
<Stack spacing={0.75}>
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle1"
|
variant="subtitle1"
|
||||||
@@ -766,19 +820,171 @@ export function BudgetColumn({
|
|||||||
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
<Typography color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
{isRecurringSeries
|
{isRecurringSeries
|
||||||
? expense.occurrenceCount > 0
|
? expense.occurrenceCount > 0
|
||||||
? `${formatCurrency(expense.netPeriodAmount)} netto im Zeitraum (${expense.occurrenceCount} x ${formatCurrency(expense.amount)}) von ${expense.creator.name}`
|
? `${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}`
|
: `Noch keine Monatsrate in diesem Zeitraum · ${formatCurrency(expense.amount)} pro Monat · von ${expense.creator.name}`
|
||||||
: `${formatCurrency(expense.netPeriodAmount)} netto von ${expense.creator.name}`}
|
: `${formatCurrency(expense.amount)} von ${expense.creator.name}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
{expense.donationAmount > 0 ? (
|
{expense.donationAmount > 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{`Brutto: ${formatCurrency(expense.periodAmount)} · Spenden: ${formatCurrency(expense.donationAmount)}`}
|
{`Rest: ${formatCurrency(expense.netPeriodAmount)}`}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
<StatusChips expense={expense} />
|
<StatusChips expense={expense} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() =>
|
||||||
|
setExpandedExpenseDetails((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: !isDetailsExpanded
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
endIcon={isDetailsExpanded ? <ExpandLessRoundedIcon /> : <ExpandMoreRoundedIcon />}
|
||||||
|
sx={{ alignSelf: "flex-start", px: 0 }}
|
||||||
|
>
|
||||||
|
{isDetailsExpanded ? "Details ausblenden" : "Details anzeigen"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canEditExpenses ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditRoundedIcon />}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingExpenseId(expense.id);
|
||||||
|
setExpenseDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[expense.id]: getExpenseDraft(expense)
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
sx={{ alignSelf: "flex-start" }}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editingExpenseId === expense.id ? (
|
||||||
|
<Box sx={{ p: 1.2, borderRadius: "14px", border: `1px solid ${alpha(budget.colorCode, 0.25)}` }}>
|
||||||
|
{(() => {
|
||||||
|
const draft = getExpenseDraft(expense);
|
||||||
|
const editGroup =
|
||||||
|
workingGroups.find((entry) => entry.id === draft.agId) ?? workingGroups[0] ?? group;
|
||||||
|
const editBudgets = editGroup.budgets;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
size="small"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { title: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Beschreibung"
|
||||||
|
size="small"
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { description: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Betrag in EUR"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
value={draft.amount}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { amount: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="AG"
|
||||||
|
size="small"
|
||||||
|
value={draft.agId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextGroup = workingGroups.find((entry) => entry.id === event.target.value);
|
||||||
|
updateExpenseDraft(expense, {
|
||||||
|
agId: event.target.value,
|
||||||
|
budgetId: nextGroup?.budgets[0]?.id ?? ""
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{workingGroups.map((entry) => (
|
||||||
|
<MenuItem key={entry.id} value={entry.id}>
|
||||||
|
{entry.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Budget"
|
||||||
|
size="small"
|
||||||
|
value={draft.budgetId}
|
||||||
|
onChange={(event) => updateExpenseDraft(expense, { budgetId: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
disabled={editBudgets.length === 0}
|
||||||
|
>
|
||||||
|
{editBudgets.map((entry) => (
|
||||||
|
<MenuItem key={entry.id} value={entry.id}>
|
||||||
|
{entry.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Stichtag"
|
||||||
|
size="small"
|
||||||
|
value={draft.cutoffPhase}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateExpenseDraft(expense, {
|
||||||
|
cutoffPhase: event.target.value as "PRE" | "POST"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="PRE">Pre Open Air</MenuItem>
|
||||||
|
<MenuItem value="POST">Post Open Air</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disabled={busy || !draft.budgetId}
|
||||||
|
onClick={async () => {
|
||||||
|
await onUpdateExpense(expense.id, draft);
|
||||||
|
setEditingExpenseId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setEditingExpenseId(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Collapse in={isDetailsExpanded} unmountOnExit>
|
||||||
|
<Stack spacing={1}>
|
||||||
{expense.description ? (
|
{expense.description ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "break-word" }}>
|
||||||
{expense.description}
|
{expense.description}
|
||||||
@@ -1076,6 +1282,8 @@ export function BudgetColumn({
|
|||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -100,9 +100,21 @@ type DonationFormState = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
donatedAt: string;
|
donatedAt: string;
|
||||||
target: "GENERAL" | "EXPENSE";
|
target: "GENERAL" | "EXPENSE";
|
||||||
|
workingGroupId: string;
|
||||||
expenseId: string;
|
expenseId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DonationDraft = DonationFormState;
|
||||||
|
|
||||||
|
type ExpenseEditDraft = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
amount: string;
|
||||||
|
agId: string;
|
||||||
|
budgetId: string;
|
||||||
|
cutoffPhase: "PRE" | "POST";
|
||||||
|
};
|
||||||
|
|
||||||
type WorkingGroupFormState = {
|
type WorkingGroupFormState = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
@@ -337,7 +349,7 @@ export function DashboardShell({
|
|||||||
const desktopSections = [
|
const desktopSections = [
|
||||||
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
|
{ value: "overview" as const, label: "AG-\u00dcbersicht" },
|
||||||
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
|
{ value: "finance" as const, label: "Finanz\u00fcbersicht" },
|
||||||
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
|
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "AGs & Budgets" }] : []),
|
||||||
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
|
||||||
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
|
||||||
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
|
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
|
||||||
@@ -387,6 +399,7 @@ export function DashboardShell({
|
|||||||
amount: "",
|
amount: "",
|
||||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||||
target: "GENERAL",
|
target: "GENERAL",
|
||||||
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||||
expenseId: ""
|
expenseId: ""
|
||||||
});
|
});
|
||||||
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
const [budgetForm, setBudgetForm] = useState<BudgetFormState>({
|
||||||
@@ -428,6 +441,8 @@ export function DashboardShell({
|
|||||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||||
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
|
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
|
||||||
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
|
const [driveDiagnosticResult, setDriveDiagnosticResult] = useState<DriveDiagnosticResult | null>(null);
|
||||||
|
const [donationDrafts, setDonationDrafts] = useState<Record<string, DonationDraft>>({});
|
||||||
|
const [editingDonationId, setEditingDonationId] = useState<string | null>(null);
|
||||||
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
|
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
|
||||||
requiredApprovalTypes: settings.requiredApprovalTypes,
|
requiredApprovalTypes: settings.requiredApprovalTypes,
|
||||||
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
|
||||||
@@ -745,6 +760,10 @@ export function DashboardShell({
|
|||||||
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
|
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
|
||||||
selectedBudgetReleaseOptions[0] ??
|
selectedBudgetReleaseOptions[0] ??
|
||||||
null;
|
null;
|
||||||
|
const selectedDonationGroup =
|
||||||
|
visibleGroups.find((group) => group.id === donationForm.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||||
|
const selectedDonationGroupExpenses =
|
||||||
|
selectedDonationGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
|
||||||
const selectedBudgetReleasePaidAmount =
|
const selectedBudgetReleasePaidAmount =
|
||||||
selectedBudgetReleaseBudget?.expenses.reduce(
|
selectedBudgetReleaseBudget?.expenses.reduce(
|
||||||
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
|
(sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0),
|
||||||
@@ -791,6 +810,28 @@ export function DashboardShell({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDonationDraft(donation: DashboardDonation): DonationDraft {
|
||||||
|
return donationDrafts[donation.id] ?? {
|
||||||
|
title: donation.title,
|
||||||
|
description: donation.description ?? "",
|
||||||
|
amount: donation.amount.toFixed(2),
|
||||||
|
donatedAt: toDateInputValue(donation.donatedAt),
|
||||||
|
target: donation.expenseId ? "EXPENSE" : "GENERAL",
|
||||||
|
workingGroupId: donation.workingGroupId ?? visibleGroups[0]?.id ?? "",
|
||||||
|
expenseId: donation.expenseId ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDonationDraft(donation: DashboardDonation, patch: Partial<DonationDraft>) {
|
||||||
|
setDonationDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[donation.id]: {
|
||||||
|
...getDonationDraft(donation),
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return visibleGroups.reduce(
|
return visibleGroups.reduce(
|
||||||
(summary, group) => {
|
(summary, group) => {
|
||||||
@@ -1153,6 +1194,7 @@ export function DashboardShell({
|
|||||||
amount: "",
|
amount: "",
|
||||||
donatedAt: toDateInputValue(new Date().toISOString()),
|
donatedAt: toDateInputValue(new Date().toISOString()),
|
||||||
target: "GENERAL",
|
target: "GENERAL",
|
||||||
|
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||||
expenseId: ""
|
expenseId: ""
|
||||||
});
|
});
|
||||||
}, "Spende wurde erfasst.");
|
}, "Spende wurde erfasst.");
|
||||||
@@ -1178,6 +1220,51 @@ export function DashboardShell({
|
|||||||
}, "Ausgabe wurde gel\u00f6scht.");
|
}, "Ausgabe wurde gel\u00f6scht.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateExpense(expenseId: string, draft: ExpenseEditDraft) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await parseResponse(
|
||||||
|
await fetch(`/api/expenses/${expenseId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(draft)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, "Ausgabe wurde bearbeitet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateDonation(donationId: string, draft: DonationDraft) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await parseResponse(
|
||||||
|
await fetch(`/api/donations/${donationId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: draft.title,
|
||||||
|
description: draft.description,
|
||||||
|
amount: draft.amount,
|
||||||
|
donatedAt: draft.donatedAt,
|
||||||
|
expenseId: draft.target === "EXPENSE" ? draft.expenseId : ""
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setEditingDonationId(null);
|
||||||
|
}, "Spende wurde bearbeitet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDonation(donationId: string, title: string) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await parseResponse(
|
||||||
|
await fetch(`/api/donations/${donationId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, `Spende ${title} wurde gelöscht.`);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
|
async function handleCreatePeriod(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -1796,44 +1883,6 @@ export function DashboardShell({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSavePeriod} sx={nestedPanelSx}>
|
|
||||||
<Stack spacing={1.4}>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
Stichtage
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
Dieser Stichtag trennt Ausgaben in Pre/Post und wird in der Finanzübersicht ausgewertet.
|
|
||||||
</Typography>
|
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2}>
|
|
||||||
<TextField
|
|
||||||
label="Stichtag-Name"
|
|
||||||
value={periodEditForm.cutoffName}
|
|
||||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
|
|
||||||
required
|
|
||||||
fullWidth
|
|
||||||
disabled={!selectedPeriodForManagement}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Datum"
|
|
||||||
type="date"
|
|
||||||
value={periodEditForm.cutoffDate}
|
|
||||||
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
fullWidth
|
|
||||||
disabled={!selectedPeriodForManagement}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<EditRoundedIcon />}
|
|
||||||
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
|
||||||
>
|
|
||||||
Stichtag speichern
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
||||||
<Stack spacing={1.4}>
|
<Stack spacing={1.4}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
@@ -1893,6 +1942,44 @@ export function DashboardShell({
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const cutoffManagementPanel = canManagePeriods ? (
|
||||||
|
<Box component="form" onSubmit={handleSavePeriod}>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
Stichtage
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Stichtag für Pre/Post-Auswertungen anlegen und bearbeiten.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Stichtag-Name"
|
||||||
|
value={periodEditForm.cutoffName}
|
||||||
|
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffName: event.target.value }))}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={!selectedPeriodForManagement}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Datum"
|
||||||
|
type="date"
|
||||||
|
value={periodEditForm.cutoffDate}
|
||||||
|
onChange={(event) => setPeriodEditForm((current) => ({ ...current, cutoffDate: event.target.value }))}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
disabled={!selectedPeriodForManagement}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EditRoundedIcon />}
|
||||||
|
disabled={busy || !selectedPeriodForManagement || !periodEditDirty}
|
||||||
|
>
|
||||||
|
Stichtag speichern
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : null;
|
||||||
const actionCards = (
|
const actionCards = (
|
||||||
<Stack
|
<Stack
|
||||||
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
|
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
|
||||||
@@ -2230,6 +2317,28 @@ export function DashboardShell({
|
|||||||
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
{donationForm.target === "EXPENSE" ? (
|
{donationForm.target === "EXPENSE" ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="AG"
|
||||||
|
value={donationForm.workingGroupId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDonationForm((current) => ({
|
||||||
|
...current,
|
||||||
|
workingGroupId: event.target.value,
|
||||||
|
expenseId: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
disabled={visibleGroups.length === 0}
|
||||||
|
>
|
||||||
|
{visibleGroups.map((group) => (
|
||||||
|
<MenuItem key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
label="Ausgabe"
|
label="Ausgabe"
|
||||||
@@ -2237,14 +2346,15 @@ export function DashboardShell({
|
|||||||
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
|
onChange={(event) => setDonationForm((current) => ({ ...current, expenseId: event.target.value }))}
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={allExpenses.length === 0}
|
disabled={selectedDonationGroupExpenses.length === 0}
|
||||||
>
|
>
|
||||||
{allExpenses.map((expense) => (
|
{selectedDonationGroupExpenses.map((expense) => (
|
||||||
<MenuItem key={expense.id} value={expense.id}>
|
<MenuItem key={expense.id} value={expense.id}>
|
||||||
{expense.title} · {currencyFormatter.format(expense.netPeriodAmount)}
|
{expense.title} · Rest: {currencyFormatter.format(expense.netPeriodAmount)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="submit" variant="outlined" disabled={busy}>
|
<Button type="submit" variant="outlined" disabled={busy}>
|
||||||
Spende speichern
|
Spende speichern
|
||||||
@@ -2371,9 +2481,14 @@ export function DashboardShell({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
|
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
|
||||||
|
<Stack spacing={3}>
|
||||||
<Card sx={islandCardSx}>
|
<Card sx={islandCardSx}>
|
||||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card sx={islandCardSx}>
|
||||||
|
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
|
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
|
||||||
@@ -2921,6 +3036,172 @@ export function DashboardShell({
|
|||||||
|
|
||||||
const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null;
|
const selectedMobileGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0] ?? null;
|
||||||
const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups;
|
const overviewGroups = isCompactLayout && selectedMobileGroup ? [selectedMobileGroup] : visibleGroups;
|
||||||
|
const generalDonations = donations.filter((donation) => !donation.expenseId);
|
||||||
|
|
||||||
|
function renderDonationEditor(donation: DashboardDonation) {
|
||||||
|
const draft = getDonationDraft(donation);
|
||||||
|
const draftGroup = visibleGroups.find((group) => group.id === draft.workingGroupId) ?? visibleGroups[0] ?? null;
|
||||||
|
const draftExpenses = draftGroup?.budgets.flatMap((budget) => budget.expenses) ?? [];
|
||||||
|
|
||||||
|
if (editingDonationId !== donation.id) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={0.8}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" gap={1}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, overflowWrap: "break-word" }}>
|
||||||
|
{donation.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{currencyFormatter.format(donation.amount)}
|
||||||
|
{donation.expenseTitle ? ` · ${donation.expenseTitle}` : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" gap={0.5}>
|
||||||
|
<IconButton size="small" disabled={busy} onClick={() => setEditingDonationId(donation.id)}>
|
||||||
|
<EditRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`Spende "${donation.title}" wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDeleteDonation(donation.id, donation.title);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<TextField
|
||||||
|
label="Titel"
|
||||||
|
size="small"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => updateDonationDraft(donation, { title: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Betrag in EUR"
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
inputProps={{ min: 0.01, step: 0.01 }}
|
||||||
|
value={draft.amount}
|
||||||
|
onChange={(event) => updateDonationDraft(donation, { amount: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Spendendatum"
|
||||||
|
type="date"
|
||||||
|
size="small"
|
||||||
|
value={draft.donatedAt}
|
||||||
|
onChange={(event) => updateDonationDraft(donation, { donatedAt: event.target.value })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Zuordnung"
|
||||||
|
size="small"
|
||||||
|
value={draft.target}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDonationDraft(donation, {
|
||||||
|
target: event.target.value as DonationDraft["target"],
|
||||||
|
expenseId: ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<MenuItem value="GENERAL">Allgemein</MenuItem>
|
||||||
|
<MenuItem value="EXPENSE">Ausgabe zugeordnet</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
{draft.target === "EXPENSE" ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="AG"
|
||||||
|
size="small"
|
||||||
|
value={draft.workingGroupId}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDonationDraft(donation, {
|
||||||
|
workingGroupId: event.target.value,
|
||||||
|
expenseId: ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{visibleGroups.map((group) => (
|
||||||
|
<MenuItem key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Ausgabe"
|
||||||
|
size="small"
|
||||||
|
value={draft.expenseId}
|
||||||
|
onChange={(event) => updateDonationDraft(donation, { expenseId: event.target.value })}
|
||||||
|
fullWidth
|
||||||
|
disabled={draftExpenses.length === 0}
|
||||||
|
>
|
||||||
|
{draftExpenses.map((expense) => (
|
||||||
|
<MenuItem key={expense.id} value={expense.id}>
|
||||||
|
{expense.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Stack direction="row" gap={1}>
|
||||||
|
<Button size="small" variant="contained" disabled={busy} onClick={() => handleUpdateDonation(donation.id, draft)}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="text" disabled={busy} onClick={() => setEditingDonationId(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalDonationsColumn = (
|
||||||
|
<Card sx={{ ...islandCardSx, width: { xs: "100%", lg: 286 }, flex: "0 0 auto" }}>
|
||||||
|
<CardContent sx={{ p: 2.2 }}>
|
||||||
|
<Stack spacing={1.4}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
|
||||||
|
Spenden
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Allgemein: {currencyFormatter.format(generalDonationTotal)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{generalDonations.length === 0 ? (
|
||||||
|
<Box sx={{ p: 1.5, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.12) }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Noch keine allgemeinen Spenden.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
generalDonations.map((donation) => (
|
||||||
|
<Box key={donation.id} sx={{ p: 1.4, borderRadius: "14px", bgcolor: alpha("#F6C343", 0.16) }}>
|
||||||
|
{renderDonationEditor(donation)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
const overviewContent = (
|
const overviewContent = (
|
||||||
<Stack spacing={2.5}>
|
<Stack spacing={2.5}>
|
||||||
@@ -2962,6 +3243,7 @@ export function DashboardShell({
|
|||||||
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
<Box key={group.id} sx={{ width: "100%", flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||||
<BudgetColumn
|
<BudgetColumn
|
||||||
group={group}
|
group={group}
|
||||||
|
workingGroups={visibleGroups}
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
@@ -2976,9 +3258,11 @@ export function DashboardShell({
|
|||||||
onSaveBudget={handleSaveBudget}
|
onSaveBudget={handleSaveBudget}
|
||||||
onDeleteBudget={handleDeleteBudget}
|
onDeleteBudget={handleDeleteBudget}
|
||||||
onDeleteExpense={handleDeleteExpense}
|
onDeleteExpense={handleDeleteExpense}
|
||||||
|
onUpdateExpense={handleUpdateExpense}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{generalDonationsColumn}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
@@ -3009,6 +3293,7 @@ export function DashboardShell({
|
|||||||
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
<Box key={group.id} sx={{ flex: "0 0 auto", scrollSnapAlign: "start" }}>
|
||||||
<BudgetColumn
|
<BudgetColumn
|
||||||
group={group}
|
group={group}
|
||||||
|
workingGroups={visibleGroups}
|
||||||
viewer={viewer}
|
viewer={viewer}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
@@ -3023,9 +3308,11 @@ export function DashboardShell({
|
|||||||
onSaveBudget={handleSaveBudget}
|
onSaveBudget={handleSaveBudget}
|
||||||
onDeleteBudget={handleDeleteBudget}
|
onDeleteBudget={handleDeleteBudget}
|
||||||
onDeleteExpense={handleDeleteExpense}
|
onDeleteExpense={handleDeleteExpense}
|
||||||
|
onUpdateExpense={handleUpdateExpense}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{generalDonationsColumn}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -3069,27 +3356,33 @@ export function DashboardShell({
|
|||||||
if (financeViewMode === "cutoff") {
|
if (financeViewMode === "cutoff") {
|
||||||
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
const pre = allExpenses.filter((expense) => expense.cutoffPhase === "PRE");
|
||||||
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
const post = allExpenses.filter((expense) => expense.cutoffPhase === "POST");
|
||||||
|
const cutoffDate = currentPeriod?.cutoffDate ? new Date(currentPeriod.cutoffDate) : null;
|
||||||
|
const preGeneralDonations = donations
|
||||||
|
.filter((donation) => !donation.expenseId && (!cutoffDate || new Date(donation.donatedAt) <= cutoffDate))
|
||||||
|
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||||
|
const postGeneralDonations = donations
|
||||||
|
.filter((donation) => !donation.expenseId && cutoffDate && new Date(donation.donatedAt) > cutoffDate)
|
||||||
|
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||||
|
const preAssignedDonations = donations
|
||||||
|
.filter((donation) => donation.expenseId && pre.some((expense) => expense.id === donation.expenseId))
|
||||||
|
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||||
|
const postAssignedDonations = donations
|
||||||
|
.filter((donation) => donation.expenseId && post.some((expense) => expense.id === donation.expenseId))
|
||||||
|
.reduce((sum, donation) => sum + donation.amount, 0);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
label: `Pre ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||||
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
planned: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||||
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
approved: pre.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||||
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
paid: pre.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||||
donations: assignedDonationTotal
|
donations: preAssignedDonations + preGeneralDonations
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
label: `Post ${currentPeriod?.cutoffName ?? "Open Air"}`,
|
||||||
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
planned: post.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.netPeriodAmount : 0), 0),
|
||||||
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
approved: post.reduce((sum, expense) => sum + (expense.approvalStatus === "APPROVED" ? expense.netPeriodAmount : 0), 0),
|
||||||
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
paid: post.reduce((sum, expense) => sum + (expense.paidAt ? expense.netPeriodAmount : 0), 0),
|
||||||
donations: 0
|
donations: postAssignedDonations + postGeneralDonations
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Allgemeine Spenden",
|
|
||||||
planned: 0,
|
|
||||||
approved: 0,
|
|
||||||
paid: 0,
|
|
||||||
donations: generalDonationTotal
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -3135,9 +3428,9 @@ export function DashboardShell({
|
|||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||||
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
|
<Chip label={`Budget: ${currencyFormatter.format(totals.budget)}`} />
|
||||||
<Chip label={`Bezahlt netto: ${currencyFormatter.format(paidTotal)}`} color="info" />
|
<Chip label={`Bezahlt: ${currencyFormatter.format(paidTotal)}`} color="info" />
|
||||||
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
|
<Chip label={`Spenden: ${currencyFormatter.format(generalDonationTotal + assignedDonationTotal)}`} color="success" />
|
||||||
<Chip label={`Netto-Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
<Chip label={`Rest: ${currencyFormatter.format(totals.budget - totals.approved - totals.pending + generalDonationTotal)}`} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -3213,6 +3506,11 @@ export function DashboardShell({
|
|||||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canManagePeriods ? (
|
||||||
|
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 420 }, flexShrink: 0 }}>
|
||||||
|
<CardContent sx={{ p: 3 }}>{cutoffManagementPanel}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ width: "100%" }}>{actionCards}</Box>
|
<Box sx={{ width: "100%" }}>{actionCards}</Box>
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ export type DashboardDonation = {
|
|||||||
donatedAt: string;
|
donatedAt: string;
|
||||||
periodId: string;
|
periodId: string;
|
||||||
expenseId: string | null;
|
expenseId: string | null;
|
||||||
|
workingGroupId: string | null;
|
||||||
|
workingGroupName: string | null;
|
||||||
|
expenseTitle: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
creator: {
|
creator: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user