diff --git a/frontend/src/components/ActionConfirmation.tsx b/frontend/src/components/ActionConfirmation.tsx index 872841c9..d8fb3fd5 100644 --- a/frontend/src/components/ActionConfirmation.tsx +++ b/frontend/src/components/ActionConfirmation.tsx @@ -1,81 +1,123 @@ -import Button from "../components/Button"; +import Button from "./Button"; import { IoIosWarning } from "react-icons/io"; +import { FaCheckCircle, FaInfoCircle } from "react-icons/fa"; -{/* The popup that appears on delete */} - const ActionConfirmation = ({ - isOpen, - onCloseDelete, - onConfirmDelete, - title, - subtitle = "Are you sure?", - boldSubtitle = "", - warningMessage = "This action cannot be undone." - }: { - isOpen: boolean; - onCloseDelete: () => void; - onConfirmDelete: () => void; - title: string; - subtitle: string; - boldSubtitle : string; - warningMessage: string; - }) => { - if (!isOpen) return null; +export type ActionConfirmationVariant = "create" | "update" | "delete"; - return ( -
-
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}

-
-
-
- -

Warning

-
-

- {warningMessage} -

+

+ {subtitle + " "} + {boldSubtitle} + {"?"} +

+
+
+
+
+
+ +

+ {styles.label} +

+
+

+ {warningMessage} +

-
- - - {/* Buttons */}
-
- + }} + 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 ec5825dc..edde1299 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/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" + />
@@ -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 (
, - 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 2e842872..8628354c 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"; export type ChangePasswordFormValues = { currentPassword: string; @@ -28,6 +29,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; @@ -46,11 +52,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, @@ -64,6 +75,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(null); const [isUploading, setIsUploading] = useState(false); const [validationError, setValidationError] = useState(null); + const [showUploadConfirm, setShowUploadConfirm] = useState(false); const user = getAppStore().user; + useEffect(() => { + if (!isOpen) setShowUploadConfirm(false); + }, [isOpen]); + const onCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { setCroppedAreaPixels(croppedAreaPixels); }, []); @@ -51,6 +57,7 @@ export default function ProfilePictureModal({ setUploadError(null); setValidationError(null); setIsUploading(false); + setShowUploadConfirm(false); onClose(); }; @@ -82,7 +89,7 @@ export default function ProfilePictureModal({ reader.readAsDataURL(file); }; - const handleSave = async () => { + const performUpload = async () => { if (!imageSrc || !croppedAreaPixels || !user) return; setIsUploading(true); @@ -152,6 +159,18 @@ export default function ProfilePictureModal({ aria-modal="true" aria-labelledby="profile-picture-title" > + setShowUploadConfirm(false)} + onConfirmDelete={() => { + void performUpload(); + }} + title="Update profile picture" + subtitle="Are you sure you want to upload" + boldSubtitle="this profile picture" + warningMessage="This will replace your current profile picture for your account." + variant="update" + />

@@ -223,6 +228,34 @@ function Settings() { onSuccess={() => setProfilePictureMessage({ type: "success", text: "Profile picture updated." })} onError={(msg) => setProfilePictureMessage({ type: "error", text: msg })} /> + setIsRemoveProfilePicModalOpen(false)} + onConfirmDelete={() => { + handleRemoveProfilePic(); + }} + title="Remove Profile Picture" + subtitle="Are you sure you want to remove your" + boldSubtitle="profile picture" + warningMessage="Your profile picture will be removed and replaced with the default avatar." + variant="delete" + /> + setIsSaveProfileModalOpen(false)} + onConfirmDelete={() => { + handleSaveEdit(); + }} + title="Save Profile Changes" + subtitle="Are you sure you want to save changes to your" + boldSubtitle="profile information" + warningMessage={ + isEmailChanged + ? "Changing your email will also change the email you use to log in." + : "Your personal information will be updated in the system." + } + variant="update" + />
diff --git a/frontend/src/main-page/users/components/UserApprove.tsx b/frontend/src/main-page/users/components/UserApprove.tsx index 4cfc7d63..36f52d33 100644 --- a/frontend/src/main-page/users/components/UserApprove.tsx +++ b/frontend/src/main-page/users/components/UserApprove.tsx @@ -3,25 +3,48 @@ import { faCheck, faX } from "@fortawesome/free-solid-svg-icons"; import { User } from "../../../../../middle-layer/types/User"; import { approveUser, deleteUser } from "../UserActions"; import { useState } from "react"; +import ActionConfirmation from "../../../components/ActionConfirmation"; interface UserApproveProps { user: User; } const UserApprove = ({ user }: UserApproveProps) => { const [isLoading, setIsLoading] = useState(false); + const [isApproveModalOpen, setIsApproveModalOpen] = useState(false); + const [isDenyModalOpen, setIsDenyModalOpen] = useState(false); return (
+ setIsApproveModalOpen(false)} + onConfirmDelete={() => approveUser(user, setIsLoading)} + title="Approve User" + subtitle="Are you sure you want to approve" + boldSubtitle={user.email} + warningMessage="Approving this user grants access to the application." + variant="create" + /> + setIsDenyModalOpen(false)} + onConfirmDelete={() => deleteUser(user, setIsLoading)} + title="Deny User" + subtitle="Are you sure you want to deny" + boldSubtitle={user.email} + warningMessage="Denying this user will remove their pending access request." + variant="delete" + />