manual improvement 1

This commit is contained in:
hamid
2026-07-27 22:27:04 +03:30
parent bd06ef0016
commit baa3cc63cd
166 changed files with 3111 additions and 1770 deletions
@@ -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')}
@@ -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)" />
@@ -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',
@@ -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' }}>
@@ -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}
@@ -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')}
@@ -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>;
}