diff --git a/prisma/migrations/202604131955_budget_released_amount/migration.sql b/prisma/migrations/202604131955_budget_released_amount/migration.sql new file mode 100644 index 0000000..ded6589 --- /dev/null +++ b/prisma/migrations/202604131955_budget_released_amount/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "budgets" ADD COLUMN "released_amount" DECIMAL(10, 2) NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8b031f..7f45b58 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -88,6 +88,7 @@ model Budget { id String @id @default(cuid()) name String 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") workingGroupId String @map("working_group_id") periodId String @map("period_id") diff --git a/prisma/seed.ts b/prisma/seed.ts index 6499ed8..3a72a0d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -60,7 +60,8 @@ async function upsertBudget( periodId: string, name: string, totalBudget: number, - colorCode: string + colorCode: string, + releasedAmount = 0 ) { return prisma.budget.upsert({ where: { @@ -72,11 +73,13 @@ async function upsertBudget( }, update: { totalBudget, + releasedAmount, colorCode }, create: { name, totalBudget, + releasedAmount, colorCode, workingGroupId, periodId @@ -128,9 +131,9 @@ async function main() { const awareness = await upsertWorkingGroup("AG Awareness"); const technik = await upsertWorkingGroup("AG Technik"); - await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A"); - await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D"); - const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6"); + await upsertBudget(deko.id, currentPeriod.id, "Deko Hauptbudget", 0, "#FFB94A", 0); + await upsertBudget(awareness.id, currentPeriod.id, "Awareness Hauptbudget", 800, "#68A35D", 250); + const technikBudget = await upsertBudget(technik.id, currentPeriod.id, "Technik Infrastruktur", 1500, "#5677F6", 500); await upsertUser({ username: "vorstand-a", diff --git a/src/app/api/audit-logs/[id]/restore/route.ts b/src/app/api/audit-logs/[id]/restore/route.ts index 67dacc2..491d5a4 100644 --- a/src/app/api/audit-logs/[id]/restore/route.ts +++ b/src/app/api/audit-logs/[id]/restore/route.ts @@ -159,6 +159,7 @@ export async function POST(_: Request, { params }: Context) { data: { name: asString(previous.name, "Budgetname"), totalBudget: asNumber(previous.totalBudget, "Budgetbetrag"), + releasedAmount: asNumber(previous.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"), colorCode: asString(previous.colorCode, "Budgetfarbe") } }); @@ -173,6 +174,7 @@ export async function POST(_: Request, { params }: Context) { id: asString(deleted.id, "Budget-ID"), name: asString(deleted.name, "Budgetname"), totalBudget: asNumber(deleted.totalBudget, "Budgetbetrag"), + releasedAmount: asNumber(deleted.releasedAmount ?? 0, "Zus\u00e4tzliche Mittel\u00fcbergabe"), colorCode: asString(deleted.colorCode, "Budgetfarbe"), workingGroupId: asString(deleted.workingGroupId, "AG-ID"), periodId: asString(deleted.periodId, "Zeitraum-ID"), diff --git a/src/app/api/budgets/[id]/route.ts b/src/app/api/budgets/[id]/route.ts index 9bfe55d..fd9d6d7 100644 --- a/src/app/api/budgets/[id]/route.ts +++ b/src/app/api/budgets/[id]/route.ts @@ -8,11 +8,22 @@ import { canManageBudgets } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; -const updateBudgetSchema = z.object({ - name: z.string().trim().min(2).max(80), - totalBudget: z.coerce.number().min(0), - colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) -}); +const updateBudgetSchema = z + .object({ + name: z.string().trim().min(2).max(80), + 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 = { params: { @@ -43,16 +54,18 @@ export async function PATCH(request: Request, { params }: Context) { const parsed = updateBudgetSchema.safeParse(body); 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 { const previousBudget = budget; + const nextReleasedAmount = parsed.data.releasedAmount ?? Number(previousBudget.releasedAmount); const updatedBudget = await prisma.budget.update({ where: { id: params.id }, data: { name: parsed.data.name, totalBudget: parsed.data.totalBudget, + releasedAmount: nextReleasedAmount, colorCode: parsed.data.colorCode } }); @@ -66,6 +79,7 @@ export async function PATCH(request: Request, { params }: Context) { summary: `Budget ${updatedBudget.name} wurde aktualisiert.`, metadata: { totalBudget: parsed.data.totalBudget, + releasedAmount: nextReleasedAmount, colorCode: parsed.data.colorCode, rollback: { kind: "budget.update", @@ -99,7 +113,7 @@ export async function DELETE(_: Request, { params }: Context) { } 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({ diff --git a/src/app/api/budgets/route.ts b/src/app/api/budgets/route.ts index c79240e..928ffe7 100644 --- a/src/app/api/budgets/route.ts +++ b/src/app/api/budgets/route.ts @@ -7,13 +7,24 @@ import { canManageBudgets } from "@/lib/domain"; import prisma from "@/lib/prisma"; import { getCurrentViewer } from "@/lib/session"; -const budgetSchema = z.object({ - workingGroupId: z.string().trim().min(1), - periodId: z.string().trim().min(1), - name: z.string().trim().min(2).max(80), - totalBudget: z.coerce.number().min(0), - colorCode: z.string().regex(/^#([0-9a-fA-F]{6})$/) -}); +const budgetSchema = z + .object({ + workingGroupId: z.string().trim().min(1), + periodId: z.string().trim().min(1), + name: z.string().trim().min(2).max(80), + 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) { const viewer = await getCurrentViewer(); @@ -30,7 +41,10 @@ export async function POST(request: Request) { const parsed = budgetSchema.safeParse(body); 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({ @@ -63,6 +77,8 @@ export async function POST(request: Request) { } }); + const nextReleasedAmount = parsed.data.releasedAmount ?? Number(existingBudget?.releasedAmount ?? 0); + const budget = existingBudget ? await prisma.budget.update({ where: { @@ -70,6 +86,7 @@ export async function POST(request: Request) { }, data: { totalBudget: parsed.data.totalBudget, + releasedAmount: nextReleasedAmount, colorCode: parsed.data.colorCode } }) @@ -79,6 +96,7 @@ export async function POST(request: Request) { periodId: accountingPeriod.id, name: parsed.data.name, totalBudget: parsed.data.totalBudget, + releasedAmount: nextReleasedAmount, colorCode: parsed.data.colorCode } }); @@ -98,6 +116,7 @@ export async function POST(request: Request) { periodId: accountingPeriod.id, periodName: accountingPeriod.name, totalBudget: parsed.data.totalBudget, + releasedAmount: nextReleasedAmount, rollback: existingBudget ? { kind: "budget.update", diff --git a/src/app/api/export/csv/route.ts b/src/app/api/export/csv/route.ts index f1945bd..4615a57 100644 --- a/src/app/api/export/csv/route.ts +++ b/src/app/api/export/csv/route.ts @@ -33,6 +33,7 @@ const CSV_HEADERS = [ "description", "amount", "totalBudget", + "releasedAmount", "colorCode", "approvalStatus", "approvalType", @@ -198,6 +199,7 @@ export async function GET() { description: "", amount: "", totalBudget: "", + releasedAmount: "", colorCode: "", approvalStatus: "", approvalType: "", @@ -250,6 +252,7 @@ export async function GET() { description: "", amount: "", totalBudget: "", + releasedAmount: "", colorCode: "", approvalStatus: "", approvalType: "", @@ -302,6 +305,7 @@ export async function GET() { description: "", amount: "", 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: "", approvalStatus: "", approvalType: "", @@ -353,6 +357,7 @@ export async function GET() { description: "", amount: "", totalBudget: Number(budget.totalBudget).toFixed(2), + releasedAmount: Number(budget.releasedAmount).toFixed(2), colorCode: budget.colorCode, approvalStatus: "", approvalType: "", @@ -510,6 +515,7 @@ export async function GET() { description: "", amount: "", totalBudget: "", + releasedAmount: "", colorCode: "", approvalStatus: "", approvalType: "", diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 16dcfac..f7a047c 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -167,9 +167,14 @@ export async function POST(request: Request) { for (const row of budgetRows) { const totalBudget = toNumber(row.totalBudget); + const releasedAmount = toNumber(row.releasedAmount) ?? 0; 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({ @@ -177,6 +182,7 @@ export async function POST(request: Request) { id: row.id, name: row.budgetName, totalBudget, + releasedAmount, colorCode: row.colorCode, workingGroupId: row.workingGroupId, periodId: row.periodId, diff --git a/src/app/page.tsx b/src/app/page.tsx index 4e764dd..27c8c1c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -150,6 +150,7 @@ export default async function DashboardPage() { id: budget.id, name: budget.name, totalBudget: Number(budget.totalBudget), + releasedAmount: Number(budget.releasedAmount), colorCode: budget.colorCode, periodId: budget.periodId, expenses: budget.expenses.map((expense) => { diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index b845466..963a42f 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -128,6 +128,10 @@ function getPendingSpend(expenses: DashboardExpense[]) { return expenses.reduce((sum, expense) => sum + (expense.approvalStatus === "PENDING" ? expense.periodAmount : 0), 0); } +function getPaidSpend(expenses: DashboardExpense[]) { + return expenses.reduce((sum, expense) => sum + (expense.paidAt ? expense.periodAmount : 0), 0); +} + export function BudgetColumn({ group, viewer, @@ -182,6 +186,10 @@ export function BudgetColumn({ () => group.budgets.reduce((sum, budget) => sum + getPendingSpend(budget.expenses), 0), [group.budgets] ); + const releasedSpend = useMemo( + () => group.budgets.reduce((sum, budget) => sum + budget.releasedAmount + getPaidSpend(budget.expenses), 0), + [group.budgets] + ); const totalCommitted = approvedSpend + pendingSpend; const remainingBudget = group.totalBudget - totalCommitted; @@ -340,6 +348,12 @@ export function BudgetColumn({ color={remainingBudget < 0 ? "error" : "default"} sx={{ ...wrappingChipSx, width: "fit-content" }} /> + group.totalBudget ? "error" : "default"} + variant="outlined" + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> {group.budgets.length === 0 ? ( @@ -372,11 +386,15 @@ export function BudgetColumn({ const isEditing = editingBudgetId === budget.id; const budgetApproved = getApprovedSpend(budget.expenses); const budgetPending = getPendingSpend(budget.expenses); + const budgetReleasedByPayments = getPaidSpend(budget.expenses); + const budgetReleasedTotal = budget.releasedAmount + budgetReleasedByPayments; const budgetCommitted = budgetApproved + budgetPending; const budgetRemaining = budget.totalBudget - budgetCommitted; const 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; + const releasedPercent = + budget.totalBudget > 0 ? Math.min((budgetReleasedTotal / budget.totalBudget) * 100, 100) : 0; return ( + {budgetReleasedTotal > 0 ? ( + + ) : null} @@ -487,6 +518,15 @@ export function BudgetColumn({ color={budgetRemaining < 0 ? "error" : "default"} sx={{ ...wrappingChipSx, width: "fit-content" }} /> + budget.totalBudget ? "error" : "default"} + variant="outlined" + sx={{ ...wrappingChipSx, width: "fit-content" }} + /> + + {`Davon zusätzlich ohne Einzelposten erfasst: ${formatCurrency(budget.releasedAmount)}. "Bezahlt setzen" zählt automatisch mit.`} + {`Unter ${formatCurrency(approvalThreshold)} werden sofort freigegeben. Größere Ausgaben bleiben blass, bis alle drei Signaturen vorliegen.`} diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 87de43c..44f286f 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -77,6 +77,12 @@ type BudgetFormState = { colorCode: string; }; +type BudgetReleaseFormState = { + workingGroupId: string; + budgetId: string; + releasedAmount: string; +}; + type WorkingGroupFormState = { name: string; }; @@ -243,6 +249,11 @@ export function DashboardShell({ totalBudget: "1200", colorCode: "#FFB94A" }); + const [budgetReleaseForm, setBudgetReleaseForm] = useState({ + workingGroupId: visibleGroups[0]?.id ?? "", + budgetId: visibleGroups[0]?.budgets[0]?.id ?? "", + releasedAmount: (visibleGroups[0]?.budgets[0]?.releasedAmount ?? 0).toFixed(2) + }); const [workingGroupForm, setWorkingGroupForm] = useState({ name: "" }); @@ -310,6 +321,52 @@ export function DashboardShell({ } }, [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(() => { if (!message || message.type !== "success") { return; @@ -375,6 +432,18 @@ export function DashboardShell({ const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0]; const selectedBudgetWorkingGroup = 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 = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null; @@ -645,6 +714,44 @@ export function DashboardShell({ }, "Budget wurde aktualisiert."); } + async function handleSaveBudgetRelease(event: FormEvent) { + 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) { await runAction(async () => { await parseResponse( @@ -1274,6 +1381,98 @@ export function DashboardShell({ ) : null} + {canManagePeriods && (isCompactLayout || desktopSection === "overview") ? ( + + + + + + {"Bereits an AG übergeben"} + + + {"\"Bezahlt setzen\" z\u00e4hlt automatisch mit. Hier erg\u00e4nzt du nur zus\u00e4tzlich bereits \u00fcbergebenes Geld, das nicht \u00fcber eine einzelne Ausgabe l\u00e4uft."} + + + + + + + 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) => ( + + {group.name} + + ))} + + + 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) => ( + + {budget.name} + + ))} + + + 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." + } + /> + + + + + + + ) : null} + {canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? ( diff --git a/src/lib/audit-snapshots.ts b/src/lib/audit-snapshots.ts index d2e6bc2..9fa8e65 100644 --- a/src/lib/audit-snapshots.ts +++ b/src/lib/audit-snapshots.ts @@ -27,11 +27,14 @@ export function snapshotAppSettings(settings: Pick) { +export function snapshotBudget( + budget: Pick +) { return { id: budget.id, name: budget.name, totalBudget: Number(budget.totalBudget), + releasedAmount: Number(budget.releasedAmount), colorCode: budget.colorCode, workingGroupId: budget.workingGroupId, periodId: budget.periodId, diff --git a/src/lib/dashboard-types.ts b/src/lib/dashboard-types.ts index 213c411..b9c7965 100644 --- a/src/lib/dashboard-types.ts +++ b/src/lib/dashboard-types.ts @@ -62,6 +62,7 @@ export type DashboardBudget = { id: string; name: string; totalBudget: number; + releasedAmount: number; colorCode: string; periodId: string; expenses: DashboardExpense[];