e.stopPropagation()}
- >
-
- {/* Title */}
-
- {title}
-
+const ActionConfirmation = ({
+ isOpen,
+ onCloseDelete,
+ onConfirmDelete,
+ title,
+ subtitle = "Are you sure?",
+ boldSubtitle = "",
+ warningMessage = "This action cannot be undone.",
+ variant = "delete",
+}: {
+ isOpen: boolean;
+ onCloseDelete: () => void;
+ onConfirmDelete: () => void;
+ title: string;
+ subtitle: string;
+ boldSubtitle: string;
+ warningMessage: string;
+ variant?: ActionConfirmationVariant;
+}) => {
+ if (!isOpen) return null;
- {/* Message */}
-
- {subtitle + " "}
- {boldSubtitle}
- {"?"}
-
+ const styles =
+ variant === "create"
+ ? {
+ panel: "border-t-4 border-green bg-green-light/30",
+ stripe: "bg-green",
+ box: "bg-green-light",
+ Icon: FaCheckCircle,
+ iconClass: "text-green",
+ label: "Confirm",
+ labelClass: "text-green",
+ textClass: "text-green-dark",
+ cancelClass:
+ "text-grey-700 border-grey-500 hover:border-grey-600 hover:bg-grey-150 active:bg-grey-200",
+ }
+ : variant === "update"
+ ? {
+ panel: "border-t-4 border-grey-400 bg-grey-150",
+ stripe: "bg-grey-500",
+ box: "bg-grey-200",
+ Icon: FaInfoCircle,
+ iconClass: "text-grey-700",
+ label: "Review",
+ labelClass: "text-grey-800",
+ textClass: "text-grey-800",
+ cancelClass:
+ "text-grey-700 border-grey-500 hover:border-grey-600 hover:bg-grey-200 active:bg-grey-300",
+ }
+ : {
+ panel: "border-t-4 border-red bg-red-lightest/40",
+ stripe: "bg-red",
+ box: "bg-red-light",
+ Icon: IoIosWarning,
+ iconClass: "text-red",
+ label: "Warning",
+ labelClass: "text-red",
+ textClass: "text-red",
+ cancelClass:
+ "text-red border-red hover:border-red hover:bg-red-light active:bg-red",
+ };
-
+ const { Icon } = styles;
-
+ return (
+
+
e.stopPropagation()}
+ >
+
{title}
-
-
-
-
- {warningMessage}
-
+
+ {subtitle + " "}
+ {boldSubtitle}
+ {"?"}
+
+
+
-
-
- {/* Buttons */}
-
- {
+
+ {
onConfirmDelete();
onCloseDelete();
- }} className="border-grey-500" />
-
-
+ }}
+ className="border-grey-500"
+ />
-
- );
- };
+
+ );
+};
- export default ActionConfirmation;
\ No newline at end of file
+export default ActionConfirmation;
diff --git a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx
index 71b2ce43..73d42225 100644
--- a/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx
+++ b/frontend/src/main-page/cash-flow/components/CashAddRevenue.tsx
@@ -10,6 +10,7 @@ import CashRevenueInstallment, {
EditableInstallment,
} from "./CashRevenueInstallment";
import { createNewRevenue, isValidInstallment, toInstallment } from "../../cash-flow/processCashflowDataEditSave";
+import ActionConfirmation from "../../../components/ActionConfirmation";
type FieldErrors = {
type?: string;
@@ -46,6 +47,10 @@ export default function CashAddRevenue() {
const [errors, setErrors] = useState
({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [successMessage, setSuccessMessage] = useState(null);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [pendingRevenue, setPendingRevenue] = useState(
+ null,
+ );
const showSuccessMessage = (message: string) => {
setSuccessMessage(message);
@@ -120,12 +125,19 @@ export default function CashAddRevenue() {
setErrors({});
}
- const handleSubmit = async () => {
+ const requestSubmit = () => {
setSuccessMessage(null);
const payload = buildPayload();
if (!payload) {
return;
}
+ setPendingRevenue(payload);
+ setShowConfirmModal(true);
+ };
+
+ const handleConfirmedSubmit = async () => {
+ if (!pendingRevenue) return;
+ const payload = pendingRevenue;
setIsSubmitting(true);
setErrors((previous) => ({ ...previous, submit: undefined }));
@@ -192,6 +204,22 @@ export default function CashAddRevenue() {
return (
+
{
+ setShowConfirmModal(false);
+ setPendingRevenue(null);
+ }}
+ onConfirmDelete={() => {
+ void handleConfirmedSubmit();
+ setPendingRevenue(null);
+ }}
+ title="Create revenue source"
+ subtitle="Are you sure you want to add"
+ boldSubtitle={pendingRevenue?.name ?? ""}
+ warningMessage="This will create a new revenue line in your cash flow."
+ variant="create"
+ />
{"Add Revenue Source"}
@@ -297,7 +325,7 @@ export default function CashAddRevenue() {
/>
diff --git a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx
index eeb8c567..1e879d1b 100644
--- a/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx
+++ b/frontend/src/main-page/cash-flow/components/CashEditLineItem.tsx
@@ -69,6 +69,7 @@ export default function CashEditLineItem({
subtitle={"Are you sure you want to delete"}
boldSubtitle={sourceName}
warningMessage="If you delete this item, it will be permanently removed from the system."
+ variant="delete"
/>
);
diff --git a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx
index 72eeeefb..05af6821 100644
--- a/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx
+++ b/frontend/src/main-page/cash-flow/components/CashEditRevenue.tsx
@@ -5,9 +5,8 @@ import { Installment } from "../../../../../middle-layer/types/Installment";
import { RevenueType } from "../../../../../middle-layer/types/RevenueType";
import Button from "../../../components/Button";
import InputField from "../../../components/InputField";
-import {
- saveRevenueEdits,
-} from "../processCashflowDataEditSave";
+import { saveRevenueEdits } from "../processCashflowDataEditSave";
+import ActionConfirmation from "../../../components/ActionConfirmation";
import CashCategoryDropdown from "./CashCategoryDropdown";
import CashRevenueInstallment, {
EditableInstallment,
@@ -71,6 +70,10 @@ export default function CashEditRevenue({
);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showConfirmModal, setShowConfirmModal] = useState(false);
+ const [pendingRevenue, setPendingRevenue] = useState(
+ null,
+ );
const isValidInstallment = (installment: EditableInstallment) => {
if (installment.amount === null || installment.date === null) {
@@ -196,11 +199,18 @@ export default function CashEditRevenue({
)
: (singleInstallment.amount ?? 0);
- const handleSave = async () => {
+ const requestSave = () => {
const payload = buildPayload();
if (!payload) {
return;
}
+ setPendingRevenue(payload);
+ setShowConfirmModal(true);
+ };
+
+ const handleConfirmedSave = async () => {
+ if (!pendingRevenue) return;
+ const payload = pendingRevenue;
setIsSubmitting(true);
setErrors((previous) => ({ ...previous, submit: undefined }));
@@ -221,6 +231,22 @@ export default function CashEditRevenue({
return (
+
{
+ setShowConfirmModal(false);
+ setPendingRevenue(null);
+ }}
+ onConfirmDelete={() => {
+ void handleConfirmedSave();
+ setPendingRevenue(null);
+ }}
+ title="Update revenue source"
+ subtitle="Are you sure you want to save changes to"
+ boldSubtitle={pendingRevenue?.name ?? revenueItem.name}
+ warningMessage="This will update this revenue line in your cash flow."
+ variant="update"
+ />
diff --git a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx
index 0fa5f428..e84842f3 100644
--- a/frontend/src/main-page/grants/edit-grant/EditGrant.tsx
+++ b/frontend/src/main-page/grants/edit-grant/EditGrant.tsx
@@ -47,6 +47,7 @@ const EditGrant: React.FC<{
// State to track if form was submitted successfully
const [saving, setSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [showSaveModal, setShowSaveModal] = useState(false);
const [form, dispatch] = useReducer(reducer, {
organization: grantToEdit?.organization ?? "",
@@ -187,7 +188,7 @@ const EditGrant: React.FC<{
setShowSaveModal(true)}
disabled={saving}
/>
@@ -222,8 +223,29 @@ const EditGrant: React.FC<{
subtitle={"Are you sure you want to delete"}
boldSubtitle={form.organization}
warningMessage="If you delete this grant, it will be permanently removed from the system."
+ variant="delete"
/>
)}
+ setShowSaveModal(false)}
+ onConfirmDelete={() => {
+ handleSubmit();
+ }}
+ title={grantToEdit ? "Save Grant" : "Create Grant"}
+ subtitle={
+ grantToEdit
+ ? "Are you sure you want to save changes to"
+ : "Are you sure you want to create a grant for"
+ }
+ boldSubtitle={form.organization}
+ warningMessage={
+ grantToEdit
+ ? "Saving will update this grant's details in the system."
+ : "A new grant will be added to the system with these details."
+ }
+ variant={grantToEdit ? "update" : "create"}
+ />
{/* Error Popup */}
diff --git a/frontend/src/main-page/navbar/NavBar.tsx b/frontend/src/main-page/navbar/NavBar.tsx
index b7dab5a9..e73ae05d 100644
--- a/frontend/src/main-page/navbar/NavBar.tsx
+++ b/frontend/src/main-page/navbar/NavBar.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
clearAllFilters,
@@ -12,6 +13,7 @@ import NavTab, { NavTabProps } from "./NavTab.tsx";
import { faChartLine, faMoneyBill, faClipboardCheck } from "@fortawesome/free-solid-svg-icons";
import { NavBarBranding } from "../../translations/general.ts";
import { saveCashflowSettings } from "../cash-flow/processCashflowDataEditSave";
+import ActionConfirmation from "../../components/ActionConfirmation";
const tabs: NavTabProps[] = [
{ name: "Dashboard", linkTo: "/main/dashboard", icon: faChartLine },
@@ -25,19 +27,32 @@ const NavBar: React.FC = observer(() => {
const navigate = useNavigate();
const user = getAppStore().user;
const isAdmin = user?.position === UserStatus.Admin;
+ const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false);
- const handleLogout = async () => {
+ const performLogout = async () => {
const { cashflowSettings } = getAppStore();
if (cashflowSettings) {
- await saveCashflowSettings(cashflowSettings);
- }
+ await saveCashflowSettings(cashflowSettings);
+ }
logoutUser();
clearAllFilters();
navigate("/login");
};
-
+
return (
+ setSignOutConfirmOpen(false)}
+ onConfirmDelete={() => {
+ void performLogout();
+ }}
+ title="Sign out"
+ subtitle="Are you sure you want to"
+ boldSubtitle="sign out"
+ warningMessage="Your cash flow settings will be saved to the server, then you will be logged out."
+ variant="update"
+ />
{/* Logo at top */}
@@ -90,7 +105,8 @@ const NavBar: React.FC = observer(() => {
icon={faGear}
/>
setSignOutConfirmOpen(true)}
className="flex items-center gap-3 w-[85%] pl-8 pr-4 py-3 rounded-r-full transition-colors hover:bg-grey-500 hover:text-white text-left border-none font-medium"
>
diff --git a/frontend/src/main-page/notifications/GrantNotification.tsx b/frontend/src/main-page/notifications/GrantNotification.tsx
index a17bb2a6..6ff6f18a 100644
--- a/frontend/src/main-page/notifications/GrantNotification.tsx
+++ b/frontend/src/main-page/notifications/GrantNotification.tsx
@@ -4,7 +4,7 @@ interface GrantNotificationProps {
notificationId: string;
message: string;
alertTime: string;
- onDelete: (notificationId: string) => void;
+ onRequestDelete: (notificationId: string) => void;
avatarUrl: string | null;
firstName: string;
lastName: string;
@@ -23,7 +23,7 @@ const GrantNotification: React.FC = ({
notificationId,
message,
alertTime,
- onDelete,
+ onRequestDelete,
avatarUrl,
firstName,
lastName,
@@ -49,7 +49,7 @@ const GrantNotification: React.FC = ({
onDelete(notificationId)}
+ onClick={() => onRequestDelete(notificationId)}
/>
);
diff --git a/frontend/src/main-page/notifications/NotificationPopup.tsx b/frontend/src/main-page/notifications/NotificationPopup.tsx
index 85957248..3e17ec7a 100644
--- a/frontend/src/main-page/notifications/NotificationPopup.tsx
+++ b/frontend/src/main-page/notifications/NotificationPopup.tsx
@@ -1,107 +1,159 @@
-import { createPortal } from 'react-dom';
+import { createPortal } from "react-dom";
+import { useState } from "react";
import GrantNotification from "./GrantNotification";
import { FaTrashAlt } from "react-icons/fa";
import { api } from "../../api";
import { setNotifications as setNotificationsAction } from "../../external/bcanSatchel/actions";
import { Notification } from "../../../../middle-layer/types/Notification";
import { getAppStore } from "../../external/bcanSatchel/store";
-import { observer } from 'mobx-react-lite';
+import { observer } from "mobx-react-lite";
+import ActionConfirmation from "../../components/ActionConfirmation";
+
+type ConfirmState =
+ | { kind: "none" }
+ | { kind: "one"; id: string; message: string }
+ | { kind: "all" };
interface NotificationPopupProps {
- setOpenModal: (open: boolean) => void;
+ setOpenModal: (open: boolean) => void;
}
-const NotificationPopup: React.FC = observer(({
- setOpenModal
-}) => {
+const NotificationPopup: React.FC = observer(
+ ({ setOpenModal }) => {
const store = getAppStore();
const liveNotifications: Notification[] = store.notifications ?? [];
const user = store.user;
+ const [confirm, setConfirm] = useState({ kind: "none" });
const handleDelete = async (notificationId: string) => {
- try {
- const response = await api(
- `/notifications/${notificationId}`,
- {
- method: "DELETE",
- }
- );
+ try {
+ const response = await api(`/notifications/${notificationId}`, {
+ method: "DELETE",
+ });
if (!response.ok) {
- console.error("Failed to delete notification:", response.statusText);
- return;
+ console.error("Failed to delete notification:", response.statusText);
+ return;
}
-
const fetchResponse = await api(
- `/notifications/user/${store.user?.email}/current`,
- {
- method: "GET",
- }
+ `/notifications/user/${store.user?.email}/current`,
+ {
+ method: "GET",
+ },
);
- if (fetchResponse.ok) {
- const updatedNotifications = await fetchResponse.json();
- setNotificationsAction(updatedNotifications);
- }
- }
- catch (error) {
- console.error("Error deleting notification:", error);
+ if (fetchResponse.ok) {
+ const updatedNotifications = await fetchResponse.json();
+ setNotificationsAction(updatedNotifications);
}
+ } catch (error) {
+ console.error("Error deleting notification:", error);
+ }
};
const handleDeleteAll = async () => {
- try {
- await Promise.allSettled(
- liveNotifications.map((n) =>
- api(`/notifications/${n.notificationId}`, { method: "DELETE" })
- )
- );
- setNotificationsAction([]);
- } catch (error) {
- console.error("Error deleting all notifications:", error);
- }
+ try {
+ await Promise.allSettled(
+ liveNotifications.map((n) =>
+ api(`/notifications/${n.notificationId}`, { method: "DELETE" }),
+ ),
+ );
+ setNotificationsAction([]);
+ } catch (error) {
+ console.error("Error deleting all notifications:", error);
+ }
};
+ const confirmOpen = confirm.kind !== "none";
return createPortal(
+ <>
+ setConfirm({ kind: "none" })}
+ onConfirmDelete={() => {
+ if (confirm.kind === "one") {
+ void handleDelete(confirm.id);
+ } else if (confirm.kind === "all") {
+ void handleDeleteAll();
+ }
+ }}
+ title={
+ confirm.kind === "all"
+ ? "Delete all notifications"
+ : "Delete notification"
+ }
+ subtitle="Are you sure you want to delete"
+ boldSubtitle={
+ confirm.kind === "all"
+ ? "all notifications"
+ : confirm.kind === "one"
+ ? confirm.message.length > 56
+ ? `${confirm.message.slice(0, 56)}…`
+ : confirm.message
+ : ""
+ }
+ warningMessage={
+ confirm.kind === "all"
+ ? "Every notification in your list will be permanently removed."
+ : "This notification will be permanently removed."
+ }
+ variant="delete"
+ />
setOpenModal(false)}>
e.stopPropagation()}>
-
Your Notifications
-
-
-
- Delete All
-
-
+
+ Your Notifications
+
+
+ {
+ if (liveNotifications.length === 0) return;
+ setConfirm({ kind: "all" });
+ }}
+ >
+
+ Delete All
+
+
- {liveNotifications && liveNotifications.length > 0 ? (
- liveNotifications.map((n) => (
-
- ))
- ) : (
-
No new notifications
- )}
+ {liveNotifications && liveNotifications.length > 0 ? (
+ liveNotifications.map((n) => (
+
+ setConfirm({
+ kind: "one",
+ id,
+ message: n.message,
+ })
+ }
+ avatarUrl={user?.profilePicUrl ?? null}
+ firstName={user?.firstName ?? ""}
+ lastName={user?.lastName ?? ""}
+ />
+ ))
+ ) : (
+
+ No new notifications
+
+ )}
+
- ,
- document.body
+ >,
+ document.body,
);
-});
+ },
+);
-export default NotificationPopup;
\ No newline at end of file
+export default NotificationPopup;
diff --git a/frontend/src/main-page/settings/ChangePasswordModal.tsx b/frontend/src/main-page/settings/ChangePasswordModal.tsx
index 0d9d58d3..a5e6e7da 100644
--- a/frontend/src/main-page/settings/ChangePasswordModal.tsx
+++ b/frontend/src/main-page/settings/ChangePasswordModal.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import {
@@ -6,6 +6,7 @@ import {
PasswordRequirements,
isPasswordValid,
} from "../../sign-up";
+import ActionConfirmation from "../../components/ActionConfirmation";
import Button from "../../components/Button";
export type ChangePasswordFormValues = {
@@ -29,6 +30,11 @@ export default function ChangePasswordModal({
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [reEnterPassword, setReEnterPassword] = useState("");
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ useEffect(() => {
+ if (!isOpen) setShowConfirm(false);
+ }, [isOpen]);
if (!isOpen) return null;
@@ -47,11 +53,16 @@ export default function ChangePasswordModal({
setCurrentPassword("");
setNewPassword("");
setReEnterPassword("");
+ setShowConfirm(false);
onClose();
};
- const handleSave = () => {
+ const requestSave = () => {
if (!canSave) return;
+ setShowConfirm(true);
+ };
+
+ const handleConfirmedSave = () => {
onSubmit?.({
currentPassword: currentPassword.trim(),
newPassword,
@@ -65,6 +76,16 @@ export default function ChangePasswordModal({
aria-modal="true"
aria-labelledby="change-password-title"
>
+
setShowConfirm(false)}
+ onConfirmDelete={handleConfirmedSave}
+ title="Change password"
+ subtitle="Are you sure you want to change"
+ boldSubtitle="your password"
+ warningMessage="You will use your new password the next time you sign in."
+ variant="update"
+ />
diff --git a/frontend/src/main-page/settings/ProfilePictureModal.tsx b/frontend/src/main-page/settings/ProfilePictureModal.tsx
index 7ade673f..a80c8325 100644
--- a/frontend/src/main-page/settings/ProfilePictureModal.tsx
+++ b/frontend/src/main-page/settings/ProfilePictureModal.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
import Cropper, { Area } from "react-easy-crop";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
@@ -15,6 +15,7 @@ import { getAppStore } from "../../external/bcanSatchel/store";
import { updateUserProfile } from "../../external/bcanSatchel/actions";
import { setActiveUsers } from "../../external/bcanSatchel/actions";
import { User } from "../../../../middle-layer/types/User";
+import ActionConfirmation from "../../components/ActionConfirmation";
type ProfilePictureModalProps = {
isOpen: boolean;
@@ -36,9 +37,14 @@ export default function ProfilePictureModal({
const [uploadError, setUploadError] = useState