ui phase 1

This commit is contained in:
hamid
2026-07-17 17:10:39 +03:30
parent f1cba6cf74
commit 370c1beefa
151 changed files with 4254 additions and 1840 deletions
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import ConfirmDialog from './ConfirmDialog';
describe('<ConfirmDialog/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const base = {
title: 'Run batch?',
confirmLabel: 'Run',
cancelLabel: 'Cancel',
onClose: jest.fn(),
};
it('renders the title and body when open', () => {
wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />);
expect(screen.getByText('Run batch?')).toBeInTheDocument();
expect(screen.getByText('This moves money')).toBeInTheDocument();
});
it('calls onConfirm with no reason for a plain confirm', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('disables confirm until a required reason is entered, then passes it', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
expect(confirmBtn).toBeDisabled();
fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith('bad docs');
});
it('calls onClose from cancel', () => {
const onClose = jest.fn();
wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import {
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from '@mui/material';
import AppButton from '../AppButton';
export interface ConfirmDialogProps {
open: boolean;
/** Already-translated title. */
title: string;
/** Already-translated body (string or node). */
body?: ReactNode;
confirmLabel: string;
cancelLabel: string;
/** Called with the entered reason (undefined when `requireReason` is false). */
onConfirm: (reason?: string) => void;
onClose: () => void;
loading?: boolean;
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
/**
* The shared confirmation dialog behind every audited/irreversible action — approve/reject a
* verification, run/retry a payout, save a config, resolve an alert, archive a patient, delete an
* address. Optionally collects a **required reason** (reject/hide/resolve) and disables confirm until it
* is provided. The dialog owns the reason field only; the caller owns the mutation and closes on success.
* `loading` disables the buttons and shows a spinner so a double-submit is impossible. Promoted from
* `components/admin` (kept there as a thin alias) — the contract is unchanged.
* @component ConfirmDialog
*/
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
open,
title,
body,
confirmLabel,
cancelLabel,
onConfirm,
onClose,
loading = false,
requireReason = false,
reasonLabel,
reasonPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const close = () => {
setReason('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 700 }}>{title}</DialogTitle>
<DialogContent>
{body ? (
typeof body === 'string' ? (
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
) : (
body
)
) : null}
{requireReason ? (
<TextField
autoFocus
fullWidth
multiline
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
label={reasonLabel}
placeholder={reasonPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
{cancelLabel}
</AppButton>
<AppButton
variant="contained"
color={confirmColor}
onClick={confirm}
disabled={confirmDisabled}
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{confirmLabel}
</AppButton>
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;
@@ -0,0 +1,4 @@
import ConfirmDialog from './ConfirmDialog';
export default ConfirmDialog;
export type { ConfirmDialogProps } from './ConfirmDialog';