diff --git a/public/sw.js b/public/sw.js index 29153b0..39fc410 100644 --- a/public/sw.js +++ b/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); } diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 61720fd..67d49d8 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -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", diff --git a/src/components/dashboard/budget-column.tsx b/src/components/dashboard/budget-column.tsx index 9b1e1a1..d0c8b92 100644 --- a/src/components/dashboard/budget-column.tsx +++ b/src/components/dashboard/budget-column.tsx @@ -50,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; onMarkPaid: (expenseId: string) => Promise; onDocument: (expenseId: string, proofUrl?: string) => Promise; @@ -143,6 +144,7 @@ export function BudgetColumn({ busy, approvalThreshold, requiredApprovalTypes, + focusBudgetId, onApprove, onMarkPaid, onDocument, @@ -193,6 +195,12 @@ export function BudgetColumn({ } }, [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] diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 431346c..2eb723d 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -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("overview"); const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId); const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? ""); + const [focusedBudgetId, setFocusedBudgetId] = useState(null); const [backupFile, setBackupFile] = useState(null); const [editingPasswordUserId, setEditingPasswordUserId] = useState(null); const [editingUserId, setEditingUserId] = useState(null); @@ -359,6 +360,7 @@ export function DashboardShell({ const [periodForm, setPeriodForm] = useState(getSuggestedPeriodDraft(currentPeriod)); const [periodEditForm, setPeriodEditForm] = useState(getPeriodEditDraft(currentPeriod)); const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle"); + const handledDeepLinkRef = useRef(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} diff --git a/src/components/service-worker-registration.tsx b/src/components/service-worker-registration.tsx index 8f77ab5..5cacccc 100644 --- a/src/components/service-worker-registration.tsx +++ b/src/components/service-worker-registration.tsx @@ -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; } -