manual improvement 1
This commit is contained in:
@@ -35,7 +35,7 @@ interface NudgeCardProps {
|
||||
const NudgeCard: FunctionComponent<NudgeCardProps> = ({ icon, title, body, ctaLabel, to, onDismiss, dismissLabel }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2, position: 'relative' }}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', gap: 2, position: 'relative' }}
|
||||
>
|
||||
<AppIcon icon={icon} size={28} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 1, flexGrow: 1, minWidth: 0 }}>
|
||||
@@ -243,7 +243,7 @@ const CategoryGrid: FunctionComponent<{ onSelect: (categoryId: number) => void }
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function CancelBookingPage() {
|
||||
{/* Off-ramps before the kill switch — exits, not obstacles; the destructive path stays fully
|
||||
available below. Real rescheduling is DEFERRED (product decision + backend); this opens a
|
||||
coordination ticket instead. */}
|
||||
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('offramp_note')}
|
||||
</Typography>
|
||||
@@ -211,7 +211,7 @@ export default function CancelBookingPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function BookingInvoicePage() {
|
||||
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
|
||||
if (!validId) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={44} color="var(--bal-error)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
@@ -89,7 +89,7 @@ export default function BookingInvoicePage() {
|
||||
if (!invoice) {
|
||||
const notIssued = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
@@ -166,7 +166,7 @@ export default function BookingInvoicePage() {
|
||||
<Paper
|
||||
elevation={0}
|
||||
className={PRINT_AREA_CLASS}
|
||||
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function LeaveReviewPage() {
|
||||
<Stack sx={{ gap: 3, maxWidth: 560, mx: 'auto', width: '100%' }}>
|
||||
<PageHeading title={t('my_review_title')} subtitle={nurseName ? t('for_nurse', { name: nurseName }) : undefined} />
|
||||
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
|
||||
<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.5 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={STATUS_KIND[status]} label={t(`status_${status}`)} />
|
||||
@@ -149,7 +149,7 @@ export default function LeaveReviewPage() {
|
||||
{booking ? <ReviewContextRecap booking={booking} locale={locale} /> : null}
|
||||
|
||||
{/* Moderation expectation, up front — not only after submit. */}
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-info-soft)' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-info-soft)' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-info)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-info)' }}>
|
||||
{t('moderation_note')}
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ const EligibilityStep: FunctionComponent<EligibilityStepProps> = ({
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--bal-success)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
@@ -190,7 +190,7 @@ function DeclinedPanel({
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
|
||||
sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'var(--bal-error)', textAlign: 'center' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="rejected" size={36} color="var(--bal-error)" />
|
||||
|
||||
+3
-3
@@ -36,7 +36,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
|
||||
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payable_amount')}
|
||||
@@ -51,7 +51,7 @@ const MethodStep: FunctionComponent<MethodStepProps> = ({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
p: 1.75,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
@@ -128,7 +128,7 @@ function ProviderOption({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
p: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: selected ? 'var(--bal-secondary)' : 'divider',
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ const PlanStep: FunctionComponent<PlanStepProps> = ({
|
||||
{shownPlan ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
|
||||
// Handoff in progress — the provider redirect is being followed.
|
||||
if (busy) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<CircularProgress color="secondary" size="2.5rem" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
@@ -109,7 +109,7 @@ const ScheduleStep: FunctionComponent<ScheduleStepProps> = ({
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.75,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'var(--bal-secondary)',
|
||||
backgroundColor: 'var(--bal-secondary-soft)',
|
||||
|
||||
@@ -245,7 +245,7 @@ function CheckoutScreen() {
|
||||
</Stack>
|
||||
|
||||
{summary.paymentDeadlineAt ? (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={summary.paymentDeadlineAt}
|
||||
label={tb('payment_countdown_label')}
|
||||
@@ -284,7 +284,7 @@ function CheckoutScreen() {
|
||||
width: 300,
|
||||
}}
|
||||
>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
{payActions}
|
||||
</Paper>
|
||||
</Box>
|
||||
@@ -310,7 +310,7 @@ function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; l
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<Avatar
|
||||
src={summary.nurseAvatarUrl ?? undefined}
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ function ReturnScreen() {
|
||||
// Pending-callback (and the brief succeeded → confirmation hand-off): a staged 2-node progress instead
|
||||
// of a bare spinner+chip+title stack — the flow's calmest, most designed wait state.
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 2.5, alignItems: 'center' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, textAlign: 'center' }}>
|
||||
{t('state_pending_title')}
|
||||
|
||||
@@ -213,7 +213,7 @@ export default function BookingRequestStatusPage() {
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
@@ -247,7 +247,7 @@ export default function BookingRequestStatusPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={request.nurseResponseDeadlineAt}
|
||||
@@ -329,7 +329,7 @@ function TerminalCard({
|
||||
secondary?: TerminalAction;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
|
||||
{title}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function PatientRecordPage() {
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', backgroundColor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="family" size={22} color="var(--bal-primary)" />
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-primary)', fontWeight: 500 }}>
|
||||
@@ -296,7 +296,7 @@ function SheetActions({
|
||||
// A tappable row surface shared by the three editable tabs — mirrors PatientCard's press affordance.
|
||||
function RowCard({ onOpen, children }: { onOpen?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
|
||||
{onOpen ? (
|
||||
<AppButton
|
||||
variant="text"
|
||||
@@ -724,7 +724,7 @@ function TasksTab({
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{data.map((task) => (
|
||||
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1 }}>
|
||||
<Paper key={task.id} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={task.done} onChange={() => (canEdit ? toggleDone(task) : undefined)} disabled={!canEdit || saving} />}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ProfileSummary,
|
||||
} from '@/components';
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher';
|
||||
import { ThemeModeSetting } from '@/components/settings';
|
||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { digitsOnly } from '@/utils';
|
||||
@@ -143,6 +144,9 @@ const AccountHub: FunctionComponent<{
|
||||
/>
|
||||
<AccountRow icon="location" label={t('row_addresses')} onClick={() => goTo(ROUTES.ADDRESSES)} />
|
||||
<AccountRow icon="language" label={t('row_language')} onClick={() => setLanguageSheetOpen(true)} />
|
||||
{/* The app's appearance control lives here (and in each other actor's settings hub) — it
|
||||
used to occupy a permanent slot in every top bar for a preference set once. */}
|
||||
<ThemeModeSetting />
|
||||
<AccountRow icon="notifications" label={t('row_notifications')} onClick={() => goTo(ROUTES.NOTIFICATIONS)} />
|
||||
<AccountRow icon="support" label={t('row_support')} onClick={() => goTo(ROUTES.SUPPORT_TICKETS)} />
|
||||
</Stack>
|
||||
@@ -287,7 +291,7 @@ const AccountRow: FunctionComponent<{
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
color: tone === 'error' ? 'var(--bal-error)' : 'text.primary',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
@@ -309,7 +313,7 @@ const EmergencyContactCard: FunctionComponent<{
|
||||
}> = ({ complete, name, phone, onEdit }) => {
|
||||
const t = useTranslations('profile');
|
||||
return (
|
||||
<Box sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1.5 }}>
|
||||
<AppIcon icon={complete ? 'verified' : 'emergency'} size={22} color={complete ? 'var(--bal-success)' : 'var(--bal-warning)'} />
|
||||
<Stack sx={{ flexGrow: 1, gap: 0.25, minWidth: 0 }}>
|
||||
|
||||
@@ -213,7 +213,7 @@ const CategorySelect: FunctionComponent<{ selectedId: number | null; onSelect: (
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2, 1fr)', sm: 'repeat(3, 1fr)' }, gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Box>
|
||||
) : isError ? (
|
||||
|
||||
@@ -355,7 +355,7 @@ function ReviewCard({ review }: { review: ReviewListItem }) {
|
||||
const t = useTranslations('reviews');
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
|
||||
<RatingInput value={review.rating} readOnly size={16} ariaLabel={t('rating_label')} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', marginInlineStart: 'auto' }}>
|
||||
|
||||
@@ -29,7 +29,7 @@ const WalletInstallments: FunctionComponent = () => {
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 3, borderRadius: 2, border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Paper elevation={0} sx={{ p: 3, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', textAlign: 'center' }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="warning" size={36} color="var(--bal-warning)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
@@ -71,7 +71,7 @@ function InstallmentPlanSection({ plan }: { plan: WalletInstallmentPlan }) {
|
||||
{/* Outstanding-balance card — terracotta financial accent; contrast text is scheme-stable. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.25, borderRadius: 3, backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
|
||||
sx={{ p: 2.25, borderRadius: 'var(--bal-radius-lg)', backgroundColor: 'var(--bal-secondary)', color: 'var(--bal-secondary-contrast)' }}
|
||||
>
|
||||
<Typography variant="caption" sx={{ opacity: 0.85 }}>
|
||||
{t('outstanding_balance')}
|
||||
|
||||
+5
-5
@@ -3,12 +3,12 @@ import Stack from '@mui/material/Stack';
|
||||
import SurfaceCard from '@/components/common/SurfaceCard';
|
||||
|
||||
/**
|
||||
* The shared loading skeleton for the sidebar-shell route groups (`nurse`, `admin`, `partner`) — the
|
||||
* `TopBarAndSideBarLayout` chrome (top bar + sidebar) is already rendered by the enclosing `layout.tsx`
|
||||
* by the time this shows, so this only needs to shape the content area: a heading line + a short stack of
|
||||
* generic worklist/detail cards. A private (`_`-prefixed) folder — not a route.
|
||||
* The shared loading skeleton for the nurse/admin/partner route groups. The `MobileShell` chrome
|
||||
* (top bar + bottom nav) is already rendered by the enclosing `layout.tsx` by the time this shows,
|
||||
* so this only shapes the content area: a heading line + a short stack of generic worklist cards.
|
||||
* A private (`_`-prefixed) folder — not a route.
|
||||
*/
|
||||
export default function SidebarShellSkeleton() {
|
||||
export default function ShellContentSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="text" width={220} height={32} />
|
||||
@@ -1,81 +1,63 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Typography } from '@mui/material';
|
||||
import { AppIcon, AppLink } from '@/components';
|
||||
import { AdminPageHeader } from '@/components/admin';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Stack } from '@mui/material';
|
||||
import { NavHubList, PageHeader } from '@/components';
|
||||
import type { NavHubItem } from '@/components';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
|
||||
interface ConsoleEntry extends NavHubItem {
|
||||
/** Section this console belongs to — the same four groups the bottom nav carries. */
|
||||
section: 'trust' | 'finance' | 'support' | 'system';
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
||||
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
||||
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
||||
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
||||
* Admin overview landing (f15) — the backoffice index. Every worklist the current principal may
|
||||
* act on, grouped by the same four sections as the bottom nav, so the overview and the tabs agree
|
||||
* on where a console lives. Gating comes from `useAdminCapabilities()` (a UI hint; the server
|
||||
* still enforces every command's role scope): a `support` admin sees verification/tickets/alerts,
|
||||
* a `finance` admin sees payouts/config, only a `super_admin` sees roles.
|
||||
*
|
||||
* The old 3-column card grid is gone — inside a phone-width frame it collapsed to a single column
|
||||
* of oversized tiles carrying nothing but an icon and one word each.
|
||||
*/
|
||||
export default function AdminOverviewScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const th = useTranslations('hub');
|
||||
const tNav = useTranslations('nav');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
// `key` doubles as the `nav` i18n key for the card label.
|
||||
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
||||
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||
].filter((c) => c.enabled);
|
||||
const consoles: ConsoleEntry[] = [
|
||||
{ section: 'trust', title: tNav('verification'), subtitle: th('admin_verification_sub'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||
{ section: 'trust', title: tNav('reviews'), subtitle: th('admin_reviews_sub'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||
{ section: 'finance', title: tNav('payouts'), subtitle: th('admin_payouts_sub'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
{ section: 'system', title: tNav('users'), subtitle: th('admin_users_sub'), path: ROUTES.ADMIN_USERS, icon: 'users', enabled: caps.canManageRoles },
|
||||
{ section: 'system', title: tNav('roles'), subtitle: th('admin_roles_sub'), path: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||
];
|
||||
|
||||
const sections: Array<{ key: ConsoleEntry['section']; label: string }> = [
|
||||
{ key: 'trust', label: tNav('group_trust') },
|
||||
{ key: 'finance', label: tNav('group_finance') },
|
||||
{ key: 'support', label: tNav('group_support') },
|
||||
{ key: 'system', label: tNav('group_system') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<PageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{consoles.map((c) => (
|
||||
<AppLink
|
||||
key={c.key}
|
||||
to={`/${locale}${c.route}`}
|
||||
color="inherit"
|
||||
underline="none"
|
||||
sx={{ display: 'block', height: '100%' }}
|
||||
>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{tNav(c.key)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</AppLink>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
{sections.map((section) => {
|
||||
const items = consoles.filter((entry) => entry.section === section.key && entry.enabled);
|
||||
if (items.length === 0) return null;
|
||||
return <NavHubList key={section.key} title={section.label} items={items} />;
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { EmptyState, NavHubList, PageHeader } from '@/components';
|
||||
import type { NavHubItem } from '@/components';
|
||||
|
||||
export interface AdminGroupConsole extends NavHubItem {
|
||||
/** Capability gate for this console — a hidden row is one the current admin role can't act on. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
consoles: Array<AdminGroupConsole>;
|
||||
/** Rendered below the console list (the system group's settings + sign-out). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body every admin group-root page shares: a header, the capability-filtered consoles in that
|
||||
* group, and an optional tail. Gating stays per-console and is still only a UI hint — the server
|
||||
* authorizes every command regardless of what the nav shows.
|
||||
* A private (`_`-prefixed) folder, so this is not itself a route.
|
||||
* @component AdminGroupHub
|
||||
*/
|
||||
const AdminGroupHub: FunctionComponent<Props> = ({ title, subtitle, consoles, children }) => {
|
||||
const t = useTranslations('hub');
|
||||
const permitted = consoles.filter((console_) => console_.enabled);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={title} subtitle={subtitle} />
|
||||
{permitted.length > 0 ? (
|
||||
<NavHubList items={permitted} />
|
||||
) : (
|
||||
<EmptyState icon="lock" title={t('admin_group_empty_title')} body={t('admin_group_empty_body')} />
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminGroupHub;
|
||||
@@ -71,7 +71,7 @@ function AdminAuditPageInner() {
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
|
||||
@@ -229,7 +229,7 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
|
||||
) : (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{history.data?.items.map((change) => (
|
||||
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
|
||||
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{t('cfg_history_change_old', { old: change.oldValue ?? '—' })}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import AdminGroupHub from '../_hub/AdminGroupHub';
|
||||
|
||||
/** «مالی» group root — the weekly payout dashboard. */
|
||||
export default function AdminFinancePage() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
return (
|
||||
<AdminGroupHub
|
||||
title={tn('group_finance')}
|
||||
subtitle={t('admin_finance_subtitle')}
|
||||
consoles={[
|
||||
{
|
||||
title: tn('payouts'),
|
||||
subtitle: t('admin_payouts_sub'),
|
||||
icon: 'earnings',
|
||||
path: ROUTES.ADMIN_PAYOUTS,
|
||||
enabled: caps.canPayout,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
return <ShellContentSkeleton />;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
|
||||
/>
|
||||
|
||||
<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 divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
|
||||
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
|
||||
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
|
||||
@@ -174,7 +174,7 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
{roster.isLoading ? (
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
) : (
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{(roster.data ?? []).map((nurse) => (
|
||||
<Stack
|
||||
|
||||
@@ -83,7 +83,7 @@ function AdminPayoutBatchDetailScreen() {
|
||||
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||
) : (
|
||||
<>
|
||||
<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.5 }}>
|
||||
<StatusChip
|
||||
status={BATCH_STATUS_KIND[data.batch.status]}
|
||||
@@ -180,7 +180,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
@@ -217,7 +217,7 @@ const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; c
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
|
||||
@@ -283,7 +283,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
—
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
{result.eligible.map((n) => (
|
||||
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
|
||||
<Stack
|
||||
@@ -317,7 +317,7 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{t('payout_skipped')}
|
||||
</Typography>
|
||||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
{result.skipped.map((n) => (
|
||||
<Stack
|
||||
key={n.nurseId}
|
||||
|
||||
@@ -166,7 +166,7 @@ function ModerationCard({
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import AdminGroupHub from '../_hub/AdminGroupHub';
|
||||
|
||||
/** «پشتیبانی» group root — the global ticket queue and the internal alert worklist. */
|
||||
export default function AdminSupportPage() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
return (
|
||||
<AdminGroupHub
|
||||
title={tn('group_support')}
|
||||
subtitle={t('admin_support_subtitle')}
|
||||
consoles={[
|
||||
{
|
||||
title: tn('tickets'),
|
||||
subtitle: t('admin_tickets_sub'),
|
||||
icon: 'support',
|
||||
path: ROUTES.ADMIN_TICKETS,
|
||||
enabled: caps.canManageTickets,
|
||||
},
|
||||
{
|
||||
title: tn('alerts'),
|
||||
subtitle: t('admin_alerts_sub'),
|
||||
icon: 'alerts',
|
||||
path: ROUTES.ADMIN_ALERTS,
|
||||
enabled: caps.canManageAlerts,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ProfileSummary, SurfaceCard } from '@/components';
|
||||
import { SettingsPanel, SignOutRow } from '@/components/settings';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useMe } from '@/services/auth';
|
||||
import AdminGroupHub from '../_hub/AdminGroupHub';
|
||||
|
||||
/**
|
||||
* «سیستم» group root — platform configuration plus the identity/appearance/sign-out block that
|
||||
* used to live in the top bar and drawer footer. Always reachable (even for an admin role with no
|
||||
* system console permitted), because it is the only way out of the app.
|
||||
*/
|
||||
export default function AdminSystemPage() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
const ta = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const { data: me } = useMe();
|
||||
|
||||
const primaryRoleCode = caps.roles[0];
|
||||
|
||||
return (
|
||||
<AdminGroupHub
|
||||
title={tn('group_system')}
|
||||
subtitle={t('admin_system_subtitle')}
|
||||
consoles={[
|
||||
{
|
||||
title: tn('config'),
|
||||
subtitle: t('admin_config_sub'),
|
||||
icon: 'config',
|
||||
path: ROUTES.ADMIN_CONFIG,
|
||||
enabled: caps.canConfig,
|
||||
},
|
||||
{
|
||||
title: tn('holidays'),
|
||||
subtitle: t('admin_holidays_sub'),
|
||||
icon: 'calendar',
|
||||
path: ROUTES.ADMIN_HOLIDAYS,
|
||||
enabled: caps.canConfig,
|
||||
},
|
||||
{
|
||||
title: tn('audit'),
|
||||
subtitle: t('admin_audit_sub'),
|
||||
icon: 'audit',
|
||||
path: ROUTES.ADMIN_AUDIT,
|
||||
enabled: caps.canViewAudit,
|
||||
},
|
||||
{
|
||||
title: tn('partners'),
|
||||
subtitle: t('admin_partners_sub'),
|
||||
icon: 'partners',
|
||||
path: ROUTES.ADMIN_PARTNERS,
|
||||
enabled: caps.canManagePartners,
|
||||
},
|
||||
{
|
||||
title: tn('users'),
|
||||
subtitle: t('admin_users_sub'),
|
||||
icon: 'users',
|
||||
path: ROUTES.ADMIN_USERS,
|
||||
enabled: caps.canManageRoles,
|
||||
},
|
||||
{
|
||||
title: tn('roles'),
|
||||
subtitle: t('admin_roles_sub'),
|
||||
icon: 'roles',
|
||||
path: ROUTES.ADMIN_ROLES,
|
||||
enabled: caps.canManageRoles,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<SurfaceCard>
|
||||
<ProfileSummary
|
||||
displayName={me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''}
|
||||
phone={me?.phone}
|
||||
roleLabel={primaryRoleCode ? ta(`role_${primaryRoleCode}`) : undefined}
|
||||
loading={!me}
|
||||
/>
|
||||
</SurfaceCard>
|
||||
<SettingsPanel />
|
||||
<SignOutRow />
|
||||
</Stack>
|
||||
</AdminGroupHub>
|
||||
);
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export default function AdminTicketThreadPage() {
|
||||
<AdminEmptyState icon="support" title={t('ticket_empty')} />
|
||||
) : (
|
||||
<>
|
||||
<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)' }}>
|
||||
<PageHeader
|
||||
title={t('ticket_thread_title', { ref: detail.referenceCode })}
|
||||
subtitle={detail.subject ?? undefined}
|
||||
@@ -202,7 +202,7 @@ export default function AdminTicketThreadPage() {
|
||||
<Paper
|
||||
id={REFUND_PANEL_ID}
|
||||
elevation={0}
|
||||
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
@@ -229,7 +229,7 @@ export default function AdminTicketThreadPage() {
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -112,7 +112,7 @@ function AdminTicketsQueue() {
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import AdminGroupHub from '../_hub/AdminGroupHub';
|
||||
|
||||
/** «اعتماد» group root — the verification queue and review moderation. */
|
||||
export default function AdminTrustPage() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
const caps = useAdminCapabilities();
|
||||
|
||||
return (
|
||||
<AdminGroupHub
|
||||
title={tn('group_trust')}
|
||||
subtitle={t('admin_trust_subtitle')}
|
||||
consoles={[
|
||||
{
|
||||
title: tn('verification'),
|
||||
subtitle: t('admin_verification_sub'),
|
||||
icon: 'verification',
|
||||
path: ROUTES.ADMIN_VERIFICATION,
|
||||
enabled: caps.canVerify,
|
||||
},
|
||||
{
|
||||
title: tn('reviews'),
|
||||
subtitle: t('admin_reviews_sub'),
|
||||
icon: 'moderation',
|
||||
path: ROUTES.ADMIN_REVIEWS,
|
||||
enabled: caps.canModerate,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ function AdminVerificationCaseScreen() {
|
||||
<AdminEmptyState icon="verified" title={t('ver_empty')} />
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('ver_identity_name')}
|
||||
</Typography>
|
||||
@@ -207,7 +207,7 @@ function AdminVerificationCaseScreen() {
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
|
||||
{data.credentials.map((cred) => (
|
||||
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
|
||||
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.75 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
@@ -325,7 +325,7 @@ function StepCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 2 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||
{t(`step_${step.code}`)}
|
||||
|
||||
@@ -174,7 +174,7 @@ function AdminVerificationQueueScreen() {
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Avatar, Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AccentCard,
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLink,
|
||||
CountdownTimer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
InitialsAvatar,
|
||||
Money,
|
||||
SurfaceCard,
|
||||
TrustBadge,
|
||||
@@ -19,78 +20,114 @@ import { useMe } from '@/services/auth';
|
||||
import { useNurseRequestInbox } from '@/services/bookingRequests';
|
||||
import { useTodaySessions } from '@/services/bookings';
|
||||
import { useNurseEarningsBalance } from '@/services/payouts';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
import { coarseResponseLabel } from '@/services/bookingRequests/format';
|
||||
import DashboardActivationSlot from './DashboardActivationSlot';
|
||||
|
||||
const DASHBOARD_MAX_WIDTH = 960;
|
||||
/** The pill's urgency tiers (ui-phase-7 §3.4): teal >2h · amber <2h · terracotta <30min. */
|
||||
const URGENT_THRESHOLD_SECONDS = 30 * 60;
|
||||
const WARN_THRESHOLD_SECONDS = 2 * 60 * 60;
|
||||
|
||||
/**
|
||||
* The nurse "امروز" dashboard (ui-phase-7 §3.1) — the operational home replacing the `PlaceholderScreen`.
|
||||
* Pure assembly: every widget reads an already-cached query. Order matters — the pending-requests strip
|
||||
* is the most time-critical thing a nurse can miss, so it sits above the earnings snapshot.
|
||||
* The nurse «امروز» home — the first bottom-nav destination.
|
||||
*
|
||||
* Rebuilt for the phone-width frame. The previous version stacked five same-weight cards, each
|
||||
* repeating its own icon + bold heading + inline "see all" button; at 480px the buttons wrapped
|
||||
* mid-word, the countdown collided with the request title, and nothing on the screen looked more
|
||||
* important than anything else. This version gives the page one visual hierarchy: a quiet identity
|
||||
* strip, then exactly one hero action (the next visit), then sections introduced by a plain label
|
||||
* with a text link instead of a competing button.
|
||||
*
|
||||
* Composition only — every widget reads a query that is already cached elsewhere in the shell, and
|
||||
* order encodes urgency: a missed request expires, an unread earnings figure does not.
|
||||
*/
|
||||
export default function NurseDashboardScreen() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { data: me, isLoading: meLoading } = useMe();
|
||||
const verification = useVerificationStatus();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: DASHBOARD_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
{meLoading ? (
|
||||
<>
|
||||
<Skeleton variant="circular" width={44} height={44} />
|
||||
<Skeleton variant="text" width={160} height={32} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar sx={{ width: 44, height: 44, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{(displayName || '؟').charAt(0)}
|
||||
</Avatar>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
{t('greeting', { name: displayName })}
|
||||
</Typography>
|
||||
{!verification.isLoading ? <TrustBadge state={ownBadgeState(verification.data)} /> : null}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 2.5 }}>
|
||||
<GreetingHeader />
|
||||
<NextVisitCard />
|
||||
<RequestsStrip />
|
||||
<EarningsSnapshotCard />
|
||||
<RequestsSection />
|
||||
<EarningsSection />
|
||||
<DashboardActivationSlot />
|
||||
<NotificationsEntryRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** First actionable session from `useTodaySessions` + a display-only "time until" line. */
|
||||
/**
|
||||
* A section label + an optional text link. Deliberately not a button: on a 480px row a
|
||||
* `<Button>` labelled «مشاهده همه» wrapped to two lines and outweighed the section it introduced.
|
||||
*/
|
||||
function SectionHeader({ title, actionLabel, actionTo }: { title: string; actionLabel?: string; actionTo?: string }) {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<Stack direction="row" sx={{ alignItems: 'baseline', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{actionLabel && actionTo ? (
|
||||
<AppLink to={`/${locale}${actionTo}`} variant="caption" color="primary" sx={{ flexShrink: 0 }}>
|
||||
{actionLabel}
|
||||
</AppLink>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Greeting + own trust badge. The avatar is initials-based — a nurse photo lives on the profile. */
|
||||
function GreetingHeader() {
|
||||
const t = useTranslations('dashboard');
|
||||
const { data: me, isLoading } = useMe();
|
||||
const verification = useVerificationStatus();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<Skeleton variant="circular" width={44} height={44} />
|
||||
<Skeleton variant="text" width={180} height={28} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', minWidth: 0 }}>
|
||||
<InitialsAvatar name={displayName} size={44} />
|
||||
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" component="h1" noWrap sx={{ fontWeight: 700 }}>
|
||||
{t('greeting', { name: displayName })}
|
||||
</Typography>
|
||||
{verification.isLoading ? null : (
|
||||
<Box>
|
||||
<TrustBadge state={ownBadgeState(verification.data)} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The page's one hero action: the next actionable session, with a full-width primary CTA. */
|
||||
function NextVisitCard() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useTodaySessions();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('next_visit_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const next = items.find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||||
const next = (data?.items ?? []).find((item) => item.status === 'scheduled' || item.status === 'in_progress');
|
||||
|
||||
if (!next) {
|
||||
return <EmptyState icon="visits" title={t('next_visit_empty')} />;
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<SectionHeader title={t('next_visit_title')} />
|
||||
<EmptyState icon="visits" title={t('next_visit_empty')} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat(localeTag(locale), { hour: '2-digit', minute: '2-digit' });
|
||||
@@ -98,48 +135,41 @@ function NextVisitCard() {
|
||||
const timeUntil = formatRelativeTime(`${next.scheduledDate}T${next.scheduledTimeStart}`, locale, formatShamsiDate);
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="next-visit">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="visits" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
<AccentCard tone="secondary" data-widget="next-visit">
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="overline" sx={{ color: 'text.secondary' }}>
|
||||
{t('next_visit_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
{next.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
<Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeRangeLabel}
|
||||
</Typography>
|
||||
{timeUntil ? ` · ${t('next_visit_starts_in', { relative: timeUntil })}` : ''}
|
||||
</Typography>
|
||||
<MetaLine
|
||||
items={[
|
||||
<Box key="range" component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{timeRangeLabel}
|
||||
</Box>,
|
||||
timeUntil ? t('next_visit_starts_in', { relative: timeUntil }) : null,
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon="check_in"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
|
||||
<AppButton variant="contained" color="secondary" startIcon="check_in" fullWidth to={`/${locale}${ROUTES.NURSE_VISITS}`}>
|
||||
{t('next_visit_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The most time-critical widget: pending-request count + the most urgent countdown, inline into detail. */
|
||||
function RequestsStrip() {
|
||||
/** The most time-critical section: a pending request expires on its own if it isn't answered. */
|
||||
function RequestsSection() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tb = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseRequestInbox();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={140} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={130} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('requests_strip_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
@@ -148,76 +178,70 @@ function RequestsStrip() {
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
if (items.length === 0) {
|
||||
return <EmptyState icon="requests" title={t('requests_strip_empty')} />;
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<SectionHeader title={t('requests_strip_title', { count: 0 })} />
|
||||
<EmptyState icon="requests" title={t('requests_strip_empty')} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const mostUrgent = items[0];
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="requests-strip">
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="requests" size={20} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('requests_strip_title', { count: total })}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }} data-widget="requests-strip">
|
||||
<SectionHeader
|
||||
title={t('requests_strip_title', { count: total })}
|
||||
actionLabel={t('requests_strip_cta')}
|
||||
actionTo={ROUTES.NURSE_REQUESTS}
|
||||
/>
|
||||
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{/* Name and countdown are siblings on one row, with the pill `flexShrink: 0` — the old
|
||||
layout let the countdown wrap under a long Persian name and collide with the date. */}
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" noWrap sx={{ fontWeight: 500 }}>
|
||||
{mostUrgent.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||||
elapsedText={tb('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
variant="text"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)}
|
||||
fullWidth
|
||||
to={`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`}
|
||||
>
|
||||
{t('requests_strip_cta')}
|
||||
{t('requests_strip_open')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{mostUrgent.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(mostUrgent.requestedDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CountdownTimer
|
||||
deadlineIso={mostUrgent.nurseResponseDeadlineAt}
|
||||
elapsedText={tb('response_elapsed')}
|
||||
warnThresholdSeconds={WARN_THRESHOLD_SECONDS}
|
||||
urgentThresholdSeconds={URGENT_THRESHOLD_SECONDS}
|
||||
coarseLabel={(minutes) => coarseResponseLabel(minutes, tb)}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="requests"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${mostUrgent.id}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('requests_strip_open')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</SurfaceCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A compact two-stat row (net payable + eligible) — never clamps a negative net balance. */
|
||||
function EarningsSnapshotCard() {
|
||||
/** Two stat tiles — the signed net balance is never clamped, an "owed back" reads as an error tone. */
|
||||
function EarningsSection() {
|
||||
const t = useTranslations('dashboard');
|
||||
const tp = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
|
||||
|
||||
if (isLoading) return <Skeleton variant="rounded" height={120} />;
|
||||
if (isLoading) return <Skeleton variant="rounded" height={110} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (isError) {
|
||||
return <ErrorState message={t('earnings_snapshot_error')} retryLabel={t('retry')} onRetry={() => refetch()} />;
|
||||
}
|
||||
@@ -228,69 +252,48 @@ function EarningsSnapshotCard() {
|
||||
const magnitude = isOwed ? -net : net;
|
||||
|
||||
return (
|
||||
<SurfaceCard data-widget="earnings-snapshot">
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="earnings" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('earnings_snapshot_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="earnings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
||||
>
|
||||
{t('earnings_snapshot_cta')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tp('bucket_eligible')}
|
||||
</Typography>
|
||||
<Money amountIrr={data.eligibleTotalIrr} size="lg" sx={{ fontWeight: 800 }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
<Stack sx={{ gap: 1 }} data-widget="earnings-snapshot">
|
||||
<SectionHeader
|
||||
title={t('earnings_snapshot_title')}
|
||||
actionLabel={t('earnings_snapshot_cta')}
|
||||
actionTo={ROUTES.NURSE_EARNINGS}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'grid', gap: 1.5, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<StatTile
|
||||
label={isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
value={<Money amountIrr={String(magnitude)} size="lg" tone={isOwed ? 'error' : 'emphasis'} />}
|
||||
/>
|
||||
<StatTile label={tp('bucket_eligible')} value={<Money amountIrr={data.eligibleTotalIrr} size="lg" />} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<SurfaceCard padding="sm">
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="caption" noWrap sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{value}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The unread-count entry row — the bell in the shell chrome is Phase 2's; this is a dashboard shortcut. */
|
||||
function NotificationsEntryRow() {
|
||||
const t = useTranslations('dashboard');
|
||||
const locale = useLocale();
|
||||
const unread = useUnreadCount();
|
||||
|
||||
/** Dot-separated secondary facts on one line, skipping the ones that aren't available. */
|
||||
function MetaLine({ items }: { items: Array<ReactNode> }) {
|
||||
const present = items.filter(Boolean);
|
||||
return (
|
||||
<AppLink to={`/${locale}${ROUTES.NURSE_NOTIFICATIONS}`} color="inherit" underline="none" sx={{ display: 'block' }}>
|
||||
<SurfaceCard data-widget="notifications-entry">
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="notifications" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{t('notifications_entry_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="body2"
|
||||
// --bal-secondary-dark, not --bal-secondary: the plain terracotta fails AA contrast for
|
||||
// small text on a light surface (frontend-designer skill §2) — this is body copy, not an icon.
|
||||
sx={{ color: unread > 0 ? 'var(--bal-secondary-dark)' : 'text.secondary', fontWeight: unread > 0 ? 700 : 400 }}
|
||||
>
|
||||
{unread > 0 ? t('notifications_entry_unread', { count: unread }) : t('notifications_entry_empty')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
</AppLink>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{present.map((item, index) => (
|
||||
<Box key={index} component="span">
|
||||
{index > 0 ? ' · ' : null}
|
||||
{item}
|
||||
</Box>
|
||||
))}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function NurseCoveragePage() {
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
@@ -153,7 +153,7 @@ export default function NurseCoveragePage() {
|
||||
</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: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('add_title')}
|
||||
|
||||
@@ -157,7 +157,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
@@ -168,7 +168,7 @@ function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={EXPLAINER_CONTENT_ID}
|
||||
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 1 }}
|
||||
sx={{ width: '100%', justifyContent: 'space-between', gap: 1, borderRadius: 'var(--bal-radius-sm)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-info)" />
|
||||
|
||||
@@ -66,14 +66,14 @@ export default function NursePayoutDetailPage() {
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
) : isError || !data ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('detail_not_found')}
|
||||
</Typography>
|
||||
</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.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
@@ -112,7 +112,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.75,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
@@ -174,7 +174,7 @@ export default function NursePayoutDetailPage() {
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('detail_bookings_hint')}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', overflow: 'hidden' }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{data.bookings.map((link) => (
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
import { Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AccentCard, ErrorState, Money, NavHubList, PageHeader } from '@/components';
|
||||
import type { NavHubItem } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { parseIrr } from '@/utils';
|
||||
import { useNurseEarningsBalance } from '@/services/payouts';
|
||||
|
||||
/**
|
||||
* «مالی» group root. The one number a nurse opens this tab for — the signed net payable balance —
|
||||
* is answered before any navigation, then the three money screens are one tap away. The balance is
|
||||
* **signed**: an outstanding clawback can exceed accrued earnings, and clamping it to zero would
|
||||
* quietly tell a nurse they are owed nothing when they in fact owe money back.
|
||||
*/
|
||||
export default function NurseFinanceScreen() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
const tp = useTranslations('payouts');
|
||||
const { data, isLoading, isError, refetch } = useNurseEarningsBalance();
|
||||
|
||||
const items: Array<NavHubItem> = [
|
||||
{
|
||||
title: tn('earnings'),
|
||||
subtitle: t('finance_earnings_sub'),
|
||||
icon: 'earnings',
|
||||
path: ROUTES.NURSE_EARNINGS,
|
||||
},
|
||||
{
|
||||
title: tn('payouts'),
|
||||
subtitle: t('finance_payouts_sub'),
|
||||
icon: 'history',
|
||||
path: ROUTES.NURSE_EARNINGS_PAYOUTS,
|
||||
},
|
||||
{
|
||||
title: tn('bank'),
|
||||
subtitle: t('finance_bank_sub'),
|
||||
icon: 'bank',
|
||||
path: ROUTES.NURSE_BANK,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={tn('group_finance')} subtitle={t('finance_subtitle')} />
|
||||
<BalanceSummary data={data} isLoading={isLoading} isError={isError} onRetry={() => refetch()} tp={tp} t={t} />
|
||||
<NavHubList items={items} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface BalanceSummaryProps {
|
||||
data: ReturnType<typeof useNurseEarningsBalance>['data'];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onRetry: () => void;
|
||||
tp: ReturnType<typeof useTranslations<'payouts'>>;
|
||||
t: ReturnType<typeof useTranslations<'hub'>>;
|
||||
}
|
||||
|
||||
function BalanceSummary({ data, isLoading, isError, onRetry, tp, t }: BalanceSummaryProps) {
|
||||
if (isLoading) return <Skeleton variant="rounded" height={104} />;
|
||||
if (isError) return <ErrorState message={t('finance_balance_error')} retryLabel={t('retry')} onRetry={onRetry} />;
|
||||
if (!data) return null;
|
||||
|
||||
const net = parseIrr(data.netPayableBalanceIrr);
|
||||
const isOwed = net < BigInt(0);
|
||||
const magnitude = isOwed ? -net : net;
|
||||
|
||||
return (
|
||||
<AccentCard tone={isOwed ? 'error' : 'primary'}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? tp('balance_owed_label') : tp('balance_net_label')}
|
||||
</Typography>
|
||||
<Money amountIrr={String(magnitude)} size="xl" tone={isOwed ? 'error' : 'emphasis'} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tp('bucket_eligible')}: <Money amountIrr={data.eligibleTotalIrr} size="sm" component="span" />
|
||||
</Typography>
|
||||
</Stack>
|
||||
</AccentCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import NurseFinanceScreen from './NurseFinanceScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'nav' });
|
||||
return { title: t('group_finance') };
|
||||
}
|
||||
|
||||
export default function NurseFinancePage() {
|
||||
return <NurseFinanceScreen />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
return <ShellContentSkeleton />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { NavHubList, PageHeader, ProfileSummary, SurfaceCard } from '@/components';
|
||||
import type { NavHubItem } from '@/components';
|
||||
import { SettingsPanel, SignOutRow } from '@/components/settings';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ActorSwitcher } from '@/layout';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { useNurseProfile } from '@/services/profiles';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import { useSupportUnreadTotal } from '@/services/tickets';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* «بیشتر» group root — the fourth bottom-nav destination: who you are signed in as, the two
|
||||
* conversation surfaces (support, notifications), appearance/language, and sign-out. Everything
|
||||
* here used to live in the sidebar drawer's header and footer, where a preference toggle sat
|
||||
* permanently next to primary navigation.
|
||||
*/
|
||||
export default function NurseMoreScreen() {
|
||||
const t = useTranslations('hub');
|
||||
const tn = useTranslations('nav');
|
||||
|
||||
const { data: me } = useMe();
|
||||
const { data: nurseProfile } = useNurseProfile();
|
||||
const { data: verification } = useVerificationStatus();
|
||||
const supportUnreadTotal = useSupportUnreadTotal();
|
||||
const notificationsUnread = useUnreadCount();
|
||||
|
||||
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
|
||||
|
||||
const items: Array<NavHubItem> = [
|
||||
{
|
||||
title: tn('support'),
|
||||
subtitle: t('more_support_sub'),
|
||||
icon: 'support',
|
||||
path: ROUTES.NURSE_SUPPORT_TICKETS,
|
||||
badgeCount: supportUnreadTotal ?? undefined,
|
||||
},
|
||||
{
|
||||
title: tn('notifications'),
|
||||
subtitle: t('more_notifications_sub'),
|
||||
icon: 'notifications',
|
||||
path: ROUTES.NURSE_NOTIFICATIONS,
|
||||
badgeCount: notificationsUnread || undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={t('more_title')} />
|
||||
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<ProfileSummary
|
||||
displayName={displayName}
|
||||
phone={me?.phone}
|
||||
avatarUrl={nurseProfile?.avatarUrl}
|
||||
trustState={ownBadgeState(verification)}
|
||||
loading={!me}
|
||||
/>
|
||||
<ActorSwitcher target="customer" />
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
|
||||
<NavHubList items={items} />
|
||||
<SettingsPanel />
|
||||
<SignOutRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import NurseMoreScreen from './NurseMoreScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'hub' });
|
||||
return { title: t('more_title') };
|
||||
}
|
||||
|
||||
export default function NurseMorePage() {
|
||||
return <NurseMoreScreen />;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { AccentCard, AppLink, NavHubList, PageHeader, StatusChip, TrustBadge } from '@/components';
|
||||
import type { NavHubItem } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useMyVariants } from '@/services/catalog';
|
||||
import { useServiceAreas } from '@/services/serviceAreas';
|
||||
import { useNurseProfile } from '@/services/profiles';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* «حرفهٔ من» group root — the page the bottom-nav tab lands on. It answers the question the
|
||||
* sidebar section never could ("is my listing actually live, and what's missing?") before handing
|
||||
* off to the four screens that fix it. Every number here is read off a query that already answers
|
||||
* it; nothing is derived optimistically, and a count is simply omitted until its query resolves.
|
||||
*/
|
||||
export default function NursePracticeScreen() {
|
||||
const t = useTranslations('hub');
|
||||
const locale = useLocale();
|
||||
const tn = useTranslations('nav');
|
||||
const { data: verification } = useVerificationStatus();
|
||||
const { data: profile } = useNurseProfile();
|
||||
const { data: variants } = useMyVariants();
|
||||
const { data: areas } = useServiceAreas();
|
||||
|
||||
const activeVariants = variants?.items.filter((variant) => variant.isActive).length;
|
||||
const isAccepting = profile?.isAcceptingBookings;
|
||||
|
||||
const items: Array<NavHubItem> = [
|
||||
{
|
||||
title: tn('profile'),
|
||||
subtitle: t('practice_profile_sub'),
|
||||
icon: 'account',
|
||||
path: ROUTES.NURSE_PROFILE,
|
||||
},
|
||||
{
|
||||
title: tn('services'),
|
||||
subtitle: t('practice_services_sub'),
|
||||
icon: 'services',
|
||||
path: ROUTES.NURSE_SERVICES,
|
||||
meta: activeVariants === undefined ? undefined : <MetaCount value={activeVariants} />,
|
||||
},
|
||||
{
|
||||
title: tn('coverage'),
|
||||
subtitle: t('practice_coverage_sub'),
|
||||
icon: 'coverage',
|
||||
path: ROUTES.NURSE_COVERAGE,
|
||||
meta: areas === undefined ? undefined : <MetaCount value={areas.total} />,
|
||||
},
|
||||
{
|
||||
title: tn('verification'),
|
||||
subtitle: t('practice_verification_sub'),
|
||||
icon: 'verification',
|
||||
path: ROUTES.NURSE_VERIFICATION,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={tn('group_profession')} subtitle={t('practice_subtitle')} />
|
||||
|
||||
<AccentCard tone={isAccepting ? 'success' : 'warning'}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('practice_status_title')}
|
||||
</Typography>
|
||||
<TrustBadge state={ownBadgeState(verification)} />
|
||||
</Stack>
|
||||
{isAccepting === undefined ? null : (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<StatusChip
|
||||
status={isAccepting ? 'active' : 'neutral'}
|
||||
label={isAccepting ? t('practice_accepting_on') : t('practice_accepting_off')}
|
||||
/>
|
||||
<AppLink to={`/${locale}${ROUTES.NURSE_SERVICES}`} variant="caption">
|
||||
{t('practice_accepting_manage')}
|
||||
</AppLink>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</AccentCard>
|
||||
|
||||
<NavHubList items={items} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A count read straight off a resolved query — never a placeholder while one is in flight. */
|
||||
function MetaCount({ value }: { value: number }) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import NursePracticeScreen from './NursePracticeScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'nav' });
|
||||
return { title: t('group_profession') };
|
||||
}
|
||||
|
||||
export default function NursePracticePage() {
|
||||
return <NursePracticeScreen />;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ const NurseProfileForm: FunctionComponent<{ initial: NurseProfile | null }> = ({
|
||||
{badgeState !== 'verified' ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
|
||||
sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderInlineStartWidth: 4, borderInlineStartColor: 'var(--bal-warning)' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function NurseRequestDetailPage() {
|
||||
|
||||
if (isError || !request) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', maxWidth: CONTENT_MAX_WIDTH, mx: 'auto' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{t('not_found_title')}
|
||||
</Typography>
|
||||
@@ -131,7 +131,7 @@ export default function NurseRequestDetailPage() {
|
||||
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<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.5 }}>
|
||||
<DetailRow caption={t('summary_patient')}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
@@ -171,7 +171,7 @@ export default function NurseRequestDetailPage() {
|
||||
</Paper>
|
||||
|
||||
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
|
||||
<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 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('inbox_notes_label')}
|
||||
@@ -225,7 +225,7 @@ export default function NurseRequestDetailPage() {
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
|
||||
@@ -105,7 +105,7 @@ const MyServicesList: FunctionComponent<MyServicesListProps> = ({ onAdd, onEdit
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton key={key} variant="rounded" height={150} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ const PublishGate: FunctionComponent = () => {
|
||||
const state = useActivationChecklist();
|
||||
const setAccepting = useSetAcceptingBookings();
|
||||
|
||||
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 2 }} />;
|
||||
if (state.isLoading) return <Skeleton variant="rounded" height={96} sx={{ borderRadius: 'var(--bal-radius-md)' }} />;
|
||||
if (state.isError) return null; // ActivationChecklist (mounted alongside) already surfaces the error + retry.
|
||||
|
||||
const toggle = (accepting: boolean) => {
|
||||
|
||||
@@ -309,7 +309,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
{t('builder_edit_title')}
|
||||
</Typography>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{pickCatalogName({ nameFa: initial.categoryNameFa, nameEn: initial.categoryNameEn }, locale)}
|
||||
@@ -375,7 +375,7 @@ const VariantBuilder: FunctionComponent<VariantBuilderProps> = ({ initial, onDon
|
||||
{categoriesQuery.isLoading ? (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton key={key} variant="rounded" height={116} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Box>
|
||||
) : categoriesQuery.isError ? (
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function CredentialsSubmitPage() {
|
||||
) : (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', p: 1.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
|
||||
@@ -318,7 +318,7 @@ export default function CredentialsSubmitPage() {
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
|
||||
@@ -119,7 +119,7 @@ export default function IdentitySubmitPage() {
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 1.5, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
sx={{ p: 1.5, borderRadius: 'var(--bal-radius-md)', bgcolor: 'var(--bal-primary-soft)', display: 'flex', gap: 1, alignItems: 'center' }}
|
||||
>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-primary)" />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
|
||||
@@ -51,9 +51,9 @@ export default function NurseVerificationPage() {
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton variant="rounded" height={72} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
{[0, 1, 2, 3].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
|
||||
<Skeleton key={key} variant="rounded" height={64} sx={{ borderRadius: 'var(--bal-radius-md)' }} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
@@ -127,7 +127,7 @@ function Approved({ onPublish }: { onPublish: () => void }) {
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
borderRadius: 'var(--bal-radius-md)',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStartWidth: 4,
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function NurseVisitNotesPanel({ bookingId }: { bookingId: number
|
||||
{canAppend ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
|
||||
sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider', borderTop: '3px solid var(--bal-secondary)' }}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
|
||||
|
||||
@@ -78,7 +78,7 @@ function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
||||
const { severity, key } = banner[state];
|
||||
|
||||
return (
|
||||
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
||||
<Alert severity={severity} sx={{ borderRadius: 'var(--bal-radius-md)' }}>
|
||||
{t(key)}
|
||||
</Alert>
|
||||
);
|
||||
@@ -89,7 +89,7 @@ function LicenseBlock({ center }: { center: PartnerCenter }) {
|
||||
const t = useTranslations('partner');
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||
{t('license_title')}
|
||||
</Typography>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function PartnerBookingDetailPage() {
|
||||
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
|
||||
) : (
|
||||
<>
|
||||
<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.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
@@ -91,7 +91,7 @@ export default function PartnerBookingDetailPage() {
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_detail_timeline_title')}
|
||||
</Typography>
|
||||
<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)' }}>
|
||||
<StatusTimeline nodes={timelineNodes} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
@@ -92,7 +92,7 @@ function PartnerBookingsScreen() {
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SidebarShellSkeleton from '../_chrome/SidebarShellSkeleton';
|
||||
import ShellContentSkeleton from '../_chrome/ShellContentSkeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <SidebarShellSkeleton />;
|
||||
return <ShellContentSkeleton />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageHeader, ProfileSummary, StatusChip, SurfaceCard } from '@/components';
|
||||
import { SettingsPanel, SignOutRow } from '@/components/settings';
|
||||
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||
|
||||
/**
|
||||
* «بیشتر» group root for the partner portal — the center's identity and merchant-of-record status
|
||||
* (previously squeezed into the top bar, where the MoR indicator was hidden below `sm`), plus
|
||||
* appearance/language and sign-out.
|
||||
*/
|
||||
export default function PartnerMorePage() {
|
||||
const t = useTranslations('hub');
|
||||
const tPartner = useTranslations('partner');
|
||||
const { data: center, isLoading } = useMyPartnerCenter();
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<PageHeader title={t('more_title')} />
|
||||
|
||||
<SurfaceCard>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<ProfileSummary displayName={center?.name ?? ''} loading={isLoading} />
|
||||
{!isLoading && center ? (
|
||||
<StatusChip
|
||||
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SurfaceCard>
|
||||
|
||||
<SettingsPanel />
|
||||
<SignOutRow />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ function PartnerSettlementScreen() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('settlement_title')} />
|
||||
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||
<Alert severity="info" sx={{ borderRadius: 'var(--bal-radius-md)' }}>
|
||||
{t('settlement_not_mor')}
|
||||
</Alert>
|
||||
</Box>
|
||||
@@ -149,7 +149,7 @@ function PartnerSettlementScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 'var(--bal-radius-md)', border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('settlement_iban')}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { FocusedLayout } from '@/layout';
|
||||
|
||||
/*
|
||||
* First-use role picker. It sits outside every actor route group on purpose — the session has no
|
||||
* public role yet, so there is no app to show tabs for — but it still needs the phone-width frame
|
||||
* that the actor shells provide, hence the chrome-free `FocusedLayout` (logo strip + content, no
|
||||
* bottom nav). No `RoleGuard` here: gating on a role is exactly what this page exists to resolve.
|
||||
*/
|
||||
export default function SelectRoleLayout({ children }: { children: ReactNode }) {
|
||||
return <FocusedLayout>{children}</FocusedLayout>;
|
||||
}
|
||||
Reference in New Issue
Block a user