Bereits an AG übergeben läuft jetzt so, wie du es beschrieben hast:
Some checks failed
CI / Build (push) Failing after 1m6s
CI / Deploy (push) Has been skipped

Bezahlt setzen zählt in der Budgetanzeige automatisch mit.
Zusätzlich gibt es unter Neue Ausgabe eine eigene Insel für zusätzlich bereits übergebenes Geld, falls das nicht über einzelne Ausgaben läuft.
In den Budgetkarten wird das als gestrichelte Querlinie plus eigenem Chip dargestellt
This commit is contained in:
Jan
2026-04-13 21:22:02 +02:00
parent 051de0eea3
commit 34e5f96db7
13 changed files with 317 additions and 21 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "budgets" ADD COLUMN "released_amount" DECIMAL(10, 2) NOT NULL DEFAULT 0;

View File

@@ -88,6 +88,7 @@ model Budget {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
totalBudget Decimal @db.Decimal(10, 2) @map("total_budget") totalBudget Decimal @db.Decimal(10, 2) @map("total_budget")
releasedAmount Decimal @default(0) @db.Decimal(10, 2) @map("released_amount")
colorCode String @map("color_code") colorCode String @map("color_code")
workingGroupId String @map("working_group_id") workingGroupId String @map("working_group_id")
periodId String @map("period_id") periodId String @map("period_id")

View File

@@ -60,7 +60,8 @@ async function upsertBudget(
periodId: string, periodId: string,
name: string, name: string,
totalBudget: number, totalBudget: number,
colorCode: string colorCode: string,
releasedAmount = 0
) { ) {
return prisma.budget.upsert({ return prisma.budget.upsert({
where: { where: {
@@ -72,11 +73,13 @@ async function upsertBudget(
}, },
update: { update: {
totalBudget, totalBudget,
releasedAmount,
colorCode colorCode
}, },
create: { create: {
name, name,
totalBudget, totalBudget,
releasedAmount,
colorCode, colorCode,
workingGroupId, workingGroupId,
periodId periodId
@@ -128,9 +131,9 @@ async function main() {
const awareness = await upsertWorkingGroup("AG Awareness"); const awareness = await upsertWorkingGroup("AG Awareness");
const technik = await upsertWorkingGroup("AG Technik"); const technik = await upsertWorkingGroup("AG Technik");
await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A"); await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A", 0);
await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D"); await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D", 250);
const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6"); const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6", 500);
await upsertUser({ await upsertUser({
username: "vorstand-a", username: "vorstand-a",

View File

@@ -159,6 +159,7 @@ export async function POST(_: Request, { params }: Context) {
data: { data: {
name: asString(previous.name, "Budgetname"), name: asString(previous.name, "Budgetname"),
totalBudget: asNumber(previous.totalBudget, "Budgetbetrag"), totalBudget: asNumber(previous.totalBudget, "Budgetbetrag"),
releasedAmount: asNumber(previous.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"),
colorCode: asString(previous.colorCode, "Budgetfarbe") colorCode: asString(previous.colorCode, "Budgetfarbe")
} }
}); });
@@ -173,6 +174,7 @@ export async function POST(_: Request, { params }: Context) {
id: asString(deleted.id, "Budget-ID"), id: asString(deleted.id, "Budget-ID"),
name: asString(deleted.name, "Budgetname"), name: asString(deleted.name, "Budgetname"),
totalBudget: asNumber(deleted.totalBudget, "Budgetbetrag"), totalBudget: asNumber(deleted.totalBudget, "Budgetbetrag"),
releasedAmount: asNumber(deleted.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"),
colorCode: asString(deleted.colorCode, "Budgetfarbe"), colorCode: asString(deleted.colorCode, "Budgetfarbe"),
workingGroupId: asString(deleted.workingGroupId, "AG-ID"), workingGroupId: asString(deleted.workingGroupId, "AG-ID"),
periodId: asString(deleted.periodId, "Zeitraum-ID"), periodId: asString(deleted.periodId, "Zeitraum-ID"),

View File

@@ -8,11 +8,22 @@ import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; import { getCurrentViewer } from "@/lib/session";
const updateBudgetSchema = z.object({ const updateBudgetSchema = z
name: z.string().trim().min(2).max(80), .object({
totalBudget: z.coerce.number().min(0), name: z.string().trim().min(2).max(80),
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) totalBudget: z.coerce.number().min(0),
}); releasedAmount: z.coerce.number().min(0).optional(),
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/)
})
.superRefine((value, ctx) => {
if (value.releasedAmount !== undefined && value.releasedAmount > value.totalBudget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Der bereits an die AG uebergebene Betrag darf das Budget nicht uebersteigen.",
path: ["releasedAmount"]
});
}
});
type Context = { type Context = {
params: { params: {
@@ -43,16 +54,18 @@ export async function PATCH(request: Request, { params }: Context) {
const parsed = updateBudgetSchema.safeParse(body); const parsed = updateBudgetSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: "Budgetname, Betrag oder Farbe sind ungueltig." }, { status: 400 }); return NextResponse.json({ error: "Budgetname, Betrag, Mitteluebergabe oder Farbe sind ungueltig." }, { status: 400 });
} }
try { try {
const previousBudget = budget; const previousBudget = budget;
const nextReleasedAmount = parsed.data.releasedAmount ?? Number(previousBudget.releasedAmount);
const updatedBudget = await prisma.budget.update({ const updatedBudget = await prisma.budget.update({
where: { id: params.id }, where: { id: params.id },
data: { data: {
name: parsed.data.name, name: parsed.data.name,
totalBudget: parsed.data.totalBudget, totalBudget: parsed.data.totalBudget,
releasedAmount: nextReleasedAmount,
colorCode: parsed.data.colorCode colorCode: parsed.data.colorCode
} }
}); });
@@ -66,6 +79,7 @@ export async function PATCH(request: Request, { params }: Context) {
summary: `Budget ${updatedBudget.name} wurde aktualisiert.`, summary: `Budget ${updatedBudget.name} wurde aktualisiert.`,
metadata: { metadata: {
totalBudget: parsed.data.totalBudget, totalBudget: parsed.data.totalBudget,
releasedAmount: nextReleasedAmount,
colorCode: parsed.data.colorCode, colorCode: parsed.data.colorCode,
rollback: { rollback: {
kind: "budget.update", kind: "budget.update",
@@ -99,7 +113,7 @@ export async function DELETE(_: Request, { params }: Context) {
} }
if (!canManageBudgets(viewer.role)) { if (!canManageBudgets(viewer.role)) {
return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG dürfen Budgets löschen." }, { status: 403 }); return NextResponse.json({ error: "Nur Vorstand oder Finanz-AG duerfen Budgets loeschen." }, { status: 403 });
} }
const budget = await prisma.budget.findUnique({ const budget = await prisma.budget.findUnique({

View File

@@ -7,13 +7,24 @@ import { canManageBudgets } from "@/lib/domain";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentViewer } from "@/lib/session"; import { getCurrentViewer } from "@/lib/session";
const budgetSchema = z.object({ const budgetSchema = z
workingGroupId: z.string().trim().min(1), .object({
periodId: z.string().trim().min(1), workingGroupId: z.string().trim().min(1),
name: z.string().trim().min(2).max(80), periodId: z.string().trim().min(1),
totalBudget: z.coerce.number().min(0), name: z.string().trim().min(2).max(80),
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) totalBudget: z.coerce.number().min(0),
}); releasedAmount: z.coerce.number().min(0).optional(),
colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/)
})
.superRefine((value, ctx) => {
if (value.releasedAmount !== undefined && value.releasedAmount > value.totalBudget) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Der bereits an die AG uebergebene Betrag darf das Budget nicht uebersteigen.",
path: ["releasedAmount"]
});
}
});
export async function POST(request: Request) { export async function POST(request: Request) {
const viewer = await getCurrentViewer(); const viewer = await getCurrentViewer();
@@ -30,7 +41,10 @@ export async function POST(request: Request) {
const parsed = budgetSchema.safeParse(body); const parsed = budgetSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: "Bitte AG, Budgetname, Betrag und Farbe korrekt angeben." }, { status: 400 }); return NextResponse.json(
{ error: "Bitte AG, Budgetname, Betrag, Mitteluebergabe und Farbe korrekt angeben." },
{ status: 400 }
);
} }
const workingGroup = await prisma.workingGroup.findUnique({ const workingGroup = await prisma.workingGroup.findUnique({
@@ -63,6 +77,8 @@ export async function POST(request: Request) {
} }
}); });
const nextReleasedAmount = parsed.data.releasedAmount ?? Number(existingBudget?.releasedAmount ?? 0);
const budget = existingBudget const budget = existingBudget
? await prisma.budget.update({ ? await prisma.budget.update({
where: { where: {
@@ -70,6 +86,7 @@ export async function POST(request: Request) {
}, },
data: { data: {
totalBudget: parsed.data.totalBudget, totalBudget: parsed.data.totalBudget,
releasedAmount: nextReleasedAmount,
colorCode: parsed.data.colorCode colorCode: parsed.data.colorCode
} }
}) })
@@ -79,6 +96,7 @@ export async function POST(request: Request) {
periodId: accountingPeriod.id, periodId: accountingPeriod.id,
name: parsed.data.name, name: parsed.data.name,
totalBudget: parsed.data.totalBudget, totalBudget: parsed.data.totalBudget,
releasedAmount: nextReleasedAmount,
colorCode: parsed.data.colorCode colorCode: parsed.data.colorCode
} }
}); });
@@ -98,6 +116,7 @@ export async function POST(request: Request) {
periodId: accountingPeriod.id, periodId: accountingPeriod.id,
periodName: accountingPeriod.name, periodName: accountingPeriod.name,
totalBudget: parsed.data.totalBudget, totalBudget: parsed.data.totalBudget,
releasedAmount: nextReleasedAmount,
rollback: existingBudget rollback: existingBudget
? { ? {
kind: "budget.update", kind: "budget.update",

View File

@@ -33,6 +33,7 @@ const CSV_HEADERS = [
"description", "description",
"amount", "amount",
"totalBudget", "totalBudget",
"releasedAmount",
"colorCode", "colorCode",
"approvalStatus", "approvalStatus",
"approvalType", "approvalType",
@@ -198,6 +199,7 @@ export async function GET() {
description: "", description: "",
amount: "", amount: "",
totalBudget: "", totalBudget: "",
releasedAmount: "",
colorCode: "", colorCode: "",
approvalStatus: "", approvalStatus: "",
approvalType: "", approvalType: "",
@@ -250,6 +252,7 @@ export async function GET() {
description: "", description: "",
amount: "", amount: "",
totalBudget: "", totalBudget: "",
releasedAmount: "",
colorCode: "", colorCode: "",
approvalStatus: "", approvalStatus: "",
approvalType: "", approvalType: "",
@@ -302,6 +305,7 @@ export async function GET() {
description: "", description: "",
amount: "", amount: "",
totalBudget: group.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0).toFixed(2), totalBudget: group.budgets.reduce((sum, budget) => sum + Number(budget.totalBudget), 0).toFixed(2),
releasedAmount: group.budgets.reduce((sum, budget) => sum + Number(budget.releasedAmount), 0).toFixed(2),
colorCode: "", colorCode: "",
approvalStatus: "", approvalStatus: "",
approvalType: "", approvalType: "",
@@ -353,6 +357,7 @@ export async function GET() {
description: "", description: "",
amount: "", amount: "",
totalBudget: Number(budget.totalBudget).toFixed(2), totalBudget: Number(budget.totalBudget).toFixed(2),
releasedAmount: Number(budget.releasedAmount).toFixed(2),
colorCode: budget.colorCode, colorCode: budget.colorCode,
approvalStatus: "", approvalStatus: "",
approvalType: "", approvalType: "",
@@ -510,6 +515,7 @@ export async function GET() {
description: "", description: "",
amount: "", amount: "",
totalBudget: "", totalBudget: "",
releasedAmount: "",
colorCode: "", colorCode: "",
approvalStatus: "", approvalStatus: "",
approvalType: "", approvalType: "",

View File

@@ -167,9 +167,14 @@ export async function POST(request: Request) {
for (const row of budgetRows) { for (const row of budgetRows) {
const totalBudget = toNumber(row.totalBudget); const totalBudget = toNumber(row.totalBudget);
const releasedAmount = toNumber(row.releasedAmount) ?? 0;
if (totalBudget === null) { if (totalBudget === null) {
throw new Error(`Budget ${row.budgetName || row.id} enthält keinen gültigen Betrag.`); throw new Error(`Budget ${row.budgetName || row.id} enth\u00e4lt keinen g\u00fcltigen Betrag.`);
}
if (releasedAmount > totalBudget) {
throw new Error(`Budget ${row.budgetName || row.id} enth\u00e4lt eine zu hohe zus\u00e4tzliche Mittel\u00fcbergabe.`);
} }
await tx.budget.create({ await tx.budget.create({
@@ -177,6 +182,7 @@ export async function POST(request: Request) {
id: row.id, id: row.id,
name: row.budgetName, name: row.budgetName,
totalBudget, totalBudget,
releasedAmount,
colorCode: row.colorCode, colorCode: row.colorCode,
workingGroupId: row.workingGroupId, workingGroupId: row.workingGroupId,
periodId: row.periodId, periodId: row.periodId,

View File

@@ -150,6 +150,7 @@ export default async function DashboardPage() {
id: budget.id, id: budget.id,
name: budget.name, name: budget.name,
totalBudget: Number(budget.totalBudget), totalBudget: Number(budget.totalBudget),
releasedAmount: Number(budget.releasedAmount),
colorCode: budget.colorCode, colorCode: budget.colorCode,
periodId: budget.periodId, periodId: budget.periodId,
expenses: budget.expenses.map((expense) => { expenses: budget.expenses.map((expense) => {

View File

@@ -128,6 +128,10 @@ function getPendingSpend(expenses: DashboardExpense[]) {
return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0); return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0);
} }
function getPaidSpend(expenses: DashboardExpense[]) {
return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0);
}
export function BudgetColumn({ export function BudgetColumn({
group, group,
viewer, viewer,
@@ -182,6 +186,10 @@ export function BudgetColumn({
() => group.budgets.reduce((sum, budget) => sum + getPendingSpend(budget.expenses), 0), () => group.budgets.reduce((sum, budget) => sum + getPendingSpend(budget.expenses), 0),
[group.budgets] [group.budgets]
); );
const releasedSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + budget.releasedAmount + getPaidSpend(budget.expenses), 0),
[group.budgets]
);
const totalCommitted = approvedSpend + pendingSpend; const totalCommitted = approvedSpend + pendingSpend;
const remainingBudget = group.totalBudget - totalCommitted; const remainingBudget = group.totalBudget - totalCommitted;
@@ -340,6 +348,12 @@ export function BudgetColumn({
color={remainingBudget < 0 ? "error" : "default"} color={remainingBudget < 0 ? "error" : "default"}
sx={{ ...wrappingChipSx, width: "fit-content" }} sx={{ ...wrappingChipSx, width: "fit-content" }}
/> />
<Chip
label={`An AG übergeben: ${formatCurrency(releasedSpend)}`}
color={releasedSpend > group.totalBudget ? "error" : "default"}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
</Stack> </Stack>
{group.budgets.length === 0 ? ( {group.budgets.length === 0 ? (
@@ -372,11 +386,15 @@ export function BudgetColumn({
const isEditing = editingBudgetId === budget.id; const isEditing = editingBudgetId === budget.id;
const budgetApproved = getApprovedSpend(budget.expenses); const budgetApproved = getApprovedSpend(budget.expenses);
const budgetPending = getPendingSpend(budget.expenses); const budgetPending = getPendingSpend(budget.expenses);
const budgetReleasedByPayments = getPaidSpend(budget.expenses);
const budgetReleasedTotal = budget.releasedAmount + budgetReleasedByPayments;
const budgetCommitted = budgetApproved + budgetPending; const budgetCommitted = budgetApproved + budgetPending;
const budgetRemaining = budget.totalBudget - budgetCommitted; const budgetRemaining = budget.totalBudget - budgetCommitted;
const approvedPercent = budget.totalBudget > 0 ? Math.min((budgetApproved / budget.totalBudget) * 100, 100) : 0; const approvedPercent = budget.totalBudget > 0 ? Math.min((budgetApproved / budget.totalBudget) * 100, 100) : 0;
const cumulativePercent = const cumulativePercent =
budget.totalBudget > 0 ? Math.min((budgetCommitted / budget.totalBudget) * 100, 100) : 0; budget.totalBudget > 0 ? Math.min((budgetCommitted / budget.totalBudget) * 100, 100) : 0;
const releasedPercent =
budget.totalBudget > 0 ? Math.min((budgetReleasedTotal / budget.totalBudget) * 100, 100) : 0;
return ( return (
<Box <Box
@@ -466,6 +484,19 @@ export function BudgetColumn({
transition: "height 220ms ease" transition: "height 220ms ease"
}} }}
/> />
{budgetReleasedTotal > 0 ? (
<Box
sx={{
position: "absolute",
left: 8,
right: 8,
bottom: `calc(${releasedPercent}% - 1px)`,
borderTop: `2px dashed ${alpha(isDark ? "#FFFFFF" : budget.colorCode, isDark ? 0.82 : 0.9)}`,
zIndex: 2,
pointerEvents: "none"
}}
/>
) : null}
</Box> </Box>
</Box> </Box>
@@ -487,6 +518,15 @@ export function BudgetColumn({
color={budgetRemaining < 0 ? "error" : "default"} color={budgetRemaining < 0 ? "error" : "default"}
sx={{ ...wrappingChipSx, width: "fit-content" }} sx={{ ...wrappingChipSx, width: "fit-content" }}
/> />
<Chip
label={`An AG übergeben: ${formatCurrency(budgetReleasedTotal)}`}
color={budgetReleasedTotal > budget.totalBudget ? "error" : "default"}
variant="outlined"
sx={{ ...wrappingChipSx, width: "fit-content" }}
/>
<Typography variant="body2" color="text.secondary">
{`Davon zusätzlich ohne Einzelposten erfasst: ${formatCurrency(budget.releasedAmount)}. "Bezahlt setzen" zählt automatisch mit.`}
</Typography>
<Typography color="text.secondary"> <Typography color="text.secondary">
{`Unter ${formatCurrency(approvalThreshold)} werden sofort freigegeben. Größere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen.`} {`Unter ${formatCurrency(approvalThreshold)} werden sofort freigegeben. Größere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen.`}
</Typography> </Typography>

View File

@@ -77,6 +77,12 @@ type BudgetFormState = {
colorCode: string; colorCode: string;
}; };
type BudgetReleaseFormState = {
workingGroupId: string;
budgetId: string;
releasedAmount: string;
};
type WorkingGroupFormState = { type WorkingGroupFormState = {
name: string; name: string;
}; };
@@ -243,6 +249,11 @@ export function DashboardShell({
totalBudget: "1200", totalBudget: "1200",
colorCode: "#FFB94A" colorCode: "#FFB94A"
}); });
const [budgetReleaseForm, setBudgetReleaseForm] = useState<BudgetReleaseFormState>({
workingGroupId: visibleGroups[0]?.id ?? "",
budgetId: visibleGroups[0]?.budgets[0]?.id ?? "",
releasedAmount: (visibleGroups[0]?.budgets[0]?.releasedAmount ?? 0).toFixed(2)
});
const [workingGroupForm, setWorkingGroupForm] = useState<WorkingGroupFormState>({ const [workingGroupForm, setWorkingGroupForm] = useState<WorkingGroupFormState>({
name: "" name: ""
}); });
@@ -310,6 +321,52 @@ export function DashboardShell({
} }
}, [budgetForm.workingGroupId, visibleGroups]); }, [budgetForm.workingGroupId, visibleGroups]);
useEffect(() => {
if (visibleGroups.length === 0) {
setBudgetReleaseForm({
workingGroupId: "",
budgetId: "",
releasedAmount: "0.00"
});
return;
}
const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0];
const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId) ?? selectedGroup?.budgets[0];
if (
budgetReleaseForm.workingGroupId !== (selectedGroup?.id ?? "") ||
budgetReleaseForm.budgetId !== (selectedBudget?.id ?? "")
) {
setBudgetReleaseForm({
workingGroupId: selectedGroup?.id ?? "",
budgetId: selectedBudget?.id ?? "",
releasedAmount: (selectedBudget?.releasedAmount ?? 0).toFixed(2)
});
}
}, [budgetReleaseForm.budgetId, budgetReleaseForm.workingGroupId, visibleGroups]);
useEffect(() => {
if (!budgetReleaseForm.budgetId) {
return;
}
const selectedGroup = visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0];
const selectedBudget = selectedGroup?.budgets.find((budget) => budget.id === budgetReleaseForm.budgetId);
if (!selectedBudget) {
return;
}
const nextReleasedAmount = selectedBudget.releasedAmount.toFixed(2);
if (budgetReleaseForm.releasedAmount !== nextReleasedAmount) {
setBudgetReleaseForm((current) => ({
...current,
releasedAmount: nextReleasedAmount
}));
}
}, [budgetReleaseForm.budgetId, budgetReleaseForm.workingGroupId, visibleGroups]);
useEffect(() => { useEffect(() => {
if (!message || message.type !== "success") { if (!message || message.type !== "success") {
return; return;
@@ -375,6 +432,18 @@ export function DashboardShell({
const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0]; const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0];
const selectedBudgetWorkingGroup = const selectedBudgetWorkingGroup =
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null; visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
const selectedBudgetReleaseGroup =
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
const selectedBudgetReleaseOptions = selectedBudgetReleaseGroup?.budgets ?? [];
const selectedBudgetReleaseBudget =
selectedBudgetReleaseOptions.find((budget) => budget.id === budgetReleaseForm.budgetId) ??
selectedBudgetReleaseOptions[0] ??
null;
const selectedBudgetReleasePaidAmount =
selectedBudgetReleaseBudget?.expenses.reduce(
(sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0),
0
) ?? 0;
const selectedPeriodForManagement = const selectedPeriodForManagement =
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
@@ -645,6 +714,44 @@ export function DashboardShell({
}, "Budget wurde aktualisiert."); }, "Budget wurde aktualisiert.");
} }
async function handleSaveBudgetRelease(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedBudgetReleaseBudget) {
setMessage({ type: "error", text: "Bitte zuerst ein Budget ausw\u00e4hlen." });
return;
}
const nextReleasedAmount = Number(budgetReleaseForm.releasedAmount.replace(",", "."));
if (!Number.isFinite(nextReleasedAmount) || nextReleasedAmount < 0) {
setMessage({ type: "error", text: "Bitte einen g\u00fcltigen zus\u00e4tzlichen \u00dcbergabebetrag eingeben." });
return;
}
await runAction(async () => {
await parseResponse(
await fetch(`/api/budgets/${selectedBudgetReleaseBudget.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name: selectedBudgetReleaseBudget.name,
totalBudget: selectedBudgetReleaseBudget.totalBudget,
releasedAmount: nextReleasedAmount,
colorCode: selectedBudgetReleaseBudget.colorCode
})
})
);
setBudgetReleaseForm((current) => ({
...current,
releasedAmount: nextReleasedAmount.toFixed(2)
}));
}, `Zus\u00e4tzliche Mittel\u00fcbergabe f\u00fcr ${selectedBudgetReleaseBudget.name} wurde gespeichert.`);
}
async function handleDeleteBudget(budgetId: string) { async function handleDeleteBudget(budgetId: string) {
await runAction(async () => { await runAction(async () => {
await parseResponse( await parseResponse(
@@ -1274,6 +1381,98 @@ export function DashboardShell({
</Card> </Card>
) : null} ) : null}
{canManagePeriods && (isCompactLayout || desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
{"Bereits an AG übergeben"}
</Typography>
<Typography color="text.secondary">
{"\"Bezahlt setzen\" z\u00e4hlt automatisch mit. Hier erg\u00e4nzt du nur zus\u00e4tzlich bereits \u00fcbergebenes Geld, das nicht \u00fcber eine einzelne Ausgabe l\u00e4uft."}
</Typography>
</Box>
<Box component="form" onSubmit={handleSaveBudgetRelease}>
<Stack spacing={2}>
<TextField
select
label="Arbeitsgruppe"
value={budgetReleaseForm.workingGroupId}
onChange={(event) =>
setBudgetReleaseForm((current) => ({
...current,
workingGroupId: event.target.value,
budgetId: ""
}))
}
required
fullWidth
disabled={visibleGroups.length === 0}
helperText={visibleGroups.length === 0 ? "Lege zuerst eine AG an." : "Wähle die AG mit dem betroffenen Budget."}
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
<TextField
select
label="Budget"
value={budgetReleaseForm.budgetId}
onChange={(event) =>
setBudgetReleaseForm((current) => ({
...current,
budgetId: event.target.value
}))
}
required
fullWidth
disabled={selectedBudgetReleaseOptions.length === 0}
helperText={
selectedBudgetReleaseOptions.length === 0
? "In dieser AG gibt es noch kein Budget."
: "Die gestrichelte Linie im Budget zeigt die gesamte Mittelübergabe inklusive bezahlter Posten."
}
>
{selectedBudgetReleaseOptions.map((budget) => (
<MenuItem key={budget.id} value={budget.id}>
{budget.name}
</MenuItem>
))}
</TextField>
<TextField
label="Zusätzlich bereits übergeben in EUR"
type="number"
inputProps={{ min: 0, step: 0.01, max: selectedBudgetReleaseBudget?.totalBudget ?? undefined }}
value={budgetReleaseForm.releasedAmount}
onChange={(event) =>
setBudgetReleaseForm((current) => ({
...current,
releasedAmount: event.target.value
}))
}
required
fullWidth
disabled={!selectedBudgetReleaseBudget}
helperText={
selectedBudgetReleaseBudget
? `Automatisch \u00fcber Bezahlt: ${currencyFormatter.format(selectedBudgetReleasePaidAmount)} | Zus\u00e4tzlich erfasst: ${currencyFormatter.format(selectedBudgetReleaseBudget.releasedAmount)}`
: "Wähle zuerst ein Budget aus."
}
/>
<Button type="submit" variant="outlined" disabled={busy || !selectedBudgetReleaseBudget}>
Betrag speichern
</Button>
</Stack>
</Box>
</Stack>
</CardContent>
</Card>
) : null}
{canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? (
<Card sx={islandCardSx}> <Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}> <CardContent sx={{ p: 3 }}>

View File

@@ -27,11 +27,14 @@ export function snapshotAppSettings(settings: Pick<AppSettings, "id" | "approval
}; };
} }
export function snapshotBudget(budget: Pick<Budget, "id" | "name" | "totalBudget" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">) { export function snapshotBudget(
budget: Pick<Budget, "id" | "name" | "totalBudget" | "releasedAmount" | "colorCode" | "workingGroupId" | "periodId" | "createdAt">
) {
return { return {
id: budget.id, id: budget.id,
name: budget.name, name: budget.name,
totalBudget: Number(budget.totalBudget), totalBudget: Number(budget.totalBudget),
releasedAmount: Number(budget.releasedAmount),
colorCode: budget.colorCode, colorCode: budget.colorCode,
workingGroupId: budget.workingGroupId, workingGroupId: budget.workingGroupId,
periodId: budget.periodId, periodId: budget.periodId,

View File

@@ -62,6 +62,7 @@ export type DashboardBudget = {
id: string; id: string;
name: string; name: string;
totalBudget: number; totalBudget: number;
releasedAmount: number;
colorCode: string; colorCode: string;
periodId: string; periodId: string;
expenses: DashboardExpense[]; expenses: DashboardExpense[];