feat: implement useOverlayDismiss hook for consistent modal dismissal behavior
This commit is contained in:
@@ -4,6 +4,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
|
|||||||
import SearchableSelect from './ui/SearchableSelect';
|
import SearchableSelect from './ui/SearchableSelect';
|
||||||
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
|
import { useBankAccounts, usePosDevices } from '../hooks/usePaymentMethods';
|
||||||
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
|
import { formatRial, formatNumber, tomanToRial, digitsOnly } from '../lib/utils';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
export type WalletMode = 'charge' | 'withdraw';
|
export type WalletMode = 'charge' | 'withdraw';
|
||||||
|
|
||||||
@@ -59,12 +60,7 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
|
if (open) { setMode('charge'); setAmountToman(0); setMethodValue('cash'); setDescription(''); }
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
const dismiss = useOverlayDismiss(onClose, open);
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -103,8 +99,8 @@ export default function WalletTransactionModal({ open, balanceRials, submitting,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 560 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 560 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
<h2>{isWithdraw ? 'برداشت از کیف پول' : 'شارژ کیف پول'}</h2>
|
||||||
<button type="button" className="mini-btn" onClick={onClose}>
|
<button type="button" className="mini-btn" onClick={onClose}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||||
import Portal from './Portal';
|
import Portal from './Portal';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -28,12 +29,14 @@ export default function ConfirmDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
children,
|
children,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const dismiss = useOverlayDismiss(onCancel, open);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onCancel}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ApiResponse } from '../../lib/api';
|
|||||||
import MobileInput from './MobileInput';
|
import MobileInput from './MobileInput';
|
||||||
import Portal from './Portal';
|
import Portal from './Portal';
|
||||||
import { iranMobileSchema } from '../../lib/utils';
|
import { iranMobileSchema } from '../../lib/utils';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
mobile: iranMobileSchema,
|
mobile: iranMobileSchema,
|
||||||
@@ -39,10 +40,12 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>دعوت پزشک به کلینیک</b>
|
<b>دعوت پزشک به کلینیک</b>
|
||||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
@@ -21,22 +22,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
|
export default function Modal({ open, title, size = 'md', onClose, children, footer }: Props) {
|
||||||
useEffect(() => {
|
const dismiss = useOverlayDismiss(onClose, open);
|
||||||
if (!open) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
return () => document.removeEventListener('keydown', onKey);
|
|
||||||
}, [open, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div
|
<div className="modal" style={{ maxWidth: sizeMap[size] }}>
|
||||||
className="modal"
|
|
||||||
style={{ maxWidth: sizeMap[size] }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
|
<button type="button" className="mini-btn" onClick={onClose} aria-label="بستن">
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function PersianCalendar({ value, onChange, onClose, enableYearPi
|
|||||||
while (cells.length % 7 !== 0) cells.push(null);
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{
|
<div ref={ref} className="cp-calendar" style={{
|
||||||
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export default function SearchableSelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select<SelectOption>
|
<Select<SelectOption>
|
||||||
|
// پیشوند پایدار برای کلاسها: کلاسهای emotion در build تولیدی برچسب ندارند و
|
||||||
|
// «منوی باز است یا نه» با آنها قابل تشخیص نیست. useOverlayDismiss به همین
|
||||||
|
// نشانه نگاه میکند تا Esc اول منو را ببندد نه مودال را.
|
||||||
|
classNamePrefix="cp-select"
|
||||||
options={options}
|
options={options}
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(opt) => onChange?.(opt ? opt.value : null)}
|
onChange={(opt) => onChange?.(opt ? opt.value : null)}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* باگی که این تستها میبندند: پسزمینه یک onClick ساده داشت، پس هر کلیکی که روی
|
||||||
|
* آن «تمام میشد» فرم نیمهپرشده را میبست — درگِ متن از داخل به بیرون، یا کلیک روی
|
||||||
|
* گزینهای که همان لحظه unmount میشد.
|
||||||
|
*/
|
||||||
|
function renderModal(onClose: () => void) {
|
||||||
|
return render(
|
||||||
|
<Modal open title="ثبت نوبت" onClose={onClose}>
|
||||||
|
<input placeholder="نام" />
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = () => document.querySelector('.overlay') as HTMLElement;
|
||||||
|
const modal = () => document.querySelector('.modal') as HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('بستن مودال با پسزمینه', () => {
|
||||||
|
it('فشردن و رها کردن روی پسزمینه، مودال را میبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(overlay());
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('درگِ متن از داخل مودال به بیرون، آن را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
// شروع روی محتوای مودال، پایان روی پسزمینه — همان حرکتِ انتخاب متن.
|
||||||
|
fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیک داخل مودال هیچوقت آن را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(modal());
|
||||||
|
fireEvent.click(modal());
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیکِ بعدی روی پسزمینه پس از یک درگ، دوباره درست کار میکند', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByPlaceholderText('نام'));
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(overlay());
|
||||||
|
fireEvent.click(overlay());
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('بستن مودال با Esc', () => {
|
||||||
|
it('Esc مودال را میبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی منوی انتخاب باز است، Esc مودال را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
// همان نشانهای که react-select با classNamePrefix میگذارد.
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'cp-select__menu';
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
menu.remove();
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی تقویم باز است، Esc مودال را نمیبندد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
const calendar = document.createElement('div');
|
||||||
|
calendar.className = 'cp-calendar';
|
||||||
|
document.body.appendChild(calendar);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کلیدهای دیگر کاری نمیکنند', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderModal(onClose);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Enter' });
|
||||||
|
fireEvent.keyDown(document, { key: 'a' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مودالِ بسته به Esc گوش نمیدهد', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<Modal open={false} title="ثبت نوبت" onClose={onClose}><p>محتوا</p></Modal>);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* لایههایی که خودشان با Esc بسته میشوند و مودال نباید جایشان بسته شود:
|
||||||
|
* منوی باز react-select و تقویم شمسی.
|
||||||
|
*/
|
||||||
|
const OPEN_LAYER_SELECTOR = '.cp-select__menu, .cp-calendar';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* رفتار بستنِ یک مودال: کلیک روی پسزمینه و کلید Esc.
|
||||||
|
*
|
||||||
|
* قبلاً پسزمینه یک `onClick={onClose}` ساده داشت و هر کلیکی که *روی آن تمام میشد*
|
||||||
|
* مودال را میبست — انتخاب متن داخل فرم و رها کردن موس بیرون، یا کلیک روی گزینهای
|
||||||
|
* که همان لحظه unmount میشد. بستنِ ناخواستهٔ فرمِ نیمهپرشده آزاردهندهترین باگ
|
||||||
|
* پنل بود، پس شرط سختتر شد: هم فشردن و هم رها کردن باید روی خودِ پسزمینه باشد.
|
||||||
|
*
|
||||||
|
* ```tsx
|
||||||
|
* const dismiss = useOverlayDismiss(onClose);
|
||||||
|
* <div className="overlay" {...dismiss}> … </div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useOverlayDismiss(onClose: () => void, enabled = true) {
|
||||||
|
const pressedOnOverlay = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
// دراپداون یا تقویمِ باز، خودش با Esc بسته میشود؛ مودال باید بماند.
|
||||||
|
if (document.querySelector(OPEN_LAYER_SELECTOR)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [enabled, onClose]);
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
pressedOnOverlay.current = e.target === e.currentTarget;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClick = useCallback((e: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
const started = pressedOnOverlay.current;
|
||||||
|
pressedOnOverlay.current = false;
|
||||||
|
if (started && e.target === e.currentTarget) onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return { onMouseDown, onClick };
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import ClinicDoctorsManager from '../components/ClinicDoctorsManager';
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
import { latinDigitsField } from '../lib/forms';
|
import { latinDigitsField } from '../lib/forms';
|
||||||
import Switch from '../components/ui/Switch';
|
import Switch from '../components/ui/Switch';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
// Fix leaflet icons
|
// Fix leaflet icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -238,9 +239,11 @@ function EditModal({ clinic, onClose, onSaved }: {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic');
|
||||||
|
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 560, maxHeight: '90vh', overflowY: 'auto' }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>ویرایش کلینیک</b>
|
<b>ویرایش کلینیک</b>
|
||||||
<button className="mini-btn" onClick={onClose}>
|
<button className="mini-btn" onClick={onClose}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +26,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|||||||
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
import ChangeLoginMobileModal from '../components/ChangeLoginMobileModal';
|
||||||
import { latinDigitsField } from '../lib/forms';
|
import { latinDigitsField } from '../lib/forms';
|
||||||
import TourButton from '../components/ui/TourButton';
|
import TourButton from '../components/ui/TourButton';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ export default function ClinicsPage() {
|
|||||||
const isRepresentation = primaryRole === 'representation';
|
const isRepresentation = primaryRole === 'representation';
|
||||||
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
// وضعیت لیست در URL میماند تا «بازگشت» از صفحهٔ جزئیات، همین فیلترها و صفحه را برگرداند.
|
||||||
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
const [urlState, setUrlState] = useUrlState({ page: '1', search: '', status: '' });
|
||||||
|
const closeAdd = useCallback(() => setAddOpen(false), []);
|
||||||
|
const addDismiss = useOverlayDismiss(closeAdd);
|
||||||
const page = pageOf(urlState.page);
|
const page = pageOf(urlState.page);
|
||||||
const search = urlState.search;
|
const search = urlState.search;
|
||||||
const statusFilter = urlState.status;
|
const statusFilter = urlState.status;
|
||||||
@@ -264,8 +267,8 @@ export default function ClinicsPage() {
|
|||||||
{/* Add Modal */}
|
{/* Add Modal */}
|
||||||
{addOpen && (
|
{addOpen && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={() => setAddOpen(false)}>
|
<div className="overlay" {...addDismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>افزودن کلینیک</b>
|
<b>افزودن کلینیک</b>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
import { CheckIcon, XMarkIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -9,6 +9,7 @@ import { formatDate } from '../lib/utils';
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
interface PreRegistration {
|
interface PreRegistration {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -49,6 +50,8 @@ export default function PreRegistrationsPage() {
|
|||||||
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
|
const [approveTarget, setApproveTarget] = useState<PreRegistration | null>(null);
|
||||||
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<PreRegistration | null>(null);
|
||||||
const [rejectNote, setRejectNote] = useState('');
|
const [rejectNote, setRejectNote] = useState('');
|
||||||
|
const closeReject = useCallback(() => setRejectTarget(null), []);
|
||||||
|
const rejectDismiss = useOverlayDismiss(closeReject);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
|
const { data, isLoading } = useQuery<PaginatedResponse<PreRegistration>>({
|
||||||
@@ -206,8 +209,8 @@ export default function PreRegistrationsPage() {
|
|||||||
{/* Reject Dialog */}
|
{/* Reject Dialog */}
|
||||||
{rejectTarget && (
|
{rejectTarget && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={() => setRejectTarget(null)}>
|
<div className="overlay" {...rejectDismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 420 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<b>رد درخواست — {rejectTarget.name}</b>
|
<b>رد درخواست — {rejectTarget.name}</b>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|||||||
import Pagination from '../components/ui/Pagination';
|
import Pagination from '../components/ui/Pagination';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useOverlayDismiss } from '../hooks/useOverlayDismiss';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,10 +109,12 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
|||||||
onSave: (role: string) => void; loading: boolean;
|
onSave: (role: string) => void; loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
|
||||||
|
const dismiss = useOverlayDismiss(onClose);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="overlay" onClick={onClose}>
|
<div className="overlay" {...dismiss}>
|
||||||
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
|
<div className="modal" style={{ maxWidth: 400 }}>
|
||||||
<div className="modal-head">
|
<div className="modal-head">
|
||||||
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
||||||
<button className="mini-btn" onClick={onClose}>
|
<button className="mini-btn" onClick={onClose}>
|
||||||
|
|||||||
Reference in New Issue
Block a user