ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
const field = screen.getByLabelText('Type to confirm');
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: 'nope' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: ' confirm ' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
});
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/**
* When set, confirm stays disabled until the typed value case-insensitively matches ONE of these
* (e.g. the literal word «تایید» OR the exact amount digit-string) — the guard for an irreversible
* action like running a payout batch. A gate, not a payload: `onConfirm`'s signature is unchanged, the
* typed value itself is never passed to the caller.
*/
requireTypedConfirmation?: string[];
typedConfirmationLabel?: string;
typedConfirmationPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
requireReason = false,
reasonLabel,
reasonPlaceholder,
requireTypedConfirmation,
typedConfirmationLabel,
typedConfirmationPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const [typedValue, setTypedValue] = useState('');
const close = () => {
setReason('');
setTypedValue('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
setTypedValue('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
const confirmDisabled =
loading ||
(requireReason && reason.trim().length === 0) ||
(requireTypedConfirmation != null &&
!requireTypedConfirmation.some((v) => v.trim().toLowerCase() === typedValue.trim().toLowerCase()));
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
sx={{ mt: 2 }}
/>
) : null}
{requireTypedConfirmation ? (
<TextField
autoFocus
fullWidth
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
label={typedConfirmationLabel}
placeholder={typedConfirmationPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import PageHeader from './PageHeader';
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
});
it('renders the meta slot below the title when provided', () => {
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('omits the back button when backTo is not given', () => {
wrap(<PageHeader title="T" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
});
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
const onBack = jest.fn();
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
});
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode;
/**
* Optional node rendered below the title/subtitle — a chip row (status/category/linked-record chips)
* or any other short meta line that isn't a button (ui-phase-11: the admin ticket thread's category/
* status/linked-booking/linked-refund chips). Kept distinct from `actions`, which is end-aligned and
* reserved for buttons.
*/
meta?: ReactNode;
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
backTo?: string;
/** Already-translated accessible label for the back button (required when `backTo` is set). */
/** Already-translated accessible label for the back button (required when `backTo` or `onBack` is set). */
backLabel?: string;
/**
* Optional back-navigation handler — an alternative to `backTo` for a caller that needs `router.back()`
* semantics (e.g. `useAdminBackToList`) rather than a fixed link target. Takes precedence over `backTo`
* when both are given (they shouldn't be).
*/
onBack?: () => void;
}
/**
* The standard page header — title + optional subtitle, an end-aligned actions slot, and an optional
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational,
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`).
* The standard page header — title + optional subtitle + optional meta row, an end-aligned actions slot,
* and an optional back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin
* alias); every area page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead.
* Presentational, caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under
* `dir="rtl"`).
* @component PageHeader
*/
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
const PageHeader: FunctionComponent<PageHeaderProps> = ({
title,
subtitle,
actions,
meta,
backTo,
backLabel,
onBack,
}) => (
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
{backTo ? (
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
{backTo || onBack ? (
<AppIconButton
icon="back"
to={onBack ? undefined : backTo}
onClick={onBack}
title={backLabel}
sx={{ mt: 0.25 }}
iconProps={{ size: 20 }}
/>
) : null}
<Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
{subtitle}
</Typography>
) : null}
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
</Box>
</Stack>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}