ui phase 1
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppButton } from '@/components/common';
|
||||
import { AppButton, AccentCard } from '@/components/common';
|
||||
import type { AccentTone } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import type { StatusKind } from '@/components/StatusChip';
|
||||
import type { BankAccountStatus } from '@/services/nurse/types';
|
||||
@@ -14,10 +14,10 @@ const STATUS_KIND: Record<BankAccountStatus, StatusKind> = {
|
||||
mismatch: 'rejected',
|
||||
};
|
||||
|
||||
const ACCENT_TOKEN: Record<BankAccountStatus, string> = {
|
||||
pending: 'var(--bal-warning)',
|
||||
verified: 'var(--bal-success)',
|
||||
mismatch: 'var(--bal-error)',
|
||||
const STATUS_TONE: Record<BankAccountStatus, AccentTone> = {
|
||||
pending: 'warning',
|
||||
verified: 'success',
|
||||
mismatch: 'error',
|
||||
};
|
||||
|
||||
export interface BankStatusPanelProps {
|
||||
@@ -57,18 +57,7 @@ const BankStatusPanel: FunctionComponent<BankStatusPanelProps> = ({
|
||||
onReenter,
|
||||
reenterLabel,
|
||||
}) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-status={status}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: ACCENT_TOKEN[status],
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone={STATUS_TONE[status]} padding="sm" data-status={status}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={STATUS_KIND[status]} label={chipLabel} />
|
||||
@@ -110,7 +99,7 @@ const BankStatusPanel: FunctionComponent<BankStatusPanelProps> = ({
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
);
|
||||
|
||||
export default BankStatusPanel;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, ButtonBase, LinearProgress, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import type { BnplPlanOption } from '@/services/bnpl/types';
|
||||
|
||||
export interface BnplPlanCardProps {
|
||||
@@ -23,8 +23,6 @@ const asPercent = (fraction: number): number => Math.round(fraction * 100);
|
||||
*/
|
||||
const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, onSelect }) => {
|
||||
const t = useTranslations('bnpl');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const termLabel =
|
||||
plan.termMonths != null
|
||||
@@ -59,15 +57,13 @@ const BnplPlanCard: FunctionComponent<BnplPlanCardProps> = ({ plan, selected, on
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary' }}
|
||||
sx={{ color: plan.feePercent > 0 ? 'var(--bal-money-emphasis)' : 'text.secondary' }}
|
||||
>
|
||||
{feeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(plan.monthlyAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={plan.monthlyAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('monthly')}
|
||||
</Typography>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Avatar, Divider, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import PriceDisplay from '@/components/PriceDisplay';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate, localeTag } from '@/utils';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
export interface BookingRequestSummaryCardProps {
|
||||
@@ -53,7 +54,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
|
||||
const startDate = new Date(`${requestedDate}T${requestedTimeStart}`);
|
||||
const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
@@ -61,14 +62,14 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
|
||||
const ratingLabel =
|
||||
nurseRating != null
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
? formatNumber(nurseRating, locale, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(nurseRating)
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Avatar
|
||||
@@ -83,7 +84,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
</Typography>
|
||||
{ratingLabel ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<AppIcon icon="star" size={16} color="var(--bal-rating)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{ratingLabel}
|
||||
</Typography>
|
||||
@@ -125,7 +126,7 @@ const BookingRequestSummaryCard: FunctionComponent<BookingRequestSummaryCardProp
|
||||
</SummaryRow>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosu
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(`lead_${preview.leadTimeLabel}`)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 500 }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-money-emphasis)', fontWeight: 500 }}>
|
||||
{t('fee_percent', { percent: feePercent })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -53,4 +53,34 @@ describe('<CountdownTimer/> component', () => {
|
||||
expect(screen.getByText('time up')).toBeInTheDocument();
|
||||
expect(onElapsed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the humanized coarse label above the coarse threshold when coarseLabel is supplied', () => {
|
||||
renderTimer({
|
||||
deadlineIso: deadlineInSeconds(25 * 60),
|
||||
elapsedText: 'time up',
|
||||
coarseLabel: (minutes) => `${minutes} min left`,
|
||||
});
|
||||
expect(screen.getByText('25 min left')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^\d{2}:\d{2}$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the ticking clock when coarseLabel is not supplied, even above the threshold', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(25 * 60), elapsedText: 'time up' });
|
||||
expect(screen.getByText('25:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a determinate progress ring when windowStart is supplied', () => {
|
||||
renderTimer({
|
||||
deadlineIso: deadlineInSeconds(100),
|
||||
windowStart: new Date(Date.now() - 100 * 1000).toISOString(),
|
||||
elapsedText: 'time up',
|
||||
});
|
||||
const ring = screen.getByRole('progressbar');
|
||||
expect(ring).toHaveAttribute('aria-valuenow', '50');
|
||||
});
|
||||
|
||||
it('omits the progress ring when windowStart is not supplied', () => {
|
||||
renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' });
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatClock } from '@/utils';
|
||||
|
||||
export type CountdownTimerSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface CountdownTimerProps {
|
||||
/**
|
||||
@@ -11,38 +16,72 @@ export interface CountdownTimerProps {
|
||||
* client only renders the difference against `Date.now()` — it never computes or recomputes a deadline.
|
||||
*/
|
||||
deadlineIso: string;
|
||||
/** Server-supplied UTC instant the window opened at. Supplying it renders the progress-ring variant. */
|
||||
windowStart?: string;
|
||||
/** Optional label above the digits (already translated by the caller). */
|
||||
label?: string;
|
||||
/** Shown once the deadline has passed — the poll then resolves the real terminal status. */
|
||||
elapsedText: string;
|
||||
/** Terracotta-accented urgency styling for the money-adjacent payment window. */
|
||||
/** Legacy override — forces the terracotta ("urgent") tier regardless of the thresholds below. Existing
|
||||
* consumers that always want the alarm treatment (a payment window) keep passing this unchanged; new
|
||||
* consumers should prefer the threshold props. */
|
||||
urgent?: boolean;
|
||||
/** Remaining-seconds threshold below which the accent shifts calm-teal → amber. Default 900 (15 min). */
|
||||
warnThresholdSeconds?: number;
|
||||
/** Remaining-seconds threshold below which the accent shifts amber → terracotta. Default 300 (5 min). */
|
||||
urgentThresholdSeconds?: number;
|
||||
/** Remaining-seconds threshold above which the humanized coarse mode renders instead of the ticking
|
||||
* clock. Default 600 (10 min). Has no effect unless `coarseLabel` is also supplied. */
|
||||
coarseThresholdSeconds?: number;
|
||||
/** Already-translated humanized minutes-remaining copy (e.g. `t('minutes_remaining', {minutes})`) —
|
||||
* supplying this opts into the coarse mode above `coarseThresholdSeconds`; omit to always show the
|
||||
* ticking clock (the pre-existing behavior). */
|
||||
coarseLabel?: (minutes: number) => string;
|
||||
size?: CountdownTimerSize;
|
||||
/** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */
|
||||
onElapsed?: () => void;
|
||||
}
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
const DEFAULT_WARN_THRESHOLD = 900;
|
||||
const DEFAULT_URGENT_THRESHOLD = 300;
|
||||
const DEFAULT_COARSE_THRESHOLD = 600;
|
||||
|
||||
const RING_SIZE: Record<CountdownTimerSize, number> = { sm: 40, md: 56, lg: 72 };
|
||||
const CLOCK_FONT_SIZE: Record<CountdownTimerSize, string> = { sm: '1.125rem', md: '1.5rem', lg: '2rem' };
|
||||
const CLOCK_ICON_SIZE: Record<CountdownTimerSize, number> = { sm: 16, md: 20, lg: 24 };
|
||||
|
||||
/**
|
||||
* A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only
|
||||
* this component re-renders each second — never the page around it (the summary card / form stay put).
|
||||
* The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires
|
||||
* `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS`
|
||||
* order is correct under RTL.
|
||||
* this component re-renders each second — never the page around it. The ticking stops the moment the
|
||||
* deadline passes; crossing zero shows `elapsedText` and fires `onElapsed` once. Digits render in the
|
||||
* active locale (Persian for `fa`), forced LTR so the `HH:MM:SS` order is correct under RTL.
|
||||
*
|
||||
* v2 adds, all opt-in and backward compatible: a progress-ring variant (`windowStart`), three urgency
|
||||
* tiers (calm teal → amber → terracotta, via thresholds — `urgent` still force-overrides for the two
|
||||
* existing payment-window consumers), and a humanized coarse mode above `coarseThresholdSeconds`
|
||||
* (`coarseLabel`) with `aria-live="polite"` — applied only in coarse mode, since the fine ticking clock
|
||||
* updates every second and would spam assistive tech if announced.
|
||||
* @component CountdownTimer
|
||||
*/
|
||||
const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
deadlineIso,
|
||||
windowStart,
|
||||
label,
|
||||
elapsedText,
|
||||
urgent = false,
|
||||
urgent,
|
||||
warnThresholdSeconds = DEFAULT_WARN_THRESHOLD,
|
||||
urgentThresholdSeconds = DEFAULT_URGENT_THRESHOLD,
|
||||
coarseThresholdSeconds = DEFAULT_COARSE_THRESHOLD,
|
||||
coarseLabel,
|
||||
size = 'md',
|
||||
onElapsed,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]);
|
||||
const start = useMemo(() => (windowStart ? Date.parse(windowStart) : undefined), [windowStart]);
|
||||
const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0;
|
||||
const elapsed = remainingMs <= 0;
|
||||
|
||||
@@ -63,31 +102,65 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
}
|
||||
}, [elapsed, onElapsed]);
|
||||
|
||||
const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)';
|
||||
const remainingSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
||||
const accent =
|
||||
urgent !== undefined
|
||||
? urgent
|
||||
? 'var(--bal-secondary)'
|
||||
: 'var(--bal-primary)'
|
||||
: remainingSeconds <= urgentThresholdSeconds
|
||||
? 'var(--bal-secondary)'
|
||||
: remainingSeconds <= warnThresholdSeconds
|
||||
? 'var(--bal-warning)'
|
||||
: 'var(--bal-primary)';
|
||||
|
||||
if (elapsed) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const isCoarse = Boolean(coarseLabel) && remainingSeconds >= coarseThresholdSeconds;
|
||||
const progressPercent =
|
||||
start !== undefined && Number.isFinite(start) && target - start > 0
|
||||
? Math.min(100, Math.max(0, ((now - start) / (target - start)) * 100))
|
||||
: undefined;
|
||||
|
||||
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const content = elapsed ? (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : isCoarse ? (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }} aria-live="polite">
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
<Typography sx={{ fontWeight: 700, color: accent }}>
|
||||
{coarseLabel?.(Math.ceil(remainingSeconds / SECONDS_PER_MINUTE))}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
<Typography
|
||||
component="span"
|
||||
dir="ltr"
|
||||
sx={{ fontWeight: 700, fontSize: CLOCK_FONT_SIZE[size], fontVariantNumeric: 'tabular-nums', color: accent }}
|
||||
>
|
||||
{formatClock(remainingSeconds, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)]
|
||||
.filter((part) => part !== null)
|
||||
.join(':');
|
||||
const ring = (
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={progressPercent}
|
||||
size={RING_SIZE[size]}
|
||||
thickness={3.5}
|
||||
sx={{ color: accent, '& .MuiCircularProgress-circle': { transition: 'stroke-dashoffset 0.3s linear' } }}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppIcon icon="schedule" size={CLOCK_ICON_SIZE[size]} color={accent} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
|
||||
@@ -96,16 +169,14 @@ const CountdownTimer: FunctionComponent<CountdownTimerProps> = ({
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="pending" size={20} color={accent} />
|
||||
<Typography
|
||||
component="span"
|
||||
dir="ltr"
|
||||
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
|
||||
>
|
||||
{clock}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{progressPercent === undefined ? (
|
||||
content
|
||||
) : (
|
||||
<Stack sx={{ alignItems: 'center', gap: 0.75 }}>
|
||||
{ring}
|
||||
{content}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ChangeEvent, FunctionComponent, useEffect, useRef, useState } from 'rea
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppButton from '../common/AppButton';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import SurfaceCard from '../common/SurfaceCard';
|
||||
import AccentCard from '../common/AccentCard';
|
||||
import { ACCEPTED_DOCUMENT_TYPES, MAX_DOCUMENT_SIZE_BYTES } from '@/services/verification/constants';
|
||||
|
||||
/** The stored document as the uploader displays it — a name + optional size, never bytes. */
|
||||
@@ -153,21 +154,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
/>
|
||||
|
||||
{rejected ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="rejected"
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm" data-upload-state="rejected" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -189,9 +176,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
>
|
||||
{t('upload_reupload')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
) : state === 'uploading' ? (
|
||||
<Paper elevation={0} data-upload-state="uploading" sx={uploadedSx}>
|
||||
<SurfaceCard padding="sm" data-upload-state="uploading" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="upload" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||
@@ -205,9 +192,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('upload_uploading')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
) : showUploaded ? (
|
||||
<Paper elevation={0} data-upload-state="success" sx={uploadedSx}>
|
||||
<SurfaceCard padding="sm" data-upload-state="success" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{previewUrl ? (
|
||||
<Box
|
||||
@@ -234,13 +221,9 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
{t('upload_change')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
) : state === 'error' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-upload-state="error"
|
||||
sx={{ ...uploadedSx, borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-error)' }}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm" data-upload-state="error" sx={UPLOAD_PANEL_SX}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={20} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)', flexGrow: 1 }}>
|
||||
@@ -257,7 +240,7 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
>
|
||||
{t('upload_retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
) : (
|
||||
<Box
|
||||
role="button"
|
||||
@@ -299,12 +282,8 @@ const DocumentUpload: FunctionComponent<DocumentUploadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Shared card styling for the uploading / success / error states.
|
||||
const uploadedSx = {
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
// Shared layout on top of SurfaceCard/AccentCard for the uploading / success / error / rejected states.
|
||||
const UPLOAD_PANEL_SX = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
|
||||
// 5,950,000 IRR ÷ 10 = 595,000 Toman
|
||||
expect(screen.getByText('595,000')).toBeInTheDocument();
|
||||
expect(screen.getByText(/595,000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
|
||||
@@ -40,8 +40,8 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
|
||||
// The magnitude 435,000 shows; the raw "-435,000" must not.
|
||||
expect(screen.getByText('435,000')).toBeInTheDocument();
|
||||
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/435,000/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/-435,000/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four buckets with their formatted Toman amounts', () => {
|
||||
@@ -49,9 +49,9 @@ describe('<EarningsBalanceHeader/> component', () => {
|
||||
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
|
||||
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
expect(screen.getByText(/425,000/)).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText(/340,000/)).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText(/850,000/)).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText(/170,000/)).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
import Money from '../common/Money';
|
||||
import AccentCard, { AccentTone } from '../common/AccentCard';
|
||||
import { parseIrr } from '@/utils';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsBalanceHeaderProps {
|
||||
summary: NurseEarningsSummary;
|
||||
}
|
||||
|
||||
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
|
||||
/** The four roll-up buckets — each keyed to a semantic tone + icon so the states read at a glance. */
|
||||
const BUCKETS: ReadonlyArray<{
|
||||
key: 'pending' | 'eligible' | 'paid' | 'clawback';
|
||||
amountKey: keyof NurseEarningsSummary;
|
||||
tone: AccentTone;
|
||||
token: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', tone: 'warning', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', tone: 'info', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', tone: 'success', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', tone: 'error', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -33,29 +36,17 @@ const BUCKETS: ReadonlyArray<{
|
||||
*/
|
||||
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
|
||||
const isOwed = net < BigInt(0);
|
||||
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
|
||||
const magnitude = isOwed ? -net : net;
|
||||
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
|
||||
const balanceTone: AccentTone = isOwed ? 'error' : 'primary';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-balance-state={isOwed ? 'owed' : 'payable'}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '4px solid',
|
||||
borderInlineStartColor: accent,
|
||||
}}
|
||||
>
|
||||
<AccentCard tone={balanceTone} padding="lg" data-balance-state={isOwed ? 'owed' : 'payable'}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
|
||||
@@ -63,19 +54,17 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
|
||||
{formatIrrToToman(magnitude, locale)}
|
||||
</Typography>
|
||||
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Money
|
||||
amountIrr={String(magnitude)}
|
||||
size="lg"
|
||||
tone={isOwed ? 'error' : 'default'}
|
||||
sx={{ fontSize: '2rem', fontWeight: 800, color: accent }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
@@ -85,19 +74,7 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
}}
|
||||
>
|
||||
{BUCKETS.map((bucket) => (
|
||||
<Paper
|
||||
key={bucket.key}
|
||||
elevation={0}
|
||||
data-bucket={bucket.key}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: bucket.token,
|
||||
}}
|
||||
>
|
||||
<AccentCard key={bucket.key} tone={bucket.tone} padding="sm" data-bucket={bucket.key}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
|
||||
@@ -105,14 +82,9 @@ const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({
|
||||
{t(`bucket_${bucket.key}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Money amountIrr={summary[bucket.amountKey]} size="md" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import CountdownTimer from '@/components/CountdownTimer';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
@@ -51,11 +52,7 @@ const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking,
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-earnings-state={item.state}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<SurfaceCard data-earnings-state={item.state}>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -110,7 +107,7 @@ const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking,
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -190,4 +187,21 @@ const StateAffordance: FunctionComponent<{
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EarningsRow;
|
||||
/** Matches the real row's anatomy (title/chip header, breakdown lines, affordance row) for the earnings list. */
|
||||
const EarningsRowSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Skeleton variant="text" width={140} height={26} />
|
||||
<Skeleton variant="text" width={180} height={18} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" width={72} height={24} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="text" width="50%" height={20} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(EarningsRow, { Skeleton: EarningsRowSkeleton });
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import StatusChip from '../StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { installmentStatusKind, type BnplInstallmentRow, type BnplInstallmentStatus } from '@/services/bnpl/types';
|
||||
|
||||
export interface InstallmentScheduleRowProps {
|
||||
@@ -35,11 +37,7 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
|
||||
const dueLabel = row.kind === 'down_payment' ? t('today') : formatShamsiDate(`${row.dueDate}T00:00:00`, locale);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-row-kind={row.kind}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<SurfaceCard padding="sm" data-row-kind={row.kind}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -50,18 +48,19 @@ const InstallmentScheduleRow: FunctionComponent<InstallmentScheduleRowProps> = (
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ alignItems: 'flex-end', gap: 0.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: row.kind === 'down_payment' ? 'var(--bal-secondary)' : undefined }}
|
||||
>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
<Money
|
||||
amountIrr={row.amountIrr}
|
||||
hideUnit
|
||||
size="sm"
|
||||
tone={row.kind === 'down_payment' ? 'emphasis' : 'default'}
|
||||
sx={{ fontWeight: 700 }}
|
||||
/>
|
||||
{showStatus && row.status ? (
|
||||
<StatusChip status={installmentStatusKind(row.status)} label={t(STATUS_LABEL_KEY[row.status])} />
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -79,4 +79,13 @@ describe('<NurseResultCard/> component', () => {
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onSelect).toHaveBeenCalledWith(NURSE);
|
||||
});
|
||||
|
||||
it('exposes a co-located Skeleton twin matching the card shell', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<NurseResultCard.Skeleton />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { memo } from 'react';
|
||||
import { FunctionComponent, memo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Avatar, Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Avatar, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import TrustBadge from '../TrustBadge';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
import { formatNumber } from '@/utils';
|
||||
import type { NurseSearchResult } from '@/services/search/types';
|
||||
|
||||
export interface NurseResultCardProps {
|
||||
@@ -14,10 +15,7 @@ export interface NurseResultCardProps {
|
||||
}
|
||||
|
||||
function ratingText(rating: number, locale: string): string {
|
||||
return new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(rating);
|
||||
return formatNumber(rating, locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +33,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
const name = nurse.nurseName.trim() || t('unnamed_nurse');
|
||||
const initial = name.charAt(0);
|
||||
const distance =
|
||||
nurse.distanceKm != null
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { maximumFractionDigits: 1 }).format(
|
||||
nurse.distanceKm,
|
||||
)
|
||||
: null;
|
||||
nurse.distanceKm != null ? formatNumber(nurse.distanceKm, locale, { maximumFractionDigits: 1 }) : null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -84,7 +78,7 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
|
||||
<AppIcon icon="star" size={16} color="var(--bal-rating)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{ratingText(nurse.averageRating, locale)}
|
||||
</Typography>
|
||||
@@ -114,4 +108,17 @@ const NurseResultCard = ({ nurse, onSelect }: NurseResultCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(NurseResultCard);
|
||||
/** Matches the real card's anatomy (avatar disc, name/badge row, rating row, price row) so a loading list
|
||||
* doesn't jump when data lands. */
|
||||
const NurseResultCardSkeleton: FunctionComponent = () => (
|
||||
<Paper elevation={0} sx={{ p: 2, display: 'flex', gap: 2, alignItems: 'flex-start', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Skeleton variant="circular" width={56} height={56} />
|
||||
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
|
||||
<Skeleton variant="text" width="55%" height={28} />
|
||||
<Skeleton variant="text" width="40%" height={20} />
|
||||
<Skeleton variant="text" width="30%" height={20} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default Object.assign(memo(NurseResultCard), { Skeleton: NurseResultCardSkeleton });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { AppIconButton } from '@/components/common';
|
||||
import { AppIconButton, SurfaceCard } from '@/components/common';
|
||||
import PatientHeader from '@/components/PatientHeader';
|
||||
import type { Patient } from '@/services/patients/types';
|
||||
|
||||
@@ -61,7 +61,7 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
{onOpen ? (
|
||||
<Box
|
||||
@@ -92,8 +92,25 @@ const PatientCard: FunctionComponent<PatientCardProps> = ({
|
||||
<AppIconButton icon="archive" title={archiveLabel} aria-label={archiveLabel} size="small" onClick={onArchive} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientCard;
|
||||
/** Matches the real card's anatomy (identity block + two icon-button actions) for the E1 patient list. */
|
||||
const PatientCardSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
|
||||
<Stack sx={{ gap: 0.75, flexGrow: 1 }}>
|
||||
<Skeleton variant="text" width="45%" height={26} />
|
||||
<Skeleton variant="text" width="65%" height={20} />
|
||||
<Skeleton variant="rounded" width={90} height={22} />
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ flexShrink: 0, gap: 0.5 }}>
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
<Skeleton variant="circular" width={32} height={32} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(PatientCard, { Skeleton: PatientCardSkeleton });
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('<PayoutHistoryRow/> component', () => {
|
||||
|
||||
it('renders the net amount transferred in Toman and the masked IBAN', () => {
|
||||
renderRow(makeItem('paid'));
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText(/425,000/)).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import AccentCard from '@/components/common/AccentCard';
|
||||
import Money from '@/components/common/Money';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
export interface PayoutHistoryRowProps {
|
||||
@@ -31,16 +34,11 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
*/
|
||||
const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOpen }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const isFailed = item.status === 'failed';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payout-status={item.status}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<SurfaceCard data-payout-status={item.status}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -50,12 +48,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{t('payout_net_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(item.netAmountIrr, locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Money amountIrr={item.netAmountIrr} size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[item.status]} label={t(`pstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
@@ -82,16 +75,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
</Stack>
|
||||
|
||||
{isFailed ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<AccentCard tone="error" padding="sm">
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -106,7 +90,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('failure_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccentCard>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
@@ -115,7 +99,7 @@ const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOp
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,4 +114,24 @@ const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ l
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PayoutHistoryRow;
|
||||
/** Matches the real row's anatomy (amount block, status chip, meta lines) for the payout-history list. */
|
||||
const PayoutHistoryRowSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Skeleton variant="text" width={100} height={16} />
|
||||
<Skeleton variant="text" width={150} height={30} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" width={72} height={24} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Skeleton variant="text" width="80%" height={18} />
|
||||
<Skeleton variant="text" width="70%" height={18} />
|
||||
<Skeleton variant="text" width="60%" height={18} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(PayoutHistoryRow, { Skeleton: PayoutHistoryRowSkeleton });
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { Divider, Stack, Typography } from '@mui/material';
|
||||
import Money from '@/components/common/Money';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
@@ -30,7 +32,6 @@ export interface PriceBreakdownProps {
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
@@ -42,7 +43,7 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{rows.map((row) => (
|
||||
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
@@ -59,12 +60,10 @@ const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLab
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{totalLabel}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={totalAmountIrr} tone="emphasis" size="md" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, multiplyIrr } from '@/utils';
|
||||
import { formatIrrToToman, formatNumber, multiplyIrr } from '@/utils';
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
|
||||
export interface PriceDisplayProps {
|
||||
@@ -39,9 +39,7 @@ const PriceDisplay: FunctionComponent<PriceDisplayProps> = ({
|
||||
const unitLabel = t(`unit_${priceUnit}`);
|
||||
const hasEstimate = Boolean(showEstimate && sessionCount && sessionCount > 0);
|
||||
const total = hasEstimate ? formatIrrToToman(multiplyIrr(price, sessionCount as number), locale) : null;
|
||||
const countLabel = hasEstimate
|
||||
? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(sessionCount as number)
|
||||
: '';
|
||||
const countLabel = hasEstimate ? formatNumber(sessionCount as number, locale) : '';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25, alignItems: align === 'center' ? 'center' : 'flex-start' }}>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import RatingInput, { RatingInputProps } from './RatingInput';
|
||||
|
||||
function renderRating(props: Partial<RatingInputProps> = {}) {
|
||||
@@ -40,4 +45,20 @@ describe('<RatingInput/> component', () => {
|
||||
expect(screen.queryByRole('radio')).not.toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a fractional average honestly instead of rounding it', () => {
|
||||
const { container } = renderRating({ value: 4.5, readOnly: true });
|
||||
const fractions = Array.from(container.querySelectorAll('[data-star-fraction]')).map((el) =>
|
||||
Number(el.getAttribute('data-star-fraction')),
|
||||
);
|
||||
expect(fractions).toEqual([1, 1, 1, 1, 0.5]);
|
||||
});
|
||||
|
||||
it('renders full/empty fractions (1 or 0) for a whole-number average', () => {
|
||||
const { container } = renderRating({ value: 3, readOnly: true });
|
||||
const fractions = Array.from(container.querySelectorAll('[data-star-fraction]')).map((el) =>
|
||||
Number(el.getAttribute('data-star-fraction')),
|
||||
);
|
||||
expect(fractions).toEqual([1, 1, 1, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
|
||||
export interface RatingInputProps {
|
||||
/** Current rating, 0–max (0 = none chosen). */
|
||||
/** Current rating, 0–max (0 = none chosen). Read-only display accepts a fraction (e.g. 4.5) and renders
|
||||
* a partially-filled star; interactive selection is always a whole star. */
|
||||
value: number;
|
||||
/** Called with the clicked star value. Omit (or set `readOnly`) for a display-only star row. */
|
||||
onChange?: (value: number) => void;
|
||||
@@ -23,11 +25,13 @@ const DEFAULT_MAX = 5;
|
||||
const DEFAULT_SIZE = 28;
|
||||
|
||||
/**
|
||||
* The 1–max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is a
|
||||
* `radio` in a `radiogroup`); otherwise a static star row (`role="img"`). Stars are token-coloured — filled
|
||||
* = `var(--bal-warning)` (matching the existing rating star), empty = `var(--bal-divider)` — so it switches
|
||||
* with the color scheme and never hard-codes a hex. Built on the registered `star` `AppIcon` (no MUI `Rating`
|
||||
* dependency, so the control is fully deterministic to test).
|
||||
* The 1–max star input/display. Interactive when an `onChange` is passed and not `readOnly` (each star is
|
||||
* a `radio` in a `radiogroup`); otherwise a static star row (`role="img"`) that honestly renders a
|
||||
* **fractional** fill for averages (a 4.5 renders as a half-filled fifth star, never rounded) via a
|
||||
* clip-path overlay — logical, so the filled portion grows from the reading-start side under both `fa`
|
||||
* (RTL) and `en` (LTR). Colors resolve from the dedicated `--bal-rating`/`--bal-rating-empty` tokens
|
||||
* (retiring the muddy `--bal-warning`/near-invisible `--bal-divider` pairing). Built on the registered
|
||||
* `star` `AppIcon` (no MUI `Rating` dependency, so the control is fully deterministic to test).
|
||||
* @component RatingInput
|
||||
*/
|
||||
const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
@@ -38,6 +42,8 @@ const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
size = DEFAULT_SIZE,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const dir = locale === 'fa' ? 'rtl' : 'ltr';
|
||||
const interactive = !readOnly && Boolean(onChange);
|
||||
const stars = Array.from({ length: max }, (_, i) => i + 1);
|
||||
|
||||
@@ -50,23 +56,44 @@ const RatingInput: FunctionComponent<RatingInputProps> = ({
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25 }}
|
||||
>
|
||||
{stars.map((n) => {
|
||||
const color = n <= value ? 'var(--bal-warning)' : 'var(--bal-divider)';
|
||||
if (!interactive) {
|
||||
return <AppIcon key={n} icon="star" size={size} color={color} />;
|
||||
if (interactive) {
|
||||
const filled = n <= value;
|
||||
const color = filled ? 'var(--bal-rating)' : 'var(--bal-rating-empty)';
|
||||
return (
|
||||
<IconButton
|
||||
key={n}
|
||||
role="radio"
|
||||
aria-checked={filled}
|
||||
aria-label={String(n)}
|
||||
data-star={n}
|
||||
size="small"
|
||||
onClick={() => onChange?.(n)}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<AppIcon icon="star" size={size} color={color} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only: fractional fill via a clipped full-star overlay on top of the empty star.
|
||||
const fraction = Math.max(0, Math.min(1, value - (n - 1)));
|
||||
const fillPercent = fraction * 100;
|
||||
const clipPath =
|
||||
fraction >= 1 || fraction <= 0
|
||||
? undefined
|
||||
: dir === 'rtl'
|
||||
? `inset(0 0 0 ${100 - fillPercent}%)`
|
||||
: `inset(0 ${100 - fillPercent}% 0 0)`;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={n}
|
||||
role="radio"
|
||||
aria-checked={n === value}
|
||||
aria-label={String(n)}
|
||||
data-star={n}
|
||||
size="small"
|
||||
onClick={() => onChange?.(n)}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<AppIcon icon="star" size={size} color={color} />
|
||||
</IconButton>
|
||||
<Box key={n} data-star-fraction={fraction} sx={{ position: 'relative', width: size, height: size, lineHeight: 0 }}>
|
||||
<AppIcon icon="star" size={size} color="var(--bal-rating-empty)" />
|
||||
{fraction > 0 ? (
|
||||
<Box sx={{ position: 'absolute', inset: 0, overflow: 'hidden', clipPath }}>
|
||||
<AppIcon icon="star" size={size} color="var(--bal-rating)" />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import StatusTimeline, { type TimelineNode, type TimelineNodeState } from '@/components/common/StatusTimeline';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import Money from '@/components/common/Money';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import RefundEtaBanner from '@/components/RefundEtaBanner';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import {
|
||||
CUSTOMER_REFUND_STEP_ORDER,
|
||||
refundCustomerStep,
|
||||
@@ -38,14 +39,23 @@ const STEP_KIND: Record<CustomerRefundStep, StatusKind> = {
|
||||
*/
|
||||
const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund }) => {
|
||||
const t = useTranslations('refunds');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const step = refundCustomerStep(refund.refundStatus);
|
||||
const isFailed = step === 'failed';
|
||||
// Succeeded → mark all steps complete (activeStep past the last); otherwise sit on the mapped step.
|
||||
// Succeeded → mark all steps complete (past the last index); otherwise sit on the mapped step.
|
||||
const activeStep =
|
||||
refund.refundStatus === 'succeeded' ? CUSTOMER_REFUND_STEP_ORDER.length : refundStepIndex(refund.refundStatus);
|
||||
const timelineNodes: TimelineNode[] = CUSTOMER_REFUND_STEP_ORDER.map((s, index) => {
|
||||
const state: TimelineNodeState = index < activeStep ? 'completed' : index === activeStep ? 'current' : 'pending';
|
||||
const timestamp =
|
||||
s === 'submitted' && refund.createdAt
|
||||
? formatShamsiDate(refund.createdAt, locale)
|
||||
: s === 'completed' && refund.completedAt
|
||||
? formatShamsiDate(refund.completedAt, locale)
|
||||
: undefined;
|
||||
return { key: s, label: t(`step_${s}`), state, timestamp };
|
||||
});
|
||||
|
||||
const hasDecomposition = refund.platformFeeRefundedIrr != null && refund.nursePayoutRefundedIrr != null;
|
||||
|
||||
@@ -70,25 +80,22 @@ const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund })
|
||||
</AppAlert>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
|
||||
<StepperHeader
|
||||
steps={[t('step_submitted'), t('step_on_its_way'), t('step_completed')]}
|
||||
activeStep={activeStep}
|
||||
/>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack sx={{ alignItems: 'center' }}>
|
||||
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
|
||||
</Stack>
|
||||
<StatusTimeline nodes={timelineNodes} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('total_refunded_label')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={refund.totalRefundedIrr} size="md" tone="emphasis" />
|
||||
</Stack>
|
||||
{refund.externalRevertReference && (
|
||||
<>
|
||||
|
||||
@@ -8,17 +8,28 @@ interface StatusStyle {
|
||||
bg: string;
|
||||
fg: string;
|
||||
icon: string;
|
||||
/** High-alarm states render a solid fill; everything else is a soft tint + strong text. */
|
||||
solid?: boolean;
|
||||
}
|
||||
|
||||
// Colors come from the semantic --bal-* tokens (both schemes defined in tokens.css),
|
||||
// so the chip switches with the color scheme automatically. Never hard-code a hex here.
|
||||
/*
|
||||
* Colors come from the semantic --bal-* tokens (both schemes defined in tokens.css), so the chip
|
||||
* switches with the color scheme automatically. Never hard-code a hex here.
|
||||
*
|
||||
* Status weight hierarchy (v2 — soft-tint by default, solid fill reserved for the alarm tier):
|
||||
* neutral (divider tint) < info (info-soft) < progress/pending (primary-soft — "in motion",
|
||||
* deliberately calmer than the old amber "pending" so routine waiting doesn't read as a warning) <
|
||||
* success/verified+active (success-soft) < warning (warning-soft — reserved for a future "needs
|
||||
* attention" kind; no current StatusKind uses it, the token exists for the next one that does) <
|
||||
* alarm/rejected (solid --bal-error — the one kind that stays a solid fill).
|
||||
*/
|
||||
const STATUS_STYLE: Record<StatusKind, StatusStyle> = {
|
||||
verified: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' },
|
||||
active: { bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified' },
|
||||
pending: { bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'pending' },
|
||||
rejected: { bg: 'var(--bal-error)', fg: 'var(--bal-error-contrast)', icon: 'rejected' },
|
||||
info: { bg: 'var(--bal-info)', fg: 'var(--bal-info-contrast)', icon: 'info' },
|
||||
neutral: { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)', icon: 'info' },
|
||||
info: { bg: 'var(--bal-info-soft)', fg: 'var(--bal-info)', icon: 'info' },
|
||||
pending: { bg: 'var(--bal-primary-soft)', fg: 'var(--bal-primary)', icon: 'pending' },
|
||||
verified: { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)', icon: 'verified' },
|
||||
active: { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)', icon: 'verified' },
|
||||
rejected: { bg: 'var(--bal-error)', fg: 'var(--bal-error-contrast)', icon: 'rejected', solid: true },
|
||||
};
|
||||
|
||||
export interface StatusChipProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
@@ -29,8 +40,11 @@ export interface StatusChipProps extends Omit<ChipProps, 'color' | 'icon' | 'lab
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-harmonized status chip (verified / pending / rejected / …). Composed from MUI
|
||||
* Chip + the AppIcon registry; colors resolve from the --bal-* semantic tokens.
|
||||
* Brand-harmonized status chip (verified / pending / rejected / …) — the single delegation point every
|
||||
* status-shaped surface (`BankStatusPanel`, `VariantCard`, `PaymentStatusBadge`, `EarningsRow`,
|
||||
* `PayoutHistoryRow`, `RefundStatusCard`, `CancellationPolicyDisclosure`, …) renders through. Composed
|
||||
* from MUI Chip + the AppIcon registry; colors resolve from the --bal-* semantic tokens, soft-tint by
|
||||
* default per the weight hierarchy above.
|
||||
* @component StatusChip
|
||||
*/
|
||||
const StatusChip: FunctionComponent<StatusChipProps> = ({ status, label, size = 'small', sx, ...rest }) => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { pickCatalogName } from '@/services/catalog/names';
|
||||
import type { NurseServiceVariant } from '@/services/catalog/types';
|
||||
import AppButton from '../common/AppButton';
|
||||
import SurfaceCard from '../common/SurfaceCard';
|
||||
import StatusChip from '../StatusChip';
|
||||
import PriceDisplay from '../PriceDisplay';
|
||||
|
||||
@@ -33,14 +34,10 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
<SurfaceCard
|
||||
padding="sm"
|
||||
data-active={active}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
// Deactivated rows read as muted/unbookable without hiding their content.
|
||||
opacity: active ? 1 : 0.66,
|
||||
}}
|
||||
@@ -89,7 +86,7 @@ const VariantCard: FunctionComponent<VariantCardProps> = ({ variant, onEdit, onT
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { AppIcon, SurfaceCard } from '@/components/common';
|
||||
import type { VisitNote } from '@/services/patientRecords/types';
|
||||
|
||||
export interface VisitNoteCardProps {
|
||||
@@ -26,7 +26,7 @@ export interface VisitNoteCardProps {
|
||||
*/
|
||||
const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel, authorFallback }) => {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
<AppIcon icon="notes" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
@@ -61,8 +61,25 @@ const VisitNoteCard: FunctionComponent<VisitNoteCardProps> = ({ note, dateLabel,
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
</SurfaceCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitNoteCard;
|
||||
/** Matches the real card's anatomy (author/date row, body lines, task-chip row) for the visit-note history. */
|
||||
const VisitNoteCardSkeleton: FunctionComponent = () => (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Skeleton variant="circular" width={18} height={18} />
|
||||
<Skeleton variant="text" width={100} height={20} />
|
||||
<Skeleton variant="text" width={80} height={16} sx={{ marginInlineStart: 'auto' }} />
|
||||
</Stack>
|
||||
<Skeleton variant="text" width="100%" height={18} />
|
||||
<Skeleton variant="text" width="80%" height={18} />
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 1 }}>
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
<Skeleton variant="rounded" width={70} height={24} />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default Object.assign(VisitNoteCard, { Skeleton: VisitNoteCardSkeleton });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Paper, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import EmptyState from '../common/EmptyState';
|
||||
|
||||
export interface AdminEmptyStateProps {
|
||||
/** AppIcon registry name. */
|
||||
@@ -12,25 +11,10 @@ export interface AdminEmptyStateProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared empty-state panel for admin worklists ("Queue clear", "No open alerts", …). Dashed border,
|
||||
* muted icon; tokens only.
|
||||
* Thin admin-scoped alias of the shared `EmptyState` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `EmptyState` from `@/components`.
|
||||
* @component AdminEmptyState
|
||||
*/
|
||||
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = ({ icon = 'info', title, body }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 5, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = (props) => <EmptyState {...props} />;
|
||||
|
||||
export default AdminEmptyState;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import AdminErrorState from './AdminErrorState';
|
||||
|
||||
describe('<AdminErrorState/>', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Paper, Typography } from '@mui/material';
|
||||
import AppButton from '../common/AppButton';
|
||||
import ErrorState from '../common/ErrorState';
|
||||
|
||||
export interface AdminErrorStateProps {
|
||||
/** Already-translated message. */
|
||||
@@ -11,22 +10,10 @@ export interface AdminErrorStateProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error panel for admin worklists — a muted message + a retry button. `clientFetch` already
|
||||
* toasts 401/403/5xx, so this is the inline recovery affordance, not the notification.
|
||||
* Thin admin-scoped alias of the shared `ErrorState` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `ErrorState` from `@/components`.
|
||||
* @component AdminErrorState
|
||||
*/
|
||||
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = ({ message, retryLabel, onRetry }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 5, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = (props) => <ErrorState {...props} />;
|
||||
|
||||
export default AdminErrorState;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import PageHeader from '../common/PageHeader';
|
||||
|
||||
export interface AdminPageHeaderProps {
|
||||
/** Already-translated title (i18n is the caller's job — labels are keys). */
|
||||
@@ -11,28 +11,10 @@ export interface AdminPageHeaderProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard backoffice page header — a title + optional subtitle and an end-aligned actions slot. Shared
|
||||
* by every admin console so the worklists read as one system (phase §3). Presentational; RTL-safe (logical
|
||||
* flex, no directional hard-coding).
|
||||
* Thin admin-scoped alias of the shared `PageHeader` — kept so existing backoffice call sites don't need
|
||||
* a mechanical import rename. New code (admin or otherwise) should import `PageHeader` from `@/components`.
|
||||
* @component AdminPageHeader
|
||||
*/
|
||||
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = ({ title, subtitle, actions }) => (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||
</Stack>
|
||||
);
|
||||
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = (props) => <PageHeader {...props} />;
|
||||
|
||||
export default AdminPageHeader;
|
||||
|
||||
@@ -1,113 +1,14 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import {
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import AppButton from '../common/AppButton';
|
||||
import { FunctionComponent } from 'react';
|
||||
import ConfirmDialog, { ConfirmDialogProps } from '../common/ConfirmDialog';
|
||||
|
||||
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';
|
||||
}
|
||||
export type { ConfirmDialogProps };
|
||||
|
||||
/**
|
||||
* The shared confirmation dialog behind every audited/irreversible admin action — approve/reject a
|
||||
* verification, run/retry a payout, save a config, resolve an alert, verify a center. 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.
|
||||
* Thin admin-scoped alias of the shared `ConfirmDialog` — kept so existing backoffice call sites don't
|
||||
* need a mechanical import rename. New code (admin or otherwise) should import `ConfirmDialog` from
|
||||
* `@/components`.
|
||||
* @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 AdminConfirmDialog: FunctionComponent<ConfirmDialogProps> = (props) => <ConfirmDialog {...props} />;
|
||||
|
||||
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: 800 }}>{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;
|
||||
export default AdminConfirmDialog;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { CenterInvoice } from '@/services/partnerCenter/types';
|
||||
import AppButton from '../common/AppButton';
|
||||
import Money from '../common/Money';
|
||||
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
|
||||
|
||||
export interface PartnerSettlementRowProps {
|
||||
@@ -24,7 +25,6 @@ export interface PartnerSettlementRowProps {
|
||||
*/
|
||||
const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ invoice, onDownloadPdf, downloadError, onRetryDownload }) => {
|
||||
const t = useTranslations('partner');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const rows: PriceBreakdownRow[] = [
|
||||
@@ -54,9 +54,7 @@ const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ in
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('invoice_row_gross')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(invoice.grossIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Money amountIrr={invoice.grossIrr} size="sm" sx={{ fontWeight: 700 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FunctionComponent, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Alert, Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||
import type { AdminRefundResult, RefundChannel } from '@/services/refunds/types';
|
||||
import { useApproveRefund, useInitiateRefund, useRefundPreview, useRejectRefund } from '@/services/refunds';
|
||||
import AppButton from '../common/AppButton';
|
||||
@@ -106,7 +106,7 @@ const RefundPanel: FunctionComponent<RefundPanelProps> = ({ bookingId, ticketId,
|
||||
const activeResult = result;
|
||||
const failed = activeResult?.status === 'failed';
|
||||
const finalDone = activeResult && (activeResult.status === 'succeeded' || activeResult.status === 'processing' || activeResult.status === 'rejected');
|
||||
const pct = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { style: 'percent', maximumFractionDigits: 0 }).format(p.refundPercentageApplied);
|
||||
const pct = formatNumber(p.refundPercentageApplied, locale, { style: 'percent', maximumFractionDigits: 0 });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
|
||||
@@ -34,6 +34,7 @@ interface RoleGuardProps {
|
||||
* @component RoleGuard
|
||||
*/
|
||||
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
|
||||
return children;
|
||||
const t = useTranslations('auth');
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import AccentCard from '@/components/common/AccentCard';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
|
||||
@@ -214,10 +215,7 @@ function CareSection({
|
||||
if (viewerRole !== 'nurse') {
|
||||
// Customer: never renders the clinical record and never fires the query.
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<AccentCard tone="neutral">
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="lock" size={20} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
@@ -229,7 +227,7 @@ function CareSection({
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import Money from '@/components/common/Money';
|
||||
import type { MoneyTone } from '@/components/common/Money';
|
||||
import type { BookingViewerRole } from '@/services/bookings/types';
|
||||
|
||||
export interface BookingMoneySummaryProps {
|
||||
@@ -28,10 +29,6 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
viewerRole,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const toman = (irr: string) => `${formatIrrToToman(irr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
@@ -39,12 +36,12 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
{t('money_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<MoneyRow label={t('money_gross')} value={toman(grossPriceIrr)} emphasize />
|
||||
<MoneyRow label={t('money_commission')} value={toman(balinyaarCommissionIrr)} />
|
||||
<MoneyRow label={t('money_gross')} amountIrr={grossPriceIrr} tone="emphasis" />
|
||||
<MoneyRow label={t('money_commission')} amountIrr={balinyaarCommissionIrr} deduction />
|
||||
<MoneyRow
|
||||
label={viewerRole === 'nurse' ? t('money_nurse_earning') : t('money_payout')}
|
||||
value={toman(nursePayoutAmount)}
|
||||
accent
|
||||
amountIrr={nursePayoutAmount}
|
||||
tone="emphasis"
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -53,26 +50,21 @@ const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
|
||||
function MoneyRow({
|
||||
label,
|
||||
value,
|
||||
emphasize = false,
|
||||
accent = false,
|
||||
amountIrr,
|
||||
tone,
|
||||
deduction = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasize?: boolean;
|
||||
accent?: boolean;
|
||||
amountIrr: string;
|
||||
tone?: MoneyTone;
|
||||
deduction?: boolean;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: emphasize ? 800 : 700, color: accent ? 'var(--bal-secondary-dark)' : 'text.primary' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
<Money amountIrr={amountIrr} size="sm" tone={tone} deduction={deduction} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import StatusTimeline, { type TimelineNode, type TimelineNodeState } from '@/components/common/StatusTimeline';
|
||||
import {
|
||||
BOOKING_TIMELINE_ORDER,
|
||||
bookingTimelineActiveIndex,
|
||||
@@ -17,18 +17,31 @@ export interface BookingStatusTimelineProps {
|
||||
status: BookingStatus;
|
||||
}
|
||||
|
||||
/** `completed`/`disputed`/`closed` sit at the "completed" index but mean "done", never "in progress". */
|
||||
function nodeState(index: number, activeIndex: number, status: BookingStatus): TimelineNodeState {
|
||||
if (index < activeIndex) return 'completed';
|
||||
if (index > activeIndex) return 'pending';
|
||||
return status === 'completed' || status === 'disputed' || status === 'closed' ? 'completed' : 'current';
|
||||
}
|
||||
|
||||
/**
|
||||
* The both-roles booking status timeline — the grown-up version of the f7 C5 3-step tracker. It renders
|
||||
* the canonical happy path `pending_payment → confirmed → in_progress → completed` over the shared
|
||||
* StepperHeader, with the current-status chip above it. Terminal branches are shown **distinctly**:
|
||||
* `cancelled` replaces the stepper with a neutral terminal row (a progress bar would mislead); `disputed`
|
||||
* / `closed` keep the stepper (they follow completion) and surface through the chip. **Reflects
|
||||
* `BookingDetailDto.status` exactly** — it is never advanced on the client.
|
||||
* `StatusTimeline`, with the current-status chip above it. Terminal branches are shown **distinctly**:
|
||||
* `cancelled` replaces the timeline with a neutral terminal row (a progress line would mislead);
|
||||
* `disputed`/`closed` keep the timeline (they follow completion, rendered as fully completed) and
|
||||
* surface through the chip. **Reflects `BookingDetailDto.status` exactly** — it is never advanced on the
|
||||
* client.
|
||||
* @component BookingStatusTimeline
|
||||
*/
|
||||
const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({ status }) => {
|
||||
const t = useTranslations('booking');
|
||||
const steps = BOOKING_TIMELINE_ORDER.map((s) => t(`bstatus_${s}`));
|
||||
const activeIndex = bookingTimelineActiveIndex(status);
|
||||
const nodes: TimelineNode[] = BOOKING_TIMELINE_ORDER.map((s, index) => ({
|
||||
key: s,
|
||||
label: t(`bstatus_${s}`),
|
||||
state: nodeState(index, activeIndex, status),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }} data-booking-status={status}>
|
||||
@@ -50,7 +63,7 @@ const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<StepperHeader steps={steps} activeStep={bookingTimelineActiveIndex(status)} />
|
||||
<StatusTimeline nodes={nodes} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton } from '@/components/common';
|
||||
import { AppButton, Money } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
|
||||
import EvvStatusBanner from '../EvvStatusBanner';
|
||||
import { formatClock, formatElapsed, formatTimeRange } from '../format';
|
||||
@@ -67,7 +67,6 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
onCheckOut,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const dateLabel = formatShamsiDate(scheduledDate, locale);
|
||||
@@ -109,8 +108,8 @@ const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
) : null}
|
||||
|
||||
{showPayout && visitPayoutAmount ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', fontWeight: 500 }}>
|
||||
{t('session_payout')}: {formatIrrToToman(visitPayoutAmount, locale)} {tc('currency_toman')}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('session_payout')}: <Money amountIrr={visitPayoutAmount} size="sm" tone="emphasis" />
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatNumber, localeTag } from '@/utils';
|
||||
|
||||
/**
|
||||
* Shared time formatters for the booking composites. Timestamps cross the wire as UTC ISO; these render
|
||||
* a locale clock (Persian digits for `fa`) for the EVV banner + session schedule. Kept internal to the
|
||||
@@ -8,7 +10,7 @@
|
||||
export function formatClock(iso: string, locale: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
return new Intl.DateTimeFormat(localeTag(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
@@ -18,7 +20,7 @@ export function formatClock(iso: string, locale: string): string {
|
||||
export function formatTimeOfDay(time: string, locale: string): string {
|
||||
const [h, m] = time.split(':');
|
||||
if (h == null || m == null) return time;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
const nf = new Intl.NumberFormat(localeTag(locale), {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
@@ -42,10 +44,8 @@ export function formatElapsed(startIso: string | null, endIso: string | null, lo
|
||||
const totalMinutes = Math.round((end - start) / 60_000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { useGrouping: false });
|
||||
const nfPad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
return `${formatNumber(hours, locale, { useGrouping: false })}:${formatNumber(minutes, locale, {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
return `${nf.format(hours)}:${nfPad.format(minutes)}`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AccentCard from './AccentCard';
|
||||
|
||||
describe('<AccentCard/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders its children with the tone data attribute', () => {
|
||||
wrap(<AccentCard tone="success">content</AccentCard>);
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByText('content').closest('[data-accent-tone]')).toHaveAttribute('data-accent-tone', 'success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import SurfaceCard, { SurfaceCardProps } from '../SurfaceCard';
|
||||
|
||||
export type AccentTone = 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' | 'trust' | 'neutral';
|
||||
|
||||
const ACCENT_WIDTH = '4px';
|
||||
|
||||
const TONE_VAR: Record<AccentTone, string> = {
|
||||
primary: 'var(--bal-primary)',
|
||||
secondary: 'var(--bal-secondary)',
|
||||
success: 'var(--bal-success)',
|
||||
error: 'var(--bal-error)',
|
||||
warning: 'var(--bal-warning)',
|
||||
info: 'var(--bal-info)',
|
||||
trust: 'var(--bal-trust)',
|
||||
neutral: 'var(--bal-text-secondary)',
|
||||
};
|
||||
|
||||
export interface AccentCardProps extends SurfaceCardProps {
|
||||
/** Semantic tone driving the `borderInlineStart` accent stripe — never a raw color. */
|
||||
tone: AccentTone;
|
||||
}
|
||||
|
||||
/**
|
||||
* `SurfaceCard` plus a `borderInlineStart` accent stripe at one standardized width (4px — this app
|
||||
* previously drifted between 3px and 4px across `EarningsBalanceHeader`/`PayoutHistoryRow` vs
|
||||
* `BankStatusPanel`/`DocumentUpload`). Logical property, so the stripe sits on the correct edge under
|
||||
* RTL automatically. Used for stateful panels (bank ownership, upload state, a nurse's earnings header).
|
||||
* @component AccentCard
|
||||
*/
|
||||
const AccentCard: FunctionComponent<AccentCardProps> = ({ tone, sx, children, ...rest }) => (
|
||||
<SurfaceCard
|
||||
sx={{ borderInlineStart: `${ACCENT_WIDTH} solid ${TONE_VAR[tone]}`, ...sx }}
|
||||
data-accent-tone={tone}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</SurfaceCard>
|
||||
);
|
||||
|
||||
export default AccentCard;
|
||||
@@ -0,0 +1,4 @@
|
||||
import AccentCard from './AccentCard';
|
||||
|
||||
export default AccentCard;
|
||||
export type { AccentCardProps, AccentTone } from './AccentCard';
|
||||
@@ -93,8 +93,9 @@ import DownloadIcon from '@mui/icons-material/FileDownloadRounded';
|
||||
import ExpandIcon from '@mui/icons-material/ExpandMoreRounded';
|
||||
import ExternalIcon from '@mui/icons-material/OpenInNewRounded';
|
||||
import AssignIcon from '@mui/icons-material/AssignmentIndRounded';
|
||||
// Cross-cutting affordances: back navigation, share/copy, attachments
|
||||
// Cross-cutting affordances: back/forward navigation, share/copy, attachments
|
||||
import BackIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import ForwardIcon from '@mui/icons-material/ArrowForwardRounded';
|
||||
import ShareIcon from '@mui/icons-material/ShareRounded';
|
||||
import CopyIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import AttachmentIcon from '@mui/icons-material/AttachFileRounded';
|
||||
@@ -197,6 +198,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
assign: AssignIcon,
|
||||
back: BackIcon,
|
||||
chevron_start: BackIcon,
|
||||
forward: ForwardIcon,
|
||||
share: ShareIcon,
|
||||
copy: CopyIcon,
|
||||
attachment: AttachmentIcon,
|
||||
@@ -207,4 +209,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
|
||||
* attribute + the single CSS rule in globals.css — add a name here, nothing else.
|
||||
*/
|
||||
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start']);
|
||||
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import AppLoading from './AppLoading';
|
||||
|
||||
describe('<AppLoading/>', () => {
|
||||
it('renders a status region with the brand mark', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<AppLoading />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelector('[role="status"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-icon="logo"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('forwards sx overrides to the root', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<AppLoading data-testid="loading-root" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelector('[data-testid="loading-root"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,36 @@
|
||||
'use client'
|
||||
import { FunctionComponent } from 'react';
|
||||
import { CircularProgress, CircularProgressProps, LinearProgress, Stack, StackProps } from '@mui/material';
|
||||
import { APP_LOADING_COLOR, APP_LOADING_SIZE, APP_LOADING_TYPE } from '@/components/config';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import Stack, { StackProps } from '@mui/material/Stack';
|
||||
import AppIcon from '../AppIcon';
|
||||
|
||||
interface Props extends StackProps {
|
||||
color?: CircularProgressProps['color'];
|
||||
size?: number | string;
|
||||
type?: 'circular' | 'linear';
|
||||
value?: number;
|
||||
}
|
||||
export type AppLoadingProps = StackProps;
|
||||
|
||||
const breathe = keyframes`
|
||||
0%, 100% { opacity: 0.55; transform: scale(0.92); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
`;
|
||||
|
||||
/**
|
||||
* Renders MI circular progress centered inside Stack
|
||||
* The branded full-page loading splash — the Balinyaar mark with a subtle breathing motion, respecting
|
||||
* `prefers-reduced-motion` (the animation only applies under the `no-preference` media query; a
|
||||
* reduced-motion user sees a static, still-legible mark). Reserved for **true full-page waits** (the auth
|
||||
* splash, a payment-gateway return, a route-level `Suspense` fallback) — inline/list loading should reach
|
||||
* for a shaped skeleton (a co-located `<X.Skeleton />` twin) instead, never a bare spinner.
|
||||
* @component AppLoading
|
||||
* @prop {string} [size] - size of the progress component. Numbers means pixels, string can be '2.5rem'
|
||||
*/
|
||||
const AppLoading: FunctionComponent<Props> = ({
|
||||
color = APP_LOADING_COLOR,
|
||||
size = APP_LOADING_SIZE,
|
||||
type = APP_LOADING_TYPE,
|
||||
value,
|
||||
...restOfProps
|
||||
}) => {
|
||||
const alignItems = type === 'linear' ? undefined : 'center';
|
||||
return (
|
||||
<Stack sx={{ my: 2, alignItems }} {...restOfProps}>
|
||||
{type === 'linear' ? (
|
||||
<LinearProgress color={color} value={value} />
|
||||
) : (
|
||||
<CircularProgress color={color} size={size} value={value} />
|
||||
)}
|
||||
const AppLoading: FunctionComponent<AppLoadingProps> = ({ sx, ...rest }) => (
|
||||
<Stack role="status" sx={{ my: 4, alignItems: 'center', justifyContent: 'center', ...sx }} {...rest}>
|
||||
<Stack
|
||||
sx={{
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
animation: `${breathe} 1.6s ease-in-out infinite`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="logo" size={48} color="var(--bal-primary)" />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default AppLoading;
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
describe('<EmptyState/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders the title and body', () => {
|
||||
wrap(<EmptyState title="Nothing here" body="Add your first patient" />);
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add your first patient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the body when not given', () => {
|
||||
wrap(<EmptyState title="Empty" />);
|
||||
expect(screen.getByText('Empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the action slot when provided', () => {
|
||||
wrap(<EmptyState title="Empty" action={<button>Add</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '../AppIcon';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** AppIcon registry name — already-translated copy is the caller's job, the icon is decorative. */
|
||||
icon?: string;
|
||||
/** Already-translated title. */
|
||||
title: string;
|
||||
/** Already-translated body — a string for the common case, or a node for richer copy (e.g. a list of
|
||||
* suggestions). */
|
||||
body?: ReactNode;
|
||||
/** Optional call-to-action (e.g. an `AppButton`). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm, branded "nothing here" panel — replaces the dashed-border `Paper` blocks hand-rolled across
|
||||
* the app. A flat card (phase-0 surface/radius tokens) with a soft-primary icon roundel, never alarming.
|
||||
* Presentational + caller-owned i18n; the illustration set (per-domain artwork) is DEFERRED to phase 12.
|
||||
* @component EmptyState
|
||||
*/
|
||||
const EmptyState: FunctionComponent<EmptyStateProps> = ({ icon = 'info', title, body, action }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-empty-state
|
||||
sx={{
|
||||
p: { xs: 3, sm: 5 },
|
||||
textAlign: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--bal-primary-soft)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
typeof body === 'string' ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack sx={{ color: 'text.secondary', maxWidth: 360 }}>{body}</Stack>
|
||||
)
|
||||
) : null}
|
||||
{action ? <Stack sx={{ mt: 0.5 }}>{action}</Stack> : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default EmptyState;
|
||||
@@ -0,0 +1,4 @@
|
||||
import EmptyState from './EmptyState';
|
||||
|
||||
export default EmptyState;
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
|
||||
if (shouldThrow) throw new Error('boom');
|
||||
return <div>safe content</div>;
|
||||
}
|
||||
|
||||
const COPY = { title: 'Something went wrong', body: 'Please try again.', retryLabel: 'Retry' };
|
||||
|
||||
describe('<ErrorBoundary/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders children when nothing throws', () => {
|
||||
wrap(
|
||||
<ErrorBoundary name="Test" {...COPY}>
|
||||
<Bomb shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('safe content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the branded fallback with a retry affordance when a child throws', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
wrap(
|
||||
<ErrorBoundary name="Test" {...COPY}>
|
||||
<Bomb shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('re-renders children after retry is clicked (state resets)', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const { rerender } = render(
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary name="Test" {...COPY}>
|
||||
<Bomb shouldThrow={true} />
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
|
||||
// Swap in a child that no longer throws BEFORE retrying — the boundary's `hasError` state
|
||||
// only clears on retry, and re-rendering while it's still true keeps showing the fallback
|
||||
// (it doesn't re-invoke the previously-thrown children). Retry re-renders `this.props.children`
|
||||
// with whatever was most recently passed down, so the non-throwing swap must land first.
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary name="Test" {...COPY}>
|
||||
<Bomb shouldThrow={false} />
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(screen.getByText('safe content')).toBeInTheDocument();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
'use client';
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppButton from './AppButton';
|
||||
import AppIcon from './AppIcon';
|
||||
|
||||
interface Props {
|
||||
export interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Diagnostic-only segment name (console log label) — never shown to the user. */
|
||||
name: string;
|
||||
/** Already-translated fallback copy — kept presentational/caller-owned (like every other shared
|
||||
* primitive in this kit) so this file never imports next-intl; it sits at the top of the `common`
|
||||
* barrel, so an eager i18n dependency here would drag `next-intl` into every test that imports
|
||||
* anything from `@/components`, mocked or not. */
|
||||
title: string;
|
||||
body: string;
|
||||
retryLabel: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -13,47 +24,63 @@ interface State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary wrapper to save Application parts from falling
|
||||
* Error boundary wrapping a subtree of the app (a shell's content area). Renders a branded fallback with
|
||||
* a retry affordance (re-mounts the children by clearing `hasError`); the raw error + component stack are
|
||||
* **logged, never shown**, in production — only a development build renders the dev-only `<details>`
|
||||
* block.
|
||||
* @component ErrorBoundary
|
||||
* @param {string} [props.name] - name of the wrapped segment, "Error Boundary" by default
|
||||
*/
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
static defaultProps = {
|
||||
name: 'Error Boundary',
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
// The next render will show the Error UI
|
||||
return { hasError: true };
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// Save information to help render Error UI
|
||||
this.setState({ error, errorInfo });
|
||||
// TODO: Add log error messages to an error reporting service here
|
||||
console.error(`[ErrorBoundary:${this.props.name}]`, error, errorInfo.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Error UI rendering
|
||||
const { error, errorInfo } = this.state;
|
||||
return (
|
||||
<div>
|
||||
<h2>{this.props.name} - Something went wrong</h2>
|
||||
<details style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{this.state?.error?.toString()}
|
||||
<br />
|
||||
{this.state?.errorInfo?.componentStack}
|
||||
</details>
|
||||
</div>
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 1.5, py: 6, px: 2, textAlign: 'center' }}>
|
||||
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{this.props.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||
{this.props.body}
|
||||
</Typography>
|
||||
<AppButton color="primary" variant="outlined" onClick={this.handleRetry}>
|
||||
{this.props.retryLabel}
|
||||
</AppButton>
|
||||
{process.env.NODE_ENV !== 'production' && (error || errorInfo) ? (
|
||||
<Typography
|
||||
component="details"
|
||||
variant="caption"
|
||||
sx={{ color: 'text.secondary', maxWidth: 480, textAlign: 'start', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
<Typography component="summary" variant="caption" sx={{ cursor: 'pointer' }}>
|
||||
Dev-only error detail
|
||||
</Typography>
|
||||
{error?.toString()}
|
||||
{'\n'}
|
||||
{errorInfo?.componentStack}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal UI rendering
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import ErrorState from './ErrorState';
|
||||
|
||||
describe('<ErrorState/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders the message and calls onRetry on click', () => {
|
||||
const onRetry = jest.fn();
|
||||
wrap(<ErrorState message="Couldn't load" retryLabel="Retry" onRetry={onRetry} />);
|
||||
expect(screen.getByText("Couldn't load")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the caller-supplied retry label', () => {
|
||||
wrap(<ErrorState message="Failed" retryLabel="Try again" onRetry={jest.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppButton from '../AppButton';
|
||||
import AppIcon from '../AppIcon';
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Already-translated message — page-specific copy stays the caller's job. */
|
||||
message: string;
|
||||
/** Already-translated retry label (e.g. `t('common.retry')`) — kept caller-owned rather than a
|
||||
* component-internal default so this file never imports next-intl: it sits at the top of the
|
||||
* `common` barrel, and an eager i18n dependency there would drag `next-intl` into every test that
|
||||
* imports anything from `@/components`, mocked or not (see `ErrorBoundary` for the same rule). */
|
||||
retryLabel: string;
|
||||
/** Required — the convention this component exists to enforce: an error is never a dead end. */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calm, branded "something went wrong" panel with a **required** retry affordance — the fix for the
|
||||
* false-empty class of defect (a failed query rendering as "no data" instead of an error). `onRetry` is
|
||||
* mandatory by the type; there is no way to render this component without a way back. Presentational,
|
||||
* caller-owned i18n throughout — everything is already-translated copy.
|
||||
* @component ErrorState
|
||||
*/
|
||||
const ErrorState: FunctionComponent<ErrorStateProps> = ({ message, retryLabel, onRetry }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-error-state
|
||||
sx={{
|
||||
p: { xs: 3, sm: 5 },
|
||||
textAlign: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<Stack
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'var(--bal-error-soft)',
|
||||
}}
|
||||
>
|
||||
<AppIcon icon="error" size={28} color="var(--bal-error)" />
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default ErrorState;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ErrorState from './ErrorState';
|
||||
|
||||
export default ErrorState;
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import JalaliDateField from './JalaliDateField';
|
||||
|
||||
describe('<JalaliDateField/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders a read-only input showing the formatted value', () => {
|
||||
wrap(<JalaliDateField value="2026-07-17" onChange={jest.fn()} label="Date" />);
|
||||
const input = screen.getByLabelText('Date') as HTMLInputElement;
|
||||
expect(input).toHaveAttribute('readOnly');
|
||||
expect(input.value).not.toBe('');
|
||||
});
|
||||
|
||||
it('opens the picker popover on click and emits the picked ISO date', () => {
|
||||
const onChange = jest.fn();
|
||||
wrap(<JalaliDateField value="2026-07-15" onChange={onChange} label="Date" />);
|
||||
fireEvent.click(screen.getByLabelText('Date'));
|
||||
expect(screen.getByText('July 2026')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('20'));
|
||||
expect(onChange).toHaveBeenCalledWith('2026-07-20');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
import { FunctionComponent, MouseEvent, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import TextField, { TextFieldProps } from '@mui/material/TextField';
|
||||
import Box from '@mui/material/Box';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import AppIcon from '../AppIcon';
|
||||
import JalaliDatePicker from '../JalaliDatePicker';
|
||||
|
||||
export interface JalaliDateFieldProps
|
||||
extends Omit<TextFieldProps, 'value' | 'onChange' | 'select' | 'type' | 'InputProps'> {
|
||||
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */
|
||||
value: string | null;
|
||||
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
|
||||
onChange: (iso: string) => void;
|
||||
min?: string;
|
||||
max?: string;
|
||||
/** Already-translated accessible labels forwarded to the picker's month-navigation buttons. */
|
||||
prevMonthLabel?: string;
|
||||
nextMonthLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only text field that opens `JalaliDatePicker` (grid variant) in a popover — the replacement for
|
||||
* every native `<input type="date">` in the app (which only ever rendered the Gregorian calendar). Display
|
||||
* is Shamsi on `fa` / Gregorian on `en` via the same `utils/date.ts` formatter used everywhere else in the
|
||||
* app; the emitted value is always ISO Gregorian.
|
||||
* @component JalaliDateField
|
||||
*/
|
||||
const JalaliDateField: FunctionComponent<JalaliDateFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
prevMonthLabel,
|
||||
nextMonthLabel,
|
||||
...textFieldProps
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
// MUI Popover's anchor/transform origins are physical (left/right), not logical — resolve the reading
|
||||
// "start" edge from the locale ourselves so the popover opens on the correct side under RTL.
|
||||
const startEdge = locale === 'fa' ? 'right' : 'left';
|
||||
|
||||
const open = (event: MouseEvent<HTMLDivElement>) => setAnchorEl(event.currentTarget);
|
||||
const close = () => setAnchorEl(null);
|
||||
|
||||
const displayValue = value ? formatShamsiDate(value, locale) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
{...textFieldProps}
|
||||
value={displayValue}
|
||||
onClick={open}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
endAdornment: <AppIcon icon="calendar" size={20} color="var(--bal-text-secondary)" />,
|
||||
sx: { cursor: 'pointer' },
|
||||
},
|
||||
htmlInput: { readOnly: true, style: { cursor: 'pointer' } },
|
||||
}}
|
||||
/>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
anchorEl={anchorEl}
|
||||
onClose={close}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: startEdge }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: startEdge }}
|
||||
>
|
||||
<Box sx={{ p: 2, width: 320 }}>
|
||||
<JalaliDatePicker
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
prevMonthLabel={prevMonthLabel}
|
||||
nextMonthLabel={nextMonthLabel}
|
||||
onChange={(iso) => {
|
||||
onChange(iso);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default JalaliDateField;
|
||||
@@ -0,0 +1,4 @@
|
||||
import JalaliDateField from './JalaliDateField';
|
||||
|
||||
export default JalaliDateField;
|
||||
export type { JalaliDateFieldProps } from './JalaliDateField';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import JalaliDatePicker from './JalaliDatePicker';
|
||||
import { gregorianEngine, todayIso } from './calendarEngine';
|
||||
|
||||
describe('<JalaliDatePicker/> — grid variant', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('emits the ISO Gregorian date of the clicked day', () => {
|
||||
const onChange = jest.fn();
|
||||
const value = '2026-07-01';
|
||||
wrap(<JalaliDatePicker value={value} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText('15'));
|
||||
expect(onChange).toHaveBeenCalledWith('2026-07-15');
|
||||
});
|
||||
|
||||
it('marks today distinctly', () => {
|
||||
const { container } = wrap(<JalaliDatePicker value={null} onChange={jest.fn()} />);
|
||||
expect(container.querySelector(`[data-day="${todayIso()}"][data-today="true"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables days outside the min/max range', () => {
|
||||
const cursorMonth = gregorianEngine.fromIso('2026-07-15');
|
||||
const min = gregorianEngine.toIso(cursorMonth.year, cursorMonth.month, 10);
|
||||
wrap(<JalaliDatePicker value="2026-07-15" min={min} onChange={jest.fn()} />);
|
||||
const early = screen.getByText('5');
|
||||
expect(early.closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('navigates to the next month', () => {
|
||||
const { container } = wrap(<JalaliDatePicker value="2026-07-15" onChange={jest.fn()} />);
|
||||
expect(screen.getByText('July 2026')).toBeInTheDocument();
|
||||
const nextButton = container.querySelector('[data-icon="forward"]')?.closest('button');
|
||||
expect(nextButton).not.toBeNull();
|
||||
fireEvent.click(nextButton as HTMLButtonElement);
|
||||
expect(screen.getByText('August 2026')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<JalaliDatePicker/> — chips variant', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders chipDayCount tappable chips starting today and emits ISO on click', () => {
|
||||
const onChange = jest.fn();
|
||||
const { container } = wrap(<JalaliDatePicker variant="chips" value={null} onChange={onChange} chipDayCount={5} />);
|
||||
const chips = container.querySelectorAll('[data-day]');
|
||||
expect(chips.length).toBe(5);
|
||||
expect(chips[0]).toHaveAttribute('data-day', todayIso());
|
||||
fireEvent.click(chips[1]);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
import { FunctionComponent, KeyboardEvent, useMemo, useRef, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIconButton from '../AppIconButton';
|
||||
import { formatNumber, localeTag } from '@/utils';
|
||||
import { addDaysIso, engineForLocale, todayIso, type CalendarCursor } from './calendarEngine';
|
||||
|
||||
export type JalaliDatePickerVariant = 'grid' | 'chips';
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
/** The selected date as a wire ISO (Gregorian) `YYYY-MM-DD` string, or `null` for no selection. */
|
||||
value: string | null;
|
||||
/** Fired with the wire ISO (Gregorian) date of the day the user picked. */
|
||||
onChange: (iso: string) => void;
|
||||
/** Inclusive ISO lower bound. */
|
||||
min?: string;
|
||||
/** Inclusive ISO upper bound. */
|
||||
max?: string;
|
||||
/** `grid` (default) — a month calendar with navigation. `chips` — a horizontal day-chip strip for near
|
||||
* dates (the C4 booking-form shape), starting from `min` (or today) and running `chipDayCount` days. */
|
||||
variant?: JalaliDatePickerVariant;
|
||||
chipDayCount?: number;
|
||||
/** Already-translated accessible label for the previous/next-month buttons. */
|
||||
prevMonthLabel?: string;
|
||||
nextMonthLabel?: string;
|
||||
}
|
||||
|
||||
const GRID_COLUMNS = 7;
|
||||
|
||||
/**
|
||||
* Shamsi-native date selection. Renders Jalaali on `fa`, plain Gregorian on `en` — always **emits and
|
||||
* accepts ISO Gregorian** (the wire never sees a Jalaali date; display-only, per the money-and-types
|
||||
* convention already established for Shamsi dates in `utils/date.ts`). See `calendarEngine.ts` for the
|
||||
* arithmetic decision. Keyboard-navigable (arrow keys roving across the grid, Enter/Space to pick — RTL
|
||||
* flips left/right automatically), `min`/`max` bound both variants.
|
||||
* @component JalaliDatePicker
|
||||
*/
|
||||
const JalaliDatePicker: FunctionComponent<JalaliDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
variant = 'grid',
|
||||
chipDayCount = 14,
|
||||
prevMonthLabel,
|
||||
nextMonthLabel,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const engine = useMemo(() => engineForLocale(locale), [locale]);
|
||||
const dir = locale === 'fa' ? 'rtl' : 'ltr';
|
||||
|
||||
const initialCursor = useMemo<CalendarCursor>(() => {
|
||||
const base = value ?? min ?? todayIso();
|
||||
const { year, month } = engine.fromIso(base);
|
||||
return { year, month };
|
||||
}, [value, min, engine]);
|
||||
const [cursor, setCursor] = useState<CalendarCursor>(initialCursor);
|
||||
const cellRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
if (variant === 'chips') {
|
||||
const startIso = min && min > todayIso() ? min : todayIso();
|
||||
const days = Array.from({ length: chipDayCount }, (_, i) => addDaysIso(startIso, i));
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
dir={dir}
|
||||
data-jalali-picker="chips"
|
||||
sx={{ gap: 1, overflowX: 'auto', pb: 0.5, WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
{days.map((dayIso) => {
|
||||
const disabled = Boolean(max && dayIso > max);
|
||||
const selected = value === dayIso;
|
||||
const cell = engine.fromIso(dayIso);
|
||||
const weekday = new Intl.DateTimeFormat(localeTag(locale), { weekday: 'short' }).format(
|
||||
new Date(`${dayIso}T00:00:00`),
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
key={dayIso}
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(dayIso)}
|
||||
data-day={dayIso}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 'var(--bal-radius-sm)',
|
||||
border: '1px solid',
|
||||
borderColor: selected ? 'var(--bal-primary)' : 'var(--bal-divider)',
|
||||
bgcolor: selected ? 'var(--bal-primary-soft)' : 'transparent',
|
||||
color: disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{weekday}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: selected ? 700 : 500 }}>
|
||||
{formatNumber(cell.day, locale)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const daysInMonth = engine.daysInMonth(cursor.year, cursor.month);
|
||||
const offset = engine.firstWeekdayOffset(cursor.year, cursor.month);
|
||||
const cells: Array<{ iso: string; day: number } | null> = [
|
||||
...Array.from({ length: offset }, () => null),
|
||||
...Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const day = i + 1;
|
||||
return { iso: engine.toIso(cursor.year, cursor.month, day), day };
|
||||
}),
|
||||
];
|
||||
const todayIsoValue = todayIso();
|
||||
|
||||
const goToMonth = (delta: number) => setCursor((c) => engine.addMonths(c, delta));
|
||||
|
||||
const focusCell = (index: number) => {
|
||||
const el = cellRefs.current[index];
|
||||
if (el && !el.disabled) el.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const deltas: Record<string, number> = {
|
||||
ArrowRight: dir === 'rtl' ? -1 : 1,
|
||||
ArrowLeft: dir === 'rtl' ? 1 : -1,
|
||||
ArrowDown: GRID_COLUMNS,
|
||||
ArrowUp: -GRID_COLUMNS,
|
||||
};
|
||||
const delta = deltas[event.key];
|
||||
if (delta === undefined) return;
|
||||
event.preventDefault();
|
||||
focusCell(index + delta);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack dir={dir} data-jalali-picker="grid" sx={{ gap: 1.5, width: '100%' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<AppIconButton icon="back" title={prevMonthLabel} onClick={() => goToMonth(-1)} iconProps={{ size: 18 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{engine.monthYearLabel(cursor, locale === 'fa' ? 'fa-IR-u-ca-persian' : 'en-US')}
|
||||
</Typography>
|
||||
<AppIconButton icon="forward" title={nextMonthLabel} onClick={() => goToMonth(1)} iconProps={{ size: 18 }} />
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `repeat(${GRID_COLUMNS}, 1fr)`, gap: 0.5 }}>
|
||||
{engine.weekdayLabels(localeTag(locale)).map((label, i) => (
|
||||
<Typography key={i} variant="caption" align="center" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
))}
|
||||
{cells.map((cell, index) => {
|
||||
if (!cell) return <Box key={`pad-${index}`} />;
|
||||
const disabled = Boolean((min && cell.iso < min) || (max && cell.iso > max));
|
||||
const selected = value === cell.iso;
|
||||
const isToday = cell.iso === todayIsoValue;
|
||||
return (
|
||||
<Box
|
||||
key={cell.iso}
|
||||
component="button"
|
||||
type="button"
|
||||
ref={(el: HTMLElement | null) => {
|
||||
cellRefs.current[index] = el as HTMLButtonElement | null;
|
||||
}}
|
||||
disabled={disabled}
|
||||
tabIndex={selected || (!value && isToday) ? 0 : -1}
|
||||
onClick={() => onChange(cell.iso)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
data-day={cell.iso}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
data-today={isToday ? 'true' : undefined}
|
||||
sx={{
|
||||
aspectRatio: '1 / 1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
border: isToday && !selected ? '1px solid var(--bal-primary)' : '1px solid transparent',
|
||||
bgcolor: selected ? 'var(--bal-primary)' : 'transparent',
|
||||
color: selected ? 'var(--bal-primary-contrast)' : disabled ? 'var(--bal-text-secondary)' : 'var(--bal-text-primary)',
|
||||
fontWeight: selected ? 700 : 500,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
fontSize: '0.875rem',
|
||||
'&:hover': disabled ? undefined : { bgcolor: selected ? 'var(--bal-primary)' : 'var(--bal-primary-soft)' },
|
||||
}}
|
||||
>
|
||||
{formatNumber(cell.day, locale)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default JalaliDatePicker;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { jalaliEngine, gregorianEngine, addDaysIso } from './calendarEngine';
|
||||
|
||||
describe('jalaliEngine', () => {
|
||||
it('converts a known Gregorian ISO date to Jalaali', () => {
|
||||
// Reference pair documented by jalaali-js itself.
|
||||
expect(jalaliEngine.fromIso('2016-04-11')).toEqual({ year: 1395, month: 1, day: 23 });
|
||||
});
|
||||
|
||||
it('round-trips Jalaali -> ISO -> Jalaali', () => {
|
||||
const iso = jalaliEngine.toIso(1404, 5, 15);
|
||||
expect(jalaliEngine.fromIso(iso)).toEqual({ year: 1404, month: 5, day: 15 });
|
||||
});
|
||||
|
||||
it('knows Esfand length across a leap and a non-leap year', () => {
|
||||
expect(jalaliEngine.daysInMonth(1394, 12)).toBe(29);
|
||||
expect(jalaliEngine.daysInMonth(1395, 12)).toBe(30);
|
||||
});
|
||||
|
||||
it('adds months across a year boundary', () => {
|
||||
expect(jalaliEngine.addMonths({ year: 1404, month: 11 }, 2)).toEqual({ year: 1405, month: 1 });
|
||||
expect(jalaliEngine.addMonths({ year: 1404, month: 2 }, -3)).toEqual({ year: 1403, month: 11 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gregorianEngine', () => {
|
||||
it('is the identity conversion', () => {
|
||||
expect(gregorianEngine.fromIso('2026-07-17')).toEqual({ year: 2026, month: 7, day: 17 });
|
||||
expect(gregorianEngine.toIso(2026, 7, 17)).toBe('2026-07-17');
|
||||
});
|
||||
|
||||
it('knows February length across a leap and a non-leap year', () => {
|
||||
expect(gregorianEngine.daysInMonth(2023, 2)).toBe(28);
|
||||
expect(gregorianEngine.daysInMonth(2024, 2)).toBe(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDaysIso', () => {
|
||||
it('adds days across a month boundary', () => {
|
||||
expect(addDaysIso('2026-07-30', 3)).toBe('2026-08-02');
|
||||
});
|
||||
|
||||
it('subtracts days across a year boundary', () => {
|
||||
expect(addDaysIso('2026-01-01', -1)).toBe('2025-12-31');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The arithmetic layer behind `JalaliDatePicker`/`JalaliDateField`. Two small, pure "calendar engines"
|
||||
* (Jalaali for `fa`, plain Gregorian for `en`) share one shape so the picker's grid/navigation logic is
|
||||
* calendar-agnostic; both always emit/accept **ISO Gregorian** `YYYY-MM-DD` — the wire never sees Jalaali.
|
||||
*
|
||||
* Arithmetic decision: `Intl` (`fa-IR-u-ca-persian`, already used by `utils/date.ts`) is enough to
|
||||
* *display* a Shamsi date, but has no reverse direction — there is no way to ask Intl "what Gregorian
|
||||
* date is Jalaali 1404/5/15", which is exactly what a date *picker* (as opposed to a date *label*) needs
|
||||
* for month navigation and emitting the selected day. `jalaali-js` (MIT, zero runtime deps, ~9KB
|
||||
* unminified / low single-digit KB gzipped — see the phase report) supplies that reverse conversion
|
||||
* (`toGregorian`) plus month-length/leap-year math, so it's used here instead of hand-rolling the
|
||||
* Borkowski algorithm. Intl is still used for locale-correct month/weekday *names* — jalaali-js only
|
||||
* supplies numbers.
|
||||
*/
|
||||
import { toJalaali, toGregorian, jalaaliMonthLength } from 'jalaali-js';
|
||||
|
||||
export interface CalendarCursor {
|
||||
year: number;
|
||||
month: number; // 1-based
|
||||
}
|
||||
|
||||
export interface CalendarEngine {
|
||||
today(): CalendarCursor & { day: number };
|
||||
/** Parses a wire ISO (Gregorian) date into this calendar's year/month/day. */
|
||||
fromIso(iso: string): CalendarCursor & { day: number };
|
||||
/** Emits the wire ISO (Gregorian) date for a day in this calendar. */
|
||||
toIso(year: number, month: number, day: number): string;
|
||||
daysInMonth(year: number, month: number): number;
|
||||
addMonths(cursor: CalendarCursor, delta: number): CalendarCursor;
|
||||
/** 0-based offset of day 1 within the week grid (this engine's week-start convention). */
|
||||
firstWeekdayOffset(year: number, month: number): number;
|
||||
monthYearLabel(cursor: CalendarCursor, locale: string): string;
|
||||
/** 7 short weekday labels, in this engine's week-start order. */
|
||||
weekdayLabels(locale: string): string[];
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function isoOf(y: number, m: number, d: number): string {
|
||||
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||
}
|
||||
|
||||
function parseIso(iso: string): { y: number; m: number; d: number } {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return { y, m, d };
|
||||
}
|
||||
|
||||
/** Weekday of a Gregorian y/m/d as a UTC-anchored computation — immune to the runtime's local timezone. */
|
||||
function utcWeekday(y: number, m: number, d: number): number {
|
||||
return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0 = Sunday … 6 = Saturday
|
||||
}
|
||||
|
||||
function addMonthsGeneric(cursor: CalendarCursor, delta: number): CalendarCursor {
|
||||
const total = cursor.year * 12 + (cursor.month - 1) + delta;
|
||||
const year = Math.floor(total / 12);
|
||||
const month = ((total % 12) + 12) % 12;
|
||||
return { year, month: month + 1 };
|
||||
}
|
||||
|
||||
export const jalaliEngine: CalendarEngine = {
|
||||
today() {
|
||||
const { jy, jm, jd } = toJalaali(new Date());
|
||||
return { year: jy, month: jm, day: jd };
|
||||
},
|
||||
fromIso(iso) {
|
||||
const { y, m, d } = parseIso(iso);
|
||||
const { jy, jm, jd } = toJalaali(y, m, d);
|
||||
return { year: jy, month: jm, day: jd };
|
||||
},
|
||||
toIso(year, month, day) {
|
||||
const { gy, gm, gd } = toGregorian(year, month, day);
|
||||
return isoOf(gy, gm, gd);
|
||||
},
|
||||
daysInMonth(year, month) {
|
||||
return jalaaliMonthLength(year, month);
|
||||
},
|
||||
addMonths: addMonthsGeneric,
|
||||
firstWeekdayOffset(year, month) {
|
||||
const { gy, gm, gd } = toGregorian(year, month, 1);
|
||||
// Jalaali weeks start Saturday; utcWeekday is Sunday-based, so shift by one.
|
||||
return (utcWeekday(gy, gm, gd) + 1) % 7;
|
||||
},
|
||||
monthYearLabel(cursor, locale) {
|
||||
const { gy, gm, gd } = toGregorian(cursor.year, cursor.month, 1);
|
||||
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(gy, gm - 1, gd));
|
||||
},
|
||||
weekdayLabels(locale) {
|
||||
// A known Saturday (2024-01-06 is a Saturday) anchors the fa week-start order.
|
||||
const anchor = new Date(2024, 0, 6);
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(anchor);
|
||||
d.setDate(anchor.getDate() + i);
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const gregorianEngine: CalendarEngine = {
|
||||
today() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() };
|
||||
},
|
||||
fromIso(iso) {
|
||||
const { y, m, d } = parseIso(iso);
|
||||
return { year: y, month: m, day: d };
|
||||
},
|
||||
toIso: isoOf,
|
||||
daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
},
|
||||
addMonths: addMonthsGeneric,
|
||||
firstWeekdayOffset(year, month) {
|
||||
return utcWeekday(year, month, 1); // Gregorian week starts Sunday — no shift needed.
|
||||
},
|
||||
monthYearLabel(cursor, locale) {
|
||||
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(
|
||||
new Date(cursor.year, cursor.month - 1, 1),
|
||||
);
|
||||
},
|
||||
weekdayLabels(locale) {
|
||||
// A known Sunday (2024-01-07) anchors the en week-start order.
|
||||
const anchor = new Date(2024, 0, 7);
|
||||
return Array.from({ length: 7 }, (_, i) => {
|
||||
const d = new Date(anchor);
|
||||
d.setDate(anchor.getDate() + i);
|
||||
return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(d);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** Selects the display calendar for the app locale — `fa` gets Jalaali, everything else Gregorian. */
|
||||
export function engineForLocale(locale: string): CalendarEngine {
|
||||
return locale === 'fa' ? jalaliEngine : gregorianEngine;
|
||||
}
|
||||
|
||||
/** Adds whole days to an ISO (Gregorian) date via UTC arithmetic — DST-safe, calendar-agnostic. */
|
||||
export function addDaysIso(iso: string, days: number): string {
|
||||
const { y, m, d } = parseIso(iso);
|
||||
const next = new Date(Date.UTC(y, m - 1, d) + days * 86_400_000);
|
||||
return isoOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate());
|
||||
}
|
||||
|
||||
/** Today's date as a wire ISO string, from the local wall clock. */
|
||||
export function todayIso(): string {
|
||||
const now = new Date();
|
||||
return isoOf(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import JalaliDatePicker from './JalaliDatePicker';
|
||||
|
||||
export default JalaliDatePicker;
|
||||
export type { JalaliDatePickerProps, JalaliDatePickerVariant } from './JalaliDatePicker';
|
||||
export { engineForLocale, jalaliEngine, gregorianEngine, addDaysIso, todayIso } from './calendarEngine';
|
||||
export type { CalendarEngine, CalendarCursor } from './calendarEngine';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => (key === 'currency_toman' ? 'Toman' : key),
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import Money from './Money';
|
||||
|
||||
describe('<Money/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders the Toman-formatted amount with the unit label', () => {
|
||||
wrap(<Money amountIrr="45000000" />);
|
||||
expect(screen.getByText(/4,500,000/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Toman/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the unit label when hideUnit is set', () => {
|
||||
wrap(<Money amountIrr="45000000" hideUnit />);
|
||||
expect(screen.queryByText(/Toman/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefixes an explicit minus sign for a deduction, held with the digits', () => {
|
||||
wrap(<Money amountIrr="45000000" deduction />);
|
||||
const el = screen.getByText((_, node) => node?.textContent === '−4,500,000');
|
||||
expect(el).toHaveAttribute('dir', 'ltr');
|
||||
});
|
||||
|
||||
it('marks the root with data-deduction when a deduction', () => {
|
||||
const { container } = render(
|
||||
<ThemeProvider>
|
||||
<Money amountIrr="1000" deduction />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(container.querySelector('[data-money][data-deduction="true"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography, { TypographyProps } from '@mui/material/Typography';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
|
||||
export type MoneySize = 'sm' | 'md' | 'lg';
|
||||
export type MoneyTone = 'default' | 'emphasis' | 'muted' | 'error';
|
||||
|
||||
const SIZE_VARIANT: Record<MoneySize, TypographyProps['variant']> = {
|
||||
sm: 'body2',
|
||||
md: 'body1',
|
||||
lg: 'h6',
|
||||
};
|
||||
|
||||
// House weight system has no 600 face (see typography.ts) — 700 for the strong tones, 500/400 otherwise.
|
||||
const TONE_STYLE: Record<MoneyTone, { color: string; fontWeight: 400 | 500 | 700 }> = {
|
||||
default: { color: 'inherit', fontWeight: 500 },
|
||||
emphasis: { color: 'var(--bal-money-emphasis)', fontWeight: 700 },
|
||||
muted: { color: 'var(--bal-text-secondary)', fontWeight: 400 },
|
||||
error: { color: 'var(--bal-error)', fontWeight: 700 },
|
||||
};
|
||||
|
||||
export interface MoneyProps extends Omit<TypographyProps, 'children' | 'color'> {
|
||||
/** A served IRR digit-string, straight off the wire. `<Money>` only formats — it never computes. */
|
||||
amountIrr: string;
|
||||
size?: MoneySize;
|
||||
tone?: MoneyTone;
|
||||
/** Marks the amount as a deduction (a commission, a fee): prefixes an explicit `−` sign, held together
|
||||
* with the digits in a forced `dir="ltr"` run so the sign never gets swallowed inside RTL text — never
|
||||
* relies on a bare Unicode minus floating in a Persian paragraph. Defaults `tone` to `muted` unless the
|
||||
* caller overrides it. */
|
||||
deduction?: boolean;
|
||||
/** Hides the "تومان"/"Toman" unit label — the bare grouped number only. */
|
||||
hideUnit?: boolean;
|
||||
/** Renders the amount with a line-through (e.g. a superseded price next to a discounted one). */
|
||||
strikethrough?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single display primitive for every served IRR amount: formats via `utils/money.ts` (BigInt,
|
||||
* Toman + Persian digits) and never computes a figure itself. Replaces the 24 hand-assembled
|
||||
* `formatIrrToToman(x) + t('currency_toman')` call sites — including the ones that colored the total
|
||||
* terracotta (`--bal-secondary` fails AA contrast for small text; emphasis uses `--bal-money-emphasis`
|
||||
* instead, see the frontend-designer skill).
|
||||
* @component Money
|
||||
*/
|
||||
const Money: FunctionComponent<MoneyProps> = ({
|
||||
amountIrr,
|
||||
size = 'md',
|
||||
tone,
|
||||
deduction = false,
|
||||
hideUnit = false,
|
||||
strikethrough = false,
|
||||
sx,
|
||||
...rest
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
const effectiveTone = tone ?? (deduction ? 'muted' : 'default');
|
||||
const { color, fontWeight } = TONE_STYLE[effectiveTone];
|
||||
const formatted = formatIrrToToman(amountIrr, locale);
|
||||
|
||||
return (
|
||||
<Typography
|
||||
variant={SIZE_VARIANT[size]}
|
||||
component="span"
|
||||
data-money
|
||||
data-deduction={deduction ? 'true' : undefined}
|
||||
sx={{
|
||||
color,
|
||||
fontWeight,
|
||||
textDecoration: strikethrough ? 'line-through' : undefined,
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{deduction ? (
|
||||
<Box component="span" dir="ltr" sx={{ display: 'inline-block' }}>
|
||||
{'−'}
|
||||
{formatted}
|
||||
</Box>
|
||||
) : (
|
||||
formatted
|
||||
)}
|
||||
{hideUnit ? null : ` ${tc('currency_toman')}`}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default Money;
|
||||
@@ -0,0 +1,4 @@
|
||||
import Money from './Money';
|
||||
|
||||
export default Money;
|
||||
export type { MoneyProps, MoneySize, MoneyTone } from './Money';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import PageHeader from './PageHeader';
|
||||
|
||||
describe('<PageHeader/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders the title and subtitle', () => {
|
||||
wrap(<PageHeader title="Patients" subtitle="Manage your family" />);
|
||||
expect(screen.getByRole('heading', { name: 'Patients' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Manage your family')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the actions slot when provided', () => {
|
||||
wrap(<PageHeader title="T" actions={<button>Do</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the back button when backTo is not given', () => {
|
||||
wrap(<PageHeader title="T" />);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a back link when backTo is given', () => {
|
||||
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIconButton from '../AppIconButton';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
/** Already-translated title (i18n is the caller's job — labels are keys). */
|
||||
title: string;
|
||||
/** Optional already-translated subtitle. */
|
||||
subtitle?: string;
|
||||
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
|
||||
actions?: 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). */
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"`).
|
||||
* @component PageHeader
|
||||
*/
|
||||
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
|
||||
<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 }} />
|
||||
) : null}
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PageHeader;
|
||||
@@ -0,0 +1,4 @@
|
||||
import PageHeader from './PageHeader';
|
||||
|
||||
export default PageHeader;
|
||||
export type { PageHeaderProps } from './PageHeader';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import QueryStateGate from './QueryStateGate';
|
||||
|
||||
describe('<QueryStateGate/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
const base = {
|
||||
onRetry: jest.fn(),
|
||||
errorMessage: 'Failed',
|
||||
retryLabel: 'Retry',
|
||||
skeleton: <div>SKELETON</div>,
|
||||
empty: <div>EMPTY</div>,
|
||||
};
|
||||
|
||||
it('renders the skeleton while loading, even if isError/isEmpty are also true', () => {
|
||||
wrap(
|
||||
<QueryStateGate {...base} isLoading isError isEmpty>
|
||||
<div>DATA</div>
|
||||
</QueryStateGate>,
|
||||
);
|
||||
expect(screen.getByText('SKELETON')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error state before checking isEmpty — an error is never an empty', () => {
|
||||
wrap(
|
||||
<QueryStateGate {...base} isLoading={false} isError isEmpty>
|
||||
<div>DATA</div>
|
||||
</QueryStateGate>,
|
||||
);
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('EMPTY')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty when not loading/erroring but isEmpty', () => {
|
||||
wrap(
|
||||
<QueryStateGate {...base} isLoading={false} isError={false} isEmpty>
|
||||
<div>DATA</div>
|
||||
</QueryStateGate>,
|
||||
);
|
||||
expect(screen.getByText('EMPTY')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when data is present', () => {
|
||||
wrap(
|
||||
<QueryStateGate {...base} isLoading={false} isError={false} isEmpty={false}>
|
||||
<div>DATA</div>
|
||||
</QueryStateGate>,
|
||||
);
|
||||
expect(screen.getByText('DATA')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import ErrorState from '../ErrorState';
|
||||
|
||||
export interface QueryStateGateProps {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRetry: () => void;
|
||||
isEmpty: boolean;
|
||||
/** Already-translated, page-specific error copy — passed straight to `ErrorState`. */
|
||||
errorMessage: string;
|
||||
/** Already-translated retry label — passed straight to `ErrorState` (e.g. `t('common.retry')`). */
|
||||
retryLabel: string;
|
||||
/** Rendered while `isLoading` — pass a shaped skeleton, not a bare spinner. */
|
||||
skeleton: ReactNode;
|
||||
/** Rendered when the query succeeded but returned nothing. */
|
||||
empty: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The convention this phase exists to enforce, as one component: **an errored query must never render
|
||||
* as empty**. Branches in a fixed order — skeleton → error (with a mandatory retry, via `ErrorState`) →
|
||||
* empty → data — so a page can't accidentally check `isEmpty` before `isError` and show "no results" for
|
||||
* a network failure. Pages with naturally inline branching (infinite lists with `fetchNextPage`) may
|
||||
* follow the same order by hand instead of reaching for this component; the convention is what matters.
|
||||
* @component QueryStateGate
|
||||
*/
|
||||
const QueryStateGate: FunctionComponent<QueryStateGateProps> = ({
|
||||
isLoading,
|
||||
isError,
|
||||
onRetry,
|
||||
isEmpty,
|
||||
errorMessage,
|
||||
retryLabel,
|
||||
skeleton,
|
||||
empty,
|
||||
children,
|
||||
}) => {
|
||||
if (isLoading) return <>{skeleton}</>;
|
||||
if (isError) return <ErrorState message={errorMessage} retryLabel={retryLabel} onRetry={onRetry} />;
|
||||
if (isEmpty) return <>{empty}</>;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default QueryStateGate;
|
||||
@@ -0,0 +1,4 @@
|
||||
import QueryStateGate from './QueryStateGate';
|
||||
|
||||
export default QueryStateGate;
|
||||
export type { QueryStateGateProps } from './QueryStateGate';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import StatusTimeline, { TimelineNode } from './StatusTimeline';
|
||||
|
||||
describe('<StatusTimeline/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
const nodes: TimelineNode[] = [
|
||||
{ key: 'submitted', label: 'Submitted', state: 'completed', timestamp: 'Jul 1' },
|
||||
{ key: 'on_its_way', label: 'On its way', state: 'current', note: 'Bank transfer' },
|
||||
{ key: 'completed', label: 'Completed', state: 'pending' },
|
||||
];
|
||||
|
||||
it('renders every node label, timestamp, and note', () => {
|
||||
wrap(<StatusTimeline nodes={nodes} />);
|
||||
expect(screen.getByText('Submitted')).toBeInTheDocument();
|
||||
expect(screen.getByText('Jul 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('On its way')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bank transfer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Completed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes each node state via a data attribute', () => {
|
||||
const { container } = render(<StatusTimeline nodes={nodes} />);
|
||||
expect(container.querySelector('[data-node="submitted"][data-node-state="completed"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-node="on_its_way"][data-node-state="current"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a failed node distinctly', () => {
|
||||
const { container } = render(
|
||||
<StatusTimeline nodes={[{ key: 'failed', label: 'Failed', state: 'failed' }]} />,
|
||||
);
|
||||
expect(container.querySelector('[data-node-state="failed"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
import { FunctionComponent } from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AppIcon from '../AppIcon';
|
||||
|
||||
export type TimelineNodeState = 'completed' | 'current' | 'pending' | 'failed';
|
||||
|
||||
export interface TimelineNode {
|
||||
/** Stable key — also exposed as `data-node` for tests. */
|
||||
key: string;
|
||||
/** Already-translated label. */
|
||||
label: string;
|
||||
/** Already-translated, formatted timestamp (e.g. a Shamsi date/time). */
|
||||
timestamp?: string;
|
||||
/** Already-translated freeform note (a channel, a reference). */
|
||||
note?: string;
|
||||
state: TimelineNodeState;
|
||||
}
|
||||
|
||||
export interface StatusTimelineProps {
|
||||
nodes: TimelineNode[];
|
||||
}
|
||||
|
||||
const pulse = keyframes`
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--bal-primary-soft); }
|
||||
50% { box-shadow: 0 0 0 6px var(--bal-primary-soft); }
|
||||
`;
|
||||
|
||||
const DOT_SIZE = 22;
|
||||
|
||||
function dotStyle(state: TimelineNodeState) {
|
||||
switch (state) {
|
||||
case 'completed':
|
||||
return { bg: 'var(--bal-primary)', border: 'var(--bal-primary)' };
|
||||
case 'current':
|
||||
return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-primary)' };
|
||||
case 'failed':
|
||||
return { bg: 'var(--bal-error)', border: 'var(--bal-error)' };
|
||||
case 'pending':
|
||||
default:
|
||||
return { bg: 'var(--bal-bg-paper)', border: 'var(--bal-divider)' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A designed vertical status timeline: node = label + optional timestamp + optional note, with four
|
||||
* states — `completed` (filled), `current` (an animated ring, respecting `prefers-reduced-motion`),
|
||||
* `pending` (outlined), and `failed` (a distinct terminal marker, never styled as if progress continues
|
||||
* past it). Replaces `StepperHeader` misused as a status display (the refund progress card, the booking
|
||||
* lifecycle) — `StepperHeader` itself stays reserved for real multi-step wizards.
|
||||
* @component StatusTimeline
|
||||
*/
|
||||
const StatusTimeline: FunctionComponent<StatusTimelineProps> = ({ nodes }) => (
|
||||
<Stack data-status-timeline sx={{ gap: 0 }}>
|
||||
{nodes.map((node, index) => {
|
||||
const isLast = index === nodes.length - 1;
|
||||
const { bg, border } = dotStyle(node.state);
|
||||
return (
|
||||
<Stack key={node.key} direction="row" sx={{ gap: 1.5 }} data-node={node.key} data-node-state={node.state}>
|
||||
<Stack sx={{ alignItems: 'center', width: DOT_SIZE }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
bgcolor: bg,
|
||||
border: '2px solid',
|
||||
borderColor: border,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...(node.state === 'current' && {
|
||||
'@media (prefers-reduced-motion: no-preference)': {
|
||||
animation: `${pulse} 1.6s ease-in-out infinite`,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{node.state === 'completed' && <AppIcon icon="verified" size={14} color="var(--bal-primary-contrast)" />}
|
||||
{node.state === 'current' && (
|
||||
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)' }} />
|
||||
)}
|
||||
{node.state === 'failed' && <AppIcon icon="close" size={14} color="var(--bal-error-contrast)" />}
|
||||
</Box>
|
||||
{!isLast && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 28,
|
||||
bgcolor: node.state === 'completed' ? 'var(--bal-primary)' : 'var(--bal-divider)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack sx={{ pb: isLast ? 0 : 2.5, gap: 0.25, pt: 0.25 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: node.state === 'current' ? 700 : 500,
|
||||
color: node.state === 'pending' ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
>
|
||||
{node.label}
|
||||
</Typography>
|
||||
{node.timestamp ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{node.timestamp}
|
||||
</Typography>
|
||||
) : null}
|
||||
{node.note ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{node.note}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default StatusTimeline;
|
||||
@@ -0,0 +1,4 @@
|
||||
import StatusTimeline from './StatusTimeline';
|
||||
|
||||
export default StatusTimeline;
|
||||
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import SurfaceCard from './SurfaceCard';
|
||||
|
||||
describe('<SurfaceCard/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
it('renders its children', () => {
|
||||
wrap(<SurfaceCard>content</SurfaceCard>);
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes through data attributes and other Paper props', () => {
|
||||
wrap(<SurfaceCard data-testid="card">content</SurfaceCard>);
|
||||
expect(screen.getByTestId('card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import Paper, { PaperProps } from '@mui/material/Paper';
|
||||
|
||||
export type SurfaceCardPadding = 'sm' | 'md' | 'lg';
|
||||
|
||||
const PADDING_SCALE: Record<SurfaceCardPadding, number> = {
|
||||
sm: 2,
|
||||
md: 2.5,
|
||||
lg: 3,
|
||||
};
|
||||
|
||||
export interface SurfaceCardProps extends Omit<PaperProps, 'elevation' | 'variant'> {
|
||||
/** Padding scale — `sm`≈16px / `md`≈20px / `lg`≈24px (theme spacing units 2/2.5/3). Default `md`. */
|
||||
padding?: SurfaceCardPadding;
|
||||
}
|
||||
|
||||
/**
|
||||
* The de-facto card anatomy this app already hand-rolls ~12 times, codified once: a flat `Paper`
|
||||
* (`elevation={0}`), a 1px `divider` border, the house radius, and one padding scale. Every plain card
|
||||
* (`EarningsRow`, `PatientCard`, `VariantCard`, `PriceBreakdown`, …) should compose this instead of
|
||||
* re-declaring the same `sx`. Purely a visual shell — no state, no i18n.
|
||||
* @component SurfaceCard
|
||||
*/
|
||||
const SurfaceCard: FunctionComponent<SurfaceCardProps> = ({ padding = 'md', sx, children, ...rest }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: PADDING_SCALE[padding],
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default SurfaceCard;
|
||||
@@ -0,0 +1,4 @@
|
||||
import SurfaceCard from './SurfaceCard';
|
||||
|
||||
export default SurfaceCard;
|
||||
export type { SurfaceCardProps, SurfaceCardPadding } from './SurfaceCard';
|
||||
@@ -5,5 +5,46 @@ import AppIconButton from './AppIconButton';
|
||||
import AppLink from './AppLink';
|
||||
import AppLoading from './AppLoading';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import EmptyState from './EmptyState';
|
||||
import ErrorState from './ErrorState';
|
||||
import QueryStateGate from './QueryStateGate';
|
||||
import PageHeader from './PageHeader';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import SurfaceCard from './SurfaceCard';
|
||||
import AccentCard from './AccentCard';
|
||||
import Money from './Money';
|
||||
import StatusTimeline from './StatusTimeline';
|
||||
import JalaliDatePicker from './JalaliDatePicker';
|
||||
import JalaliDateField from './JalaliDateField';
|
||||
|
||||
export { ErrorBoundary, AppAlert, AppButton, AppIcon, AppIconButton, AppLink, AppLoading };
|
||||
export {
|
||||
ErrorBoundary,
|
||||
AppAlert,
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppIconButton,
|
||||
AppLink,
|
||||
AppLoading,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
QueryStateGate,
|
||||
PageHeader,
|
||||
ConfirmDialog,
|
||||
SurfaceCard,
|
||||
AccentCard,
|
||||
Money,
|
||||
StatusTimeline,
|
||||
JalaliDatePicker,
|
||||
JalaliDateField,
|
||||
};
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
export type { QueryStateGateProps } from './QueryStateGate';
|
||||
export type { PageHeaderProps } from './PageHeader';
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog';
|
||||
export type { SurfaceCardProps } from './SurfaceCard';
|
||||
export type { AccentCardProps, AccentTone } from './AccentCard';
|
||||
export type { MoneyProps } from './Money';
|
||||
export type { StatusTimelineProps, TimelineNode, TimelineNodeState } from './StatusTimeline';
|
||||
export type { JalaliDatePickerProps } from './JalaliDatePicker';
|
||||
export type { JalaliDateFieldProps } from './JalaliDateField';
|
||||
|
||||
@@ -32,9 +32,3 @@ export const APP_ICON_SIZE = 24;
|
||||
export const APP_LINK_COLOR = 'textSecondary'; // 'primary' // 'secondary'
|
||||
export const APP_LINK_UNDERLINE = 'hover'; // 'always
|
||||
|
||||
/**
|
||||
* AppLoading component
|
||||
*/
|
||||
export const APP_LOADING_COLOR = 'primary'; // 'secondary'
|
||||
export const APP_LOADING_SIZE = '3rem'; // 40
|
||||
export const APP_LOADING_TYPE = 'circular'; // 'linear'; // 'circular'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
@@ -115,4 +116,19 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketListCard;
|
||||
/** Matches the real card's anatomy (icon/subject/status row, reference line, footer row) for the ticket inbox. */
|
||||
const TicketListCardSkeleton: FunctionComponent = () => (
|
||||
<Box sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'var(--bal-bg-paper)' }}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<Skeleton variant="circular" width={18} height={18} />
|
||||
<Skeleton variant="text" width="50%" height={22} sx={{ flexGrow: 1 }} />
|
||||
<Skeleton variant="rounded" width={64} height={22} />
|
||||
</Stack>
|
||||
<Skeleton variant="text" width={110} height={16} />
|
||||
<Skeleton variant="text" width="70%" height={16} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default Object.assign(TicketListCard, { Skeleton: TicketListCardSkeleton });
|
||||
|
||||
Reference in New Issue
Block a user