Web Push Zustand und Deep Links stabilisieren
All checks were successful
CI / Build and Deploy (push) Successful in 2m59s
All checks were successful
CI / Build and Deploy (push) Successful in 2m59s
This commit is contained in:
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"];
|
const APP_SHELL = ["/", "/login", "/icon.svg", "/manifest.webmanifest"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
@@ -14,7 +14,7 @@ self.addEventListener("activate", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) =>
|
caches.keys().then((cacheNames) =>
|
||||||
Promise.all(cacheNames.filter((cacheName) => cacheName !== CACHE_NAME).map((cacheName) => caches.delete(cacheName)))
|
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(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: "window", includeUncontrolled: true }).then(async (clientList) => {
|
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);
|
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) {
|
if ("navigate" in client) {
|
||||||
await client.navigate(targetUrl.href).catch(() => null);
|
await client.navigate(targetUrl.href).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
name: "RFP Finanz\u00fcbersicht",
|
name: "RFP Finanz\u00fcbersicht",
|
||||||
short_name: "RFP Finanzen",
|
short_name: "RFP Finanzen",
|
||||||
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
|
description: "Budgetfreigaben und Finanzstatus f\u00fcr Vereins-AGs.",
|
||||||
|
id: "/",
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#F5F1E8",
|
background_color: "#F5F1E8",
|
||||||
theme_color: "#3B5AE0",
|
theme_color: "#3B5AE0",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type BudgetColumnProps = {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
approvalThreshold: number;
|
approvalThreshold: number;
|
||||||
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
|
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
|
||||||
|
focusBudgetId?: string | null;
|
||||||
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
|
||||||
onMarkPaid: (expenseId: string) => Promise<void>;
|
onMarkPaid: (expenseId: string) => Promise<void>;
|
||||||
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
|
||||||
@@ -143,6 +144,7 @@ export function BudgetColumn({
|
|||||||
busy,
|
busy,
|
||||||
approvalThreshold,
|
approvalThreshold,
|
||||||
requiredApprovalTypes,
|
requiredApprovalTypes,
|
||||||
|
focusBudgetId,
|
||||||
onApprove,
|
onApprove,
|
||||||
onMarkPaid,
|
onMarkPaid,
|
||||||
onDocument,
|
onDocument,
|
||||||
@@ -193,6 +195,12 @@ export function BudgetColumn({
|
|||||||
}
|
}
|
||||||
}, [group.budgets, selectedBudgetId]);
|
}, [group.budgets, selectedBudgetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusBudgetId && group.budgets.some((budget) => budget.id === focusBudgetId)) {
|
||||||
|
setSelectedBudgetId(focusBudgetId);
|
||||||
|
}
|
||||||
|
}, [focusBudgetId, group.budgets]);
|
||||||
|
|
||||||
const approvedSpend = useMemo(
|
const approvedSpend = useMemo(
|
||||||
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
|
() => group.budgets.reduce((sum, budget) => sum + getApprovedSpend(budget.expenses), 0),
|
||||||
[group.budgets]
|
[group.budgets]
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { alpha, useTheme } from "@mui/material/styles";
|
|||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import type { FormEvent } from "react";
|
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 { BudgetColumn } from "@/components/dashboard/budget-column";
|
||||||
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
|
||||||
@@ -343,6 +343,7 @@ export function DashboardShell({
|
|||||||
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
|
||||||
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
|
||||||
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(visibleGroups[0]?.id ?? "");
|
||||||
|
const [focusedBudgetId, setFocusedBudgetId] = useState<string | null>(null);
|
||||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||||
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
||||||
const [editingUserId, setEditingUserId] = 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 [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
||||||
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
|
||||||
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
|
||||||
|
const handledDeepLinkRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedCurrentPeriodId(currentPeriodId);
|
setSelectedCurrentPeriodId(currentPeriodId);
|
||||||
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
|
||||||
@@ -379,25 +381,102 @@ export function DashboardShell({
|
|||||||
const directGroupId = searchParams.get("group");
|
const directGroupId = searchParams.get("group");
|
||||||
const budgetId = searchParams.get("budget");
|
const budgetId = searchParams.get("budget");
|
||||||
const expenseId = searchParams.get("expense");
|
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 =
|
const targetGroupId =
|
||||||
(directGroupId && visibleGroups.some((group) => group.id === directGroupId) ? directGroupId : null) ??
|
(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) =>
|
visibleGroups.find((group) =>
|
||||||
expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId))
|
expenseId && group.budgets.some((budget) => budget.expenses.some((expense) => expense.id === expenseId))
|
||||||
)?.id ??
|
)?.id ??
|
||||||
null;
|
null;
|
||||||
|
const targetBudgetId = budgetId ?? expenseBudget?.id ?? null;
|
||||||
|
|
||||||
if (targetGroupId) {
|
if (targetGroupId) {
|
||||||
|
handledDeepLinkRef.current = deepLinkKey;
|
||||||
setSelectedMobileGroupId(targetGroupId);
|
setSelectedMobileGroupId(targetGroupId);
|
||||||
|
setFocusedBudgetId(targetBudgetId);
|
||||||
setMobileSection("overview");
|
setMobileSection("overview");
|
||||||
setDesktopSection("overview");
|
setDesktopSection("overview");
|
||||||
return;
|
router.replace("/", { scroll: false });
|
||||||
}
|
}
|
||||||
|
}, [router, searchParams, visibleGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) {
|
if (!visibleGroups.some((group) => group.id === selectedMobileGroupId)) {
|
||||||
setSelectedMobileGroupId(visibleGroups[0]?.id ?? "");
|
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(() => {
|
useEffect(() => {
|
||||||
if (visibleGroups.length === 0) {
|
if (visibleGroups.length === 0) {
|
||||||
@@ -2448,7 +2527,10 @@ export function DashboardShell({
|
|||||||
select
|
select
|
||||||
label="AG auswählen"
|
label="AG auswählen"
|
||||||
value={selectedMobileGroup?.id ?? ""}
|
value={selectedMobileGroup?.id ?? ""}
|
||||||
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setSelectedMobileGroupId(event.target.value);
|
||||||
|
setFocusedBudgetId(null);
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
@@ -2481,6 +2563,7 @@ export function DashboardShell({
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
requiredApprovalTypes={settings.requiredApprovalTypes}
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||||
|
focusBudgetId={focusedBudgetId}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
@@ -2527,6 +2610,7 @@ export function DashboardShell({
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
approvalThreshold={approvalThreshold}
|
approvalThreshold={approvalThreshold}
|
||||||
requiredApprovalTypes={settings.requiredApprovalTypes}
|
requiredApprovalTypes={settings.requiredApprovalTypes}
|
||||||
|
focusBudgetId={focusedBudgetId}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onMarkPaid={handleMarkPaid}
|
onMarkPaid={handleMarkPaid}
|
||||||
onDocument={handleDocument}
|
onDocument={handleDocument}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ export function ServiceWorkerRegistration() {
|
|||||||
return;
|
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.
|
// Registrierung darf die App nicht blockieren.
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user