From f69b7f1296f229a705bdaf89c7a11ea1f0c02054 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Jul 2026 12:17:35 +0545 Subject: [PATCH 1/3] feat(OUT-4012): warn before changing the bank-deposit flag Show a confirmation modal when saving invoice settings that flip bankDepositFeeFlag, so users acknowledge that the change applies only to new invoices and that a payout mixing pre/post-change invoices may need manual reconciliation. UX safeguard only; fires on save and only when the flag differs from its saved value, both directions. - add reusable ConfirmModal (portal, Escape/backdrop dismiss, a11y ids) - gate the invoice save behind requestInvoiceSettingsSave in useSettings - render the modal from SettingAccordion; InvoiceDetail toggle unchanged Co-Authored-By: Claude Opus 4.8 --- .../dashboard/settings/SettingAccordion.tsx | 15 ++++- src/components/ui/ConfirmModal.tsx | 67 +++++++++++++++++++ src/hook/useSettings.ts | 26 ++++++- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/ConfirmModal.tsx diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 4105c997..64014baf 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -3,6 +3,7 @@ import InvoiceDetail from '@/components/dashboard/settings/sections/invoice/Invo import AccountMapping from '@/components/dashboard/settings/sections/account/AccountMapping' import ProductMapping from '@/components/dashboard/settings/sections/product/ProductMapping' import Accordion from '@/components/ui/Accordion' +import ConfirmModal from '@/components/ui/ConfirmModal' import Divider from '@/components/ui/Divider' import { useInvoiceDetailSettings, @@ -39,7 +40,6 @@ export default function SettingAccordion({ const { settingState, - submitInvoiceSettings, cancelInvoiceSettings, isLoading, changeSettings, @@ -47,6 +47,10 @@ export default function SettingAccordion({ bankAccountOptions, bankAccountsError, canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } = useInvoiceDetailSettings() const { @@ -172,7 +176,7 @@ export default function SettingAccordion({ variant="primary" prefixIcon="Check" disabled={!canSave} - onClick={submitInvoiceSettings} + onClick={requestInvoiceSettingsSave} /> )} @@ -202,6 +206,13 @@ export default function SettingAccordion({ ) })} + ) } diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx new file mode 100644 index 00000000..8bf3dea2 --- /dev/null +++ b/src/components/ui/ConfirmModal.tsx @@ -0,0 +1,67 @@ +'use client' +import { useEffect, useId } from 'react' +import { createPortal } from 'react-dom' +import { Button } from 'copilot-design-system' + +type ConfirmModalProps = { + open: boolean + title: string + description: string + confirmLabel?: string + cancelLabel?: string + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmModal({ + open, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmModalProps) { + const titleId = useId() + const descId = useId() + + // Wire Escape-to-cancel while open. + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return createPortal( +
+
e.stopPropagation()} + > +

+ {title} +

+

+ {description} +

+
+
+
+
, + document.body, + ) +} diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 5d0ceaf0..5c2e4e9e 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -442,6 +442,7 @@ export const useInvoiceDetailSettings = () => { initialInvoiceSetting, ) const [showButton, setShowButton] = useState(false) + const [showBankDepositWarning, setShowBankDepositWarning] = useState(false) const [intialSettingState, setIntialSettingState] = useState< InvoiceSettingType | undefined >() @@ -522,10 +523,29 @@ export const useInvoiceDetailSettings = () => { setSettingState(intialSettingState || initialInvoiceSetting) } + // Warn only when the bank-deposit flag actually changed vs the saved value. + const bankDepositFlagChanged = + !!intialSettingState && + settingState.bankDepositFeeFlag !== intialSettingState.bankDepositFeeFlag + + const requestInvoiceSettingsSave = () => { + if (bankDepositFlagChanged) { + setShowBankDepositWarning(true) + return + } + submitInvoiceSettings() + } + + const confirmBankDepositChange = () => { + setShowBankDepositWarning(false) + submitInvoiceSettings() + } + + const cancelBankDepositChange = () => setShowBankDepositWarning(false) + return { settingState, changeSettings, - submitInvoiceSettings, cancelInvoiceSettings, error, isLoading, @@ -533,6 +553,10 @@ export const useInvoiceDetailSettings = () => { bankAccountOptions, bankAccountsError, canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } } From 10fda6384dd417069d2975d92a612a5235415a37 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Jul 2026 12:59:21 +0545 Subject: [PATCH 2/3] fix(OUT-4012): add focus management to the confirm modal Address Greptile P2: the aria-modal dialog left focus on the background save button with no trap or restoration. On open, move focus into the dialog, trap Tab/Shift+Tab between its buttons, and restore focus to the previously focused element on close. Keying the effect on `open` via an onCancel ref also stops it re-subscribing on every render. Co-Authored-By: Claude Opus 4.8 --- src/components/ui/ConfirmModal.tsx | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 8bf3dea2..7bebbfdb 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -1,5 +1,5 @@ 'use client' -import { useEffect, useId } from 'react' +import { useEffect, useId, useRef } from 'react' import { createPortal } from 'react-dom' import { Button } from 'copilot-design-system' @@ -24,16 +24,39 @@ export default function ConfirmModal({ }: ConfirmModalProps) { const titleId = useId() const descId = useId() + const dialogRef = useRef(null) + // Keep the latest onCancel without re-running the focus effect each render. + const onCancelRef = useRef(onCancel) + onCancelRef.current = onCancel - // Wire Escape-to-cancel while open. + // On open: focus into the dialog, trap Tab, and restore focus on close. useEffect(() => { if (!open) return + const previouslyFocused = document.activeElement as HTMLElement | null + const focusables = Array.from( + dialogRef.current?.querySelectorAll('button') ?? [], + ) + focusables[0]?.focus() + const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onCancel() + if (e.key === 'Escape') return onCancelRef.current() + if (e.key !== 'Tab' || focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } } document.addEventListener('keydown', onKeyDown) - return () => document.removeEventListener('keydown', onKeyDown) - }, [open, onCancel]) + return () => { + document.removeEventListener('keydown', onKeyDown) + previouslyFocused?.focus() + } + }, [open]) if (!open) return null @@ -43,6 +66,7 @@ export default function ConfirmModal({ onClick={onCancel} >
Date: Fri, 31 Jul 2026 12:54:25 +0545 Subject: [PATCH 3/3] refactor(OUT-4012): split confirm-modal effects per review Replace the onCancelRef workaround with two focused effects: one keyed on `open` for focus-in/restore (runs once), one keyed on `open`+`onCancel` for the Escape + Tab-trap listener. Clearer, honest dependency arrays. Co-Authored-By: Claude Opus 4.8 --- src/components/ui/ConfirmModal.tsx | 35 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 7bebbfdb..171037f7 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -25,24 +25,28 @@ export default function ConfirmModal({ const titleId = useId() const descId = useId() const dialogRef = useRef(null) - // Keep the latest onCancel without re-running the focus effect each render. - const onCancelRef = useRef(onCancel) - onCancelRef.current = onCancel - // On open: focus into the dialog, trap Tab, and restore focus on close. + // On open, focus into the dialog; on close, restore focus to the opener. useEffect(() => { if (!open) return const previouslyFocused = document.activeElement as HTMLElement | null - const focusables = Array.from( - dialogRef.current?.querySelectorAll('button') ?? [], - ) - focusables[0]?.focus() + const buttons = dialogRef.current?.querySelectorAll('button') + buttons?.[0]?.focus() + return () => previouslyFocused?.focus() + }, [open]) + // Escape cancels; Tab is trapped between the dialog's buttons. + useEffect(() => { + if (!open) return const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') return onCancelRef.current() - if (e.key !== 'Tab' || focusables.length === 0) return - const first = focusables[0] - const last = focusables[focusables.length - 1] + if (e.key === 'Escape') return onCancel() + if (e.key !== 'Tab') return + const buttons = Array.from( + dialogRef.current?.querySelectorAll('button') ?? [], + ) + if (buttons.length === 0) return + const first = buttons[0] + const last = buttons[buttons.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault() last.focus() @@ -52,11 +56,8 @@ export default function ConfirmModal({ } } document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('keydown', onKeyDown) - previouslyFocused?.focus() - } - }, [open]) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onCancel]) if (!open) return null