Compare commits
4 Commits
dbb60cac4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e5ac7730d | |||
| 0d72cfb144 | |||
| 0bdb6f553b | |||
| f525279f2b |
22
public/sw.js
22
public/sw.js
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,9 @@ export function BudgetColumn({
|
||||
}));
|
||||
}
|
||||
|
||||
const selectedBudget = group.budgets.find((budget) => budget.id === selectedBudgetId) ?? group.budgets[0] ?? null;
|
||||
const visibleBudgets = isCompactLayout && selectedBudget ? [selectedBudget] : group.budgets;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
@@ -413,6 +434,22 @@ 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
|
||||
>
|
||||
{group.budgets.map((budget) => (
|
||||
<MenuItem key={budget.id} value={budget.id}>
|
||||
{budget.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@@ -420,11 +457,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 +481,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"
|
||||
}}
|
||||
|
||||
@@ -39,7 +39,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";
|
||||
@@ -343,6 +343,7 @@ export function DashboardShell({
|
||||
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,6 +360,7 @@ 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);
|
||||
useEffect(() => {
|
||||
setSelectedCurrentPeriodId(currentPeriodId);
|
||||
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
||||
@@ -379,25 +381,102 @@ export function DashboardShell({
|
||||
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) {
|
||||
@@ -2448,7 +2527,10 @@ 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
|
||||
>
|
||||
{visibleGroups.map((group) => (
|
||||
@@ -2481,6 +2563,7 @@ export function DashboardShell({
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||
focusBudgetId={focusedBudgetId}
|
||||
onApprove={handleApprove}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
onDocument={handleDocument}
|
||||
@@ -2527,6 +2610,7 @@ export function DashboardShell({
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||
focusBudgetId={focusedBudgetId}
|
||||
onApprove={handleApprove}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
onDocument={handleDocument}
|
||||
|
||||
@@ -8,11 +8,13 @@ export function ServiceWorkerRegistration() {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => registration.update().catch(() => null))
|
||||
.catch(() => {
|
||||
// Registrierung darf die App nicht blockieren.
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user