Compare commits

..

7 Commits

Author SHA1 Message Date
jan
c93616f09e Freigaberollen in Verwaltung verschieben
All checks were successful
CI / Build and Deploy (push) Successful in 3m8s
2026-05-08 17:26:49 +02:00
jan
57d1e9247b Dashboard Auto Refresh ergaenzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m51s
2026-05-07 11:55:35 +02:00
jan
208520cff8 Mobile Dropdowns und Aktionen vereinheitlichen
All checks were successful
CI / Build and Deploy (push) Successful in 2m17s
2026-05-06 21:27:40 +02:00
jan
3e5ac7730d Web Push Zustand und Deep Links stabilisieren
All checks were successful
CI / Build and Deploy (push) Successful in 2m59s
2026-05-06 21:13:54 +02:00
jan
0d72cfb144 Mobile Budgetauswahl ergaenzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m22s
2026-05-06 18:16:41 +02:00
jan
0bdb6f553b TypeScript baseUrl Deprecation beheben
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
2026-05-06 10:54:48 +02:00
jan
f525279f2b Shared Drive Uploads unterstuetzen 2026-05-06 10:43:49 +02:00
7 changed files with 362 additions and 72 deletions

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "rave-budget-control-v1";
const CACHE_NAME = "rave-budget-control-v2";
const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"];
self.addEventListener("install", (event) => {
@@ -14,7 +14,7 @@ self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)))
)
).then(() => self.clients.claim())
);
});
@@ -66,10 +66,24 @@ self.addEventListener("notificationclick", (event) => {
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
for (const client of clientList) {
const sameOriginClients = clientList.filter((client) => {
const clientUrl = new URL(client.url);
return clientUrl.origin === targetUrl.origin;
});
if (clientUrl.origin === targetUrl.origin && "focus" in client) {
const exactClient = sameOriginClients.find((client) => new URL(client.url).href === targetUrl.href);
const targetClient = exactClient ?? sameOriginClients[0];
if (targetClient && "focus" in targetClient) {
if ("navigate" in targetClient && new URL(targetClient.url).href !== targetUrl.href) {
await targetClient.navigate(targetUrl.href).catch(() => null);
}
return targetClient.focus();
}
for (const client of sameOriginClients) {
if ("focus" in client) {
if ("navigate" in client) {
await client.navigate(targetUrl.href).catch(() => null);
}

View File

@@ -5,7 +5,9 @@ export default function manifest(): MetadataRoute.Manifest {
name: "RFP Finanz\u00fcbersicht",
short_name: "RFP Finanzen",
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
id: "/",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#F5F1E8",
theme_color: "#3B5AE0",

View File

@@ -22,9 +22,11 @@ import {
Divider,
IconButton,
Link,
MenuItem,
Stack,
TextField,
Typography
Typography,
useMediaQuery
} from "@mui/material";
import { alpha, useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
@@ -48,6 +50,7 @@ type BudgetColumnProps = {
busy: boolean;
approvalThreshold: number;
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
focusBudgetId?: string | null;
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
@@ -141,6 +144,7 @@ export function BudgetColumn({
busy,
approvalThreshold,
requiredApprovalTypes,
focusBudgetId,
onApprove,
onMarkPaid,
onDocument,
@@ -153,10 +157,12 @@ export function BudgetColumn({
}: BudgetColumnProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const isCompactLayout = useMediaQuery(theme.breakpoints.down("lg"));
const [budgetDrafts, setBudgetDrafts] = useState<Record<string, BudgetDraft>>({});
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupDraftName, setGroupDraftName] = useState(group.name);
const [selectedBudgetId, setSelectedBudgetId] = useState(group.budgets[0]?.id ?? "");
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
@@ -183,6 +189,18 @@ export function BudgetColumn({
setGroupDraftName(group.name);
}, [group.name]);
useEffect(() => {
if (!group.budgets.some((budget) => budget.id === selectedBudgetId)) {
setSelectedBudgetId(group.budgets[0]?.id ?? "");
}
}, [group.budgets, selectedBudgetId]);
useEffect(() => {
if (focusBudgetId && group.budgets.some((budget) => budget.id === focusBudgetId)) {
setSelectedBudgetId(focusBudgetId);
}
}, [focusBudgetId, group.budgets]);
const approvedSpend = useMemo(
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
[group.budgets]
@@ -251,6 +269,34 @@ export function BudgetColumn({
}));
}
const selectedBudget = group.budgets.find((budget) => budget.id === selectedBudgetId) ?? group.budgets[0] ?? null;
const visibleBudgets = isCompactLayout && selectedBudget ? [selectedBudget] : group.budgets;
const mobilePrimarySelectSx = {
width: "100%",
"& .MuiOutlinedInput-root": {
minHeight: 64,
borderRadius: "28px",
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.72 : 0.96),
"& fieldset": {
borderColor: alpha(theme.palette.primary.main, isDark ? 0.54 : 0.38)
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.72)
},
"&.Mui-focused fieldset": {
borderWidth: 2,
borderColor: theme.palette.primary.main
}
},
"& .MuiInputLabel-root": {
color: theme.palette.text.secondary
},
"& .MuiInputBase-input": {
fontSize: "1.08rem",
fontWeight: 600
}
} as const;
return (
<Card
sx={{
@@ -413,6 +459,23 @@ export function BudgetColumn({
</Box>
) : null}
{isCompactLayout && group.budgets.length > 1 ? (
<TextField
select
label="Budget auswählen"
value={selectedBudget?.id ?? ""}
onChange={(event) => setSelectedBudgetId(event.target.value)}
fullWidth
sx={mobilePrimarySelectSx}
>
{group.budgets.map((budget) => (
<MenuItem key={budget.id} value={budget.id}>
{budget.name}
</MenuItem>
))}
</TextField>
) : null}
<Box
sx={{
display: "flex",
@@ -420,11 +483,11 @@ export function BudgetColumn({
overflow: "visible",
pb: 0,
alignItems: "stretch",
width: { md: desktopBudgetListWidth },
minWidth: { md: desktopBudgetListWidth }
width: isCompactLayout ? "100%" : desktopBudgetListWidth,
minWidth: isCompactLayout ? 0 : desktopBudgetListWidth
}}
>
{group.budgets.map((budget) => {
{visibleBudgets.map((budget) => {
const draft = getDraft(budget);
const isEditing = editingBudgetId === budget.id;
const budgetApproved = getApprovedSpend(budget.expenses);
@@ -444,9 +507,9 @@ export function BudgetColumn({
<Box
key={budget.id}
sx={{
minWidth: { xs: 280, sm: 312, md: budgetCardWidth },
width: { xs: "84vw", sm: 312, md: budgetCardWidth },
maxWidth: { xs: 360, md: budgetCardWidth },
minWidth: isCompactLayout ? 0 : budgetCardWidth,
width: isCompactLayout ? "100%" : budgetCardWidth,
maxWidth: isCompactLayout ? "100%" : budgetCardWidth,
flex: "0 0 auto",
scrollSnapAlign: "start"
}}

View File

@@ -18,14 +18,12 @@ import {
Button,
Card,
CardContent,
Checkbox,
Chip,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Stack,
@@ -39,7 +37,7 @@ import { alpha, useTheme } from "@mui/material/styles";
import { signOut } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import type { FormEvent } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { BudgetColumn } from "@/components/dashboard/budget-column";
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
@@ -175,6 +173,17 @@ function sortManagedUsersList(users: DashboardManagedUser[]) {
type MobileSection = "overview" | "actions";
type MobileAction =
| "expense"
| "budgetRelease"
| "workingGroup"
| "budget"
| "periods"
| "backup"
| "userCreate"
| "approvalThreshold"
| "users"
| "logs";
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
@@ -269,6 +278,16 @@ function urlBase64ToUint8Array(value: string) {
return Uint8Array.from([...rawData], (character) => character.charCodeAt(0));
}
function hasFocusedEditableElement() {
const activeElement = document.activeElement;
if (!activeElement || activeElement === document.body) {
return false;
}
return Boolean(activeElement.closest('input, textarea, select, [contenteditable="true"]'));
}
export function DashboardShell({
viewer,
workingGroups,
@@ -300,6 +319,26 @@ export function DashboardShell({
...(canManageAccounts ? [{ value: "users" as const, label: "Nutzerverwaltung" }] : []),
...(canManageAccounts ? [{ value: "logs" as const, label: "Backup & Log" }] : [])
];
const mobileActions = [
{ value: "expense" as const, label: "Neue Ausgabe" },
...(canManagePeriods ? [{ value: "budgetRelease" as const, label: "Bereits an AG übergeben" }] : []),
...(canManageBudgets(viewer.role)
? [
{ value: "workingGroup" as const, label: "AG anlegen" },
{ value: "budget" as const, label: "Budget anlegen" }
]
: []),
...(canManagePeriods ? [{ value: "periods" as const, label: "Zeitraum" }] : []),
...(canManageAccounts
? [
{ value: "backup" as const, label: "CSV-Backup" },
{ value: "userCreate" as const, label: "Nutzer anlegen" },
{ value: "approvalThreshold" as const, label: "Freigabe-Schwelle" },
{ value: "users" as const, label: "Nutzer verwalten" },
{ value: "logs" as const, label: "Änderungsverlauf" }
]
: [])
];
const showDesktopSectionTabs = !isCompactLayout && desktopSections.length > 1;
const defaultEditableGroup =
@@ -340,9 +379,11 @@ export function DashboardShell({
const [message, setMessage] = useState<DashboardMessage | null>(null);
const [busy, setBusy] = useState(false);
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
const [selectedMobileAction, setSelectedMobileAction] = useState<MobileAction>("expense");
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
const [backupFile, setBackupFile] = useState<File | null>(null);
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
@@ -359,11 +400,50 @@ export function DashboardShell({
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
const handledDeepLinkRef = useRef<string | null>(null);
const busyRef = useRef(busy);
useEffect(() => {
setSelectedCurrentPeriodId(currentPeriodId);
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
}, [currentPeriod, currentPeriodId]);
useEffect(() => {
busyRef.current = busy;
}, [busy]);
useEffect(() => {
function refreshIfSafe() {
if (document.visibilityState !== "visible" || busyRef.current || hasFocusedEditableElement()) {
return;
}
router.refresh();
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
refreshIfSafe();
}
}
function handlePageShow() {
refreshIfSafe();
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", refreshIfSafe);
window.addEventListener("pageshow", handlePageShow);
const intervalId = window.setInterval(refreshIfSafe, 45_000);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", refreshIfSafe);
window.removeEventListener("pageshow", handlePageShow);
};
}, [router]);
useEffect(() => {
const selectedPeriod = accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
setPeriodEditForm(getPeriodEditDraft(selectedPeriod));
@@ -375,29 +455,112 @@ export function DashboardShell({
}
}, [desktopSection, desktopSections]);
useEffect(() => {
if (!mobileActions.some((action) => action.value === selectedMobileAction)) {
setSelectedMobileAction(mobileActions[0]?.value ?? "expense");
}
}, [mobileActions, selectedMobileAction]);
useEffect(() => {
const directGroupId = searchParams.get("group");
const budgetId = searchParams.get("budget");
const expenseId = searchParams.get("expense");
const deepLinkKey = [directGroupId, budgetId, expenseId].filter(Boolean).join(":");
if (!deepLinkKey) {
handledDeepLinkRef.current = null;
return;
}
if (handledDeepLinkRef.current === deepLinkKey) {
return;
}
const budgetGroup = visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId));
const expenseBudget = visibleGroups
.flatMap((group) => group.budgets)
.find((budget) => expenseId && budget.expenses.some((expense) => expense.id === expenseId));
const targetGroupId =
(directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ??
visibleGroups.find((group) => budgetId && group.budgets.some((budget) => budget.id === budgetId))?.id ??
budgetGroup?.id ??
visibleGroups.find((group) =>
expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId))
)?.id ??
null;
const targetBudgetId = budgetId ?? expenseBudget?.id ?? null;
if (targetGroupId) {
handledDeepLinkRef.current = deepLinkKey;
setSelectedMobileGroupId(targetGroupId);
setFocusedBudgetId(targetBudgetId);
setMobileSection("overview");
setDesktopSection("overview");
return;
router.replace("/", { scroll: false });
}
}, [router, searchParams, visibleGroups]);
useEffect(() => {
if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) {
setSelectedMobileGroupId(visibleGroups[0]?.id ?? "");
}
}, [searchParams, selectedMobileGroupId, visibleGroups]);
}, [selectedMobileGroupId, visibleGroups]);
useEffect(() => {
let cancelled = false;
async function syncExistingPushSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
if (!cancelled) {
setPushStatus("unsupported");
}
return;
}
if (Notification.permission === "denied") {
if (!cancelled) {
setPushStatus("blocked");
}
return;
}
if (Notification.permission !== "granted") {
if (!cancelled) {
setPushStatus("idle");
}
return;
}
const registration = await navigator.serviceWorker.ready.catch(() => null);
const subscription = await registration?.pushManager.getSubscription().catch(() => null);
if (cancelled) {
return;
}
if (!subscription) {
setPushStatus("idle");
return;
}
setPushStatus("enabled");
await fetch("/api/push-subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(subscription.toJSON())
}).catch(() => {
// Der Browser hat noch eine Subscription; ein temporärer Sync-Fehler soll den UI-Status nicht zurücksetzen.
});
}
syncExistingPushSubscription();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (visibleGroups.length === 0) {
@@ -1095,6 +1258,11 @@ export function DashboardShell({
return;
}
if (orgaSettingsDraft.requiredApprovalTypes.length === 0) {
setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle auswählen." });
return;
}
await runAction(async () => {
await parseResponse(
await fetch("/api/settings", {
@@ -1103,7 +1271,8 @@ export function DashboardShell({
"Content-Type": "application/json"
},
body: JSON.stringify({
approvalThreshold: nextThreshold
approvalThreshold: nextThreshold,
requiredApprovalTypes: orgaSettingsDraft.requiredApprovalTypes
})
})
);
@@ -1317,7 +1486,7 @@ export function DashboardShell({
value: ApprovalPermissionValue[],
onToggle: (approvalType: ApprovalPermissionValue) => void,
helperText: string,
availableApprovalTypes: ApprovalPermissionValue[]
availableApprovalTypes: readonly ApprovalPermissionValue[]
) {
return (
<Stack spacing={1}>
@@ -1371,6 +1540,32 @@ export function DashboardShell({
overflow: "hidden"
};
const mobilePrimarySelectSx = {
width: "100%",
"& .MuiOutlinedInput-root": {
minHeight: 64,
borderRadius: "28px",
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.72 : 0.96),
"& fieldset": {
borderColor: alpha(theme.palette.primary.main, isDark ? 0.54 : 0.38)
},
"&:hover fieldset": {
borderColor: alpha(theme.palette.primary.main, 0.72)
},
"&.Mui-focused fieldset": {
borderWidth: 2,
borderColor: theme.palette.primary.main
}
},
"& .MuiInputLabel-root": {
color: theme.palette.text.secondary
},
"& .MuiInputBase-input": {
fontSize: "1.08rem",
fontWeight: 600
}
} as const;
const periodManagementPanel = canManagePeriods ? (
<Stack spacing={2}>
<Box>
@@ -1576,7 +1771,24 @@ export function DashboardShell({
: null)
}}
>
{isCompactLayout || desktopSection === "overview" ? (
{isCompactLayout ? (
<TextField
select
label="Aktion auswählen"
value={selectedMobileAction}
onChange={(event) => setSelectedMobileAction(event.target.value as MobileAction)}
fullWidth
sx={mobilePrimarySelectSx}
>
{mobileActions.map((action) => (
<MenuItem key={action.value} value={action.value}>
{action.label}
</MenuItem>
))}
</TextField>
) : null}
{(isCompactLayout ? selectedMobileAction === "expense" : desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
@@ -1698,7 +1910,7 @@ export function DashboardShell({
</Card>
) : null}
{canManagePeriods && (isCompactLayout || desktopSection === "overview") ? (
{canManagePeriods && (isCompactLayout ? selectedMobileAction === "budgetRelease" : desktopSection === "overview") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
@@ -1790,7 +2002,7 @@ export function DashboardShell({
</Card>
) : null}
{canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? (
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "workingGroup" : desktopSection === "budgetGroups") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
@@ -1823,7 +2035,7 @@ export function DashboardShell({
</Card>
) : null}
{canManageBudgets(viewer.role) && (isCompactLayout || desktopSection === "budgetGroups") ? (
{canManageBudgets(viewer.role) && (isCompactLayout ? selectedMobileAction === "budget" : desktopSection === "budgetGroups") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
@@ -1904,13 +2116,13 @@ export function DashboardShell({
</Card>
) : null}
{canManagePeriods && isCompactLayout ? (
{canManagePeriods && isCompactLayout && selectedMobileAction === "periods" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
</Card>
) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "logs") ? (
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "backup" : desktopSection === "logs") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
@@ -1959,8 +2171,12 @@ export function DashboardShell({
</Card>
) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
{canManageAccounts &&
(isCompactLayout
? selectedMobileAction === "userCreate" || selectedMobileAction === "approvalThreshold"
: desktopSection === "users") ? (
<Stack spacing={3}>
{!isCompactLayout || selectedMobileAction === "userCreate" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2.5}>
@@ -2062,7 +2278,9 @@ export function DashboardShell({
</Stack>
</CardContent>
</Card>
) : null}
{!isCompactLayout || selectedMobileAction === "approvalThreshold" ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
@@ -2083,15 +2301,26 @@ export function DashboardShell({
helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`}
fullWidth
/>
{renderApprovalPermissionSelector(
orgaSettingsDraft.requiredApprovalTypes,
(approvalType) =>
setOrgaSettingsDraft((current) => ({
...current,
requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType)
})),
"Diese Rollen müssen schwellenpflichtige Ausgaben bestätigen. AG-Mitglieder können nicht freigeben.",
APPROVAL_FLOW
)}
<Button type="button" variant="outlined" disabled={busy} onClick={handleSaveApprovalThreshold}>
Schwelle speichern
Schwelle und Freigaberollen speichern
</Button>
</Stack>
</CardContent>
</Card>
) : null}
</Stack>
) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "users" : desktopSection === "users") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
@@ -2333,7 +2562,7 @@ export function DashboardShell({
</CardContent>
</Card>
) : null}
{canManageAccounts && (isCompactLayout || desktopSection === "logs") ? (
{canManageAccounts && (isCompactLayout ? selectedMobileAction === "logs" : desktopSection === "logs") ? (
<Card sx={islandCardSx}>
<CardContent sx={{ p: 3 }}>
<Stack spacing={2}>
@@ -2448,8 +2677,12 @@ export function DashboardShell({
select
label="AG auswählen"
value={selectedMobileGroup?.id ?? ""}
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
onChange={(event) => {
setSelectedMobileGroupId(event.target.value);
setFocusedBudgetId(null);
}}
fullWidth
sx={mobilePrimarySelectSx}
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
@@ -2481,6 +2714,7 @@ export function DashboardShell({
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
@@ -2527,6 +2761,7 @@ export function DashboardShell({
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
focusBudgetId={focusedBudgetId}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
@@ -2690,37 +2925,10 @@ export function DashboardShell({
</Container>
</Box>
<Dialog open={isOrgaSettingsOpen} onClose={() => setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Zuständigkeiten & Benachrichtigungen</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ pt: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Freigaberollen
</Typography>
<Typography variant="body2" color="text.secondary">
Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen.
</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{APPROVAL_FLOW.map((approvalType) => (
<FormControlLabel
key={approvalType}
control={
<Checkbox
checked={orgaSettingsDraft.requiredApprovalTypes.includes(approvalType)}
onChange={() =>
setOrgaSettingsDraft((current) => ({
...current,
requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType)
}))
}
/>
}
label={approvalLabel(approvalType)}
/>
))}
</Stack>
</Box>
<Dialog open={isOrgaSettingsOpen} onClose={() => setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Zuständigkeiten & Benachrichtigungen</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ pt: 1 }}>
<TextField
select
label="Budgetfreigabe-Push"

View File

@@ -8,11 +8,13 @@ export function ServiceWorkerRegistration() {
return;
}
navigator.serviceWorker.register("/sw.js").catch(() => {
// Registrierung darf die App nicht blockieren.
});
navigator.serviceWorker
.register("/sw.js")
.then((registration) => registration.update().catch(() => null))
.catch(() => {
// Registrierung darf die App nicht blockieren.
});
}, []);
return null;
}

View File

@@ -224,7 +224,8 @@ export async function uploadExpenseProofToDrive(input: {
mimeType: input.mimeType,
body: Readable.from(input.buffer)
},
fields: "id, webViewLink"
fields: "id, name, webViewLink, webContentLink",
supportsAllDrives: true
}).catch((error: unknown) => {
throw mapDriveError(error, "DRIVE_UPLOAD_FAILED", "Google Drive konnte den Rechnungsbeleg nicht hochladen.", [
`Zielordner: ${config.folderId}`,
@@ -243,7 +244,8 @@ export async function uploadExpenseProofToDrive(input: {
requestBody: {
type: "anyone",
role: "reader"
}
},
supportsAllDrives: true
}).catch((error: unknown) => {
throw mapDriveError(error, "DRIVE_PERMISSION_FAILED", "Google Drive konnte den Freigabe-Link nicht erstellen.", [
`Die Datei wurde vermutlich bereits erstellt. Drive-Datei-ID: ${response.data.id}`
@@ -273,7 +275,8 @@ export async function runDriveDiagnostics() {
mimeType: "text/plain",
body: Readable.from(Buffer.from("RFP Finanzen Drive API Test\n", "utf8"))
},
fields: "id, webViewLink"
fields: "id, name, webViewLink, webContentLink",
supportsAllDrives: true
});
createdFileId = response.data.id ?? null;
@@ -282,7 +285,7 @@ export async function runDriveDiagnostics() {
throw new DriveIntegrationError("Google Drive hat für die Testdatei keine Datei-ID zurückgegeben.", "DRIVE_FILE_ID_MISSING");
}
await drive.files.delete({ fileId: createdFileId });
await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true });
return {
ok: true,
@@ -296,7 +299,7 @@ export async function runDriveDiagnostics() {
};
} catch (error) {
if (createdFileId) {
await drive.files.delete({ fileId: createdFileId }).catch((cleanupError: unknown) => {
await drive.files.delete({ fileId: createdFileId, supportsAllDrives: true }).catch((cleanupError: unknown) => {
throw new DriveIntegrationError(
"Drive-Test ist fehlgeschlagen und die temporäre Testdatei konnte nicht gelöscht werden.",
"DRIVE_DIAGNOSTIC_CLEANUP_FAILED",

View File

@@ -13,7 +13,6 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"plugins": [
{
"name": "next"
@@ -26,4 +25,3 @@
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}