ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
@@ -1,15 +1,21 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
interface AlertFilters {
status?: SupportAlertStatus;
type?: SupportAlertType;
}
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
'emergency',
];
const EMPTY: AlertFilters = { status: 'open' };
function parseFilters(params: URLSearchParams): AlertFilters {
const status = params.get('status') as SupportAlertStatus | null;
const type = params.get('type') as SupportAlertType | null;
return {
status: status && STATUSES.includes(status) ? status : undefined,
type: type && TYPES.includes(type) ? type : undefined,
};
}
function serializeFilters(filters: AlertFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.status) r.status = filters.status;
if (filters.type) r.type = filters.type;
return r;
}
/**
* Support-alert triage board (f15) — the **internal-only** worklist over `support_alerts`. Filter by
* type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner
* surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
*/
export default function AdminAlertsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAlertsPageInner />
</Suspense>
);
}
function AdminAlertsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1;
const meId = authState.currentUser?.id;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
const [type, setType] = useState<SupportAlertType | ''>('');
const [page, setPage] = useState(1);
const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { applied: filters, page } = listState;
const [resolving, setResolving] = useState<SupportAlert | null>(null);
const filters = { status: status || undefined, type: type || undefined };
const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const total = alerts.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
// Assign-to-self MUST NEVER default to a guessed user (the fixed defect: `?? 1`) — the button is simply
// disabled with a "loading your account" tooltip until the real id has hydrated.
const onAssignSelf = (alert: SupportAlert) => {
if (meId == null) return;
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
@@ -68,6 +102,11 @@ export default function AdminAlertsPage() {
);
};
const setStatusFilter = (value: SupportAlertStatus | '') =>
listState.applyFilters({ ...filters, status: value || undefined });
const setTypeFilter = (value: SupportAlertType | '') =>
listState.applyFilters({ ...filters, type: value || undefined });
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
select
size="small"
label={t('alert_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
value={filters.status ?? ''}
onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
select
size="small"
label={t('alert_col_type')}
value={type}
onChange={(e) => {
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
value={filters.type ?? ''}
onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
assignSelfDisabled={meId == null}
assignSelfDisabledTitle={t('assign_me_loading')}
/>
))}
</Stack>
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
<ConfirmDialog
@@ -1,15 +1,35 @@
'use client';
import { useState } from 'react';
import { Suspense, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
import { useAdminListState } from '@/hooks';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin';
import { useAuditLogs, useUserLookup } from '@/services/admin';
const EMPTY: AuditFilters = {};
function parseFilters(params: URLSearchParams): AuditFilters {
return {
entityType: params.get('entityType') ?? undefined,
entityId: params.get('entityId') ?? undefined,
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
};
}
function serializeFilters(filters: AuditFilters): Record<string, string> {
const r: Record<string, string> = {};
if (filters.entityType) r.entityType = filters.entityType;
if (filters.entityId) r.entityId = filters.entityId;
if (filters.from) r.from = filters.from;
if (filters.to) r.to = filters.to;
return r;
}
/**
* Append-only audit-log viewer (f15) — a read-only, filtered, paginated table of every admin state change,
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
@@ -17,24 +37,33 @@ const EMPTY: AuditFilters = {};
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAuditPageInner />
</Suspense>
);
}
function AdminAuditPageInner() {
const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
const [page, setPage] = useState(1);
const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const { draft, setDraft, applied, page } = listState;
const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((audit.data?.total ?? 0) / AUDIT_PAGE_SIZE));
const total = audit.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * AUDIT_PAGE_SIZE + 1;
const to = (page - 1) * AUDIT_PAGE_SIZE + items.length;
const apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
// Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
const actorIds = useMemo(
() => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
(a, b) => a - b,
),
[audit.data?.items],
);
const userLookup = useUserLookup(actorIds);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
<TextField
size="small"
type="date"
<JalaliDateField
label={t('audit_from')}
value={draft.from ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
value={draft.from ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
max={draft.to}
sx={{ minWidth: 160 }}
/>
<TextField
size="small"
type="date"
<JalaliDateField
label={t('audit_to')}
value={draft.to ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
value={draft.to ?? null}
onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
min={draft.from}
sx={{ minWidth: 160 }}
/>
<AppButton variant="contained" color="primary" onClick={apply}>
<AppButton variant="contained" color="primary" onClick={listState.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear}>
<AppButton variant="text" color="inherit" onClick={listState.clear}>
{t('clear')}
</AppButton>
</Stack>
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
) : (
<Stack sx={{ gap: 1 }}>
{items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
<AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
))}
</Stack>
)}
{items.length > 0 ? (
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
) : null}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,5 +1,5 @@
'use client';
import { useMemo, useState } from 'react';
import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -17,10 +17,10 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminConfigPageInner />
</Suspense>
);
}
function AdminConfigPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1);
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null);
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
))
)}
<AdminPager
page={listState.page}
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(listState.page + 1)}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', {
page: listState.page,
total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
})}
/>
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box>
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
autoFocus
multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1}
type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_value')}
@@ -179,15 +200,22 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
const history = useConfigChangeHistory(configKey, 1, configKey != null);
const [page, setPage] = useState(1);
const history = useConfigChangeHistory(configKey, page, configKey != null);
// RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
const anchor = locale === 'fa' ? 'left' : 'right';
const pageCount = Math.max(1, Math.ceil((history.data?.total ?? 0) / (history.data?.pageSize ?? 1)));
const close = () => {
setPage(1);
onClose();
};
return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}>
<AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
<AppIcon icon="close" />
</AppButton>
</Stack>
@@ -213,6 +241,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
))}
</Stack>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Drawer>
);
}
@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -16,13 +16,24 @@ import {
Switch,
TextField,
} from '@mui/material';
import { AppButton } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin';
/** Today's LOCAL date as ISO `YYYY-MM-DD` — never `toISOString()`, which converts to UTC first and can
* land on the wrong day near local midnight (the same class of bug fixed in the payout window default). */
function todayLocalIso(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/**
@@ -32,11 +43,21 @@ const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'nationa
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminHolidaysPageInner />
</Suspense>
);
}
function AdminHolidaysPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1);
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const holidays = useHolidays({}, listState.page);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)}
<AdminPager
page={listState.page}
pageCount={pageCount}
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page: listState.page, total: pageCount })}
/>
{editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null}
@@ -103,18 +134,16 @@ export default function AdminHolidaysPage() {
);
}
const TODAY_ISO = ''; // seeded below via state default so no Date at module load
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
const [form, setForm] = useState<HolidayInput>(() => ({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
});
}));
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
@@ -136,13 +165,11 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
type="date"
<JalaliDateField
label={t('hol_col_date')}
value={form.holidayDate}
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
value={form.holidayDate || null}
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label={t('hol_name_fa')}
@@ -1,13 +1,14 @@
'use client';
import { ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants';
import type { AdminUserSummary } from '@/services/admin/types';
import {
usePartnerCenter,
useCenterSponsoredNurses,
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState('');
const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
const data = center.data;
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
};
const onAssign = () => {
const nurseProfileId = Number(assignId);
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
const nurseProfileId = assignNurseUser?.nurseProfileId;
if (nurseProfileId == null) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId('');
setAssignNurseUser(null);
},
},
);
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
);
};
const back = (
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
if (center.isLoading) {
return (
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.75 }}>
{back}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('partner_detail_title')}
</Typography>
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />
</Stack>
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{data.name}
</Typography>
</Stack>
<PageHeader
title={t('partner_detail_title')}
subtitle={data.name}
onBack={goBack}
backLabel={t('back')}
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 }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<TextField
size="small"
type="number"
label={t('partner_assign_nurse_ph')}
value={assignId}
onChange={(e) => setAssignId(e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
sx={{ minWidth: 200 }}
/>
<Box sx={{ minWidth: 260 }}>
<NursePicker
value={assignNurseUser}
onChange={setAssignNurseUser}
label={t('partner_assign_nurse_ph')}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Box>
<AppButton
variant="contained"
color="primary"
startIcon="assign"
onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0}
disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
sx={{ mt: 0.25 }}
>
{t('partner_assign_nurse')}
@@ -1,5 +1,5 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
@@ -16,7 +16,7 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -24,14 +24,21 @@ import {
AdminErrorState,
AdminPageHeader,
AdminPager,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
import type { AdminUserSummary } from '@/services/admin/types';
import { useUserLookup } from '@/services/admin';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
type PartnersListFilters = Record<string, never>;
const EMPTY_FILTERS: PartnersListFilters = {};
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified',
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPartnersPageInner />
</Suspense>
);
}
function AdminPartnersPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const listState = useAdminListState<PartnersListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const total = centers.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
const to = (page - 1) * PARTNER_PAGE_SIZE + items.length;
const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
);
}
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs — except
* `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
* edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
interface CenterFormState {
name: string;
legalEntityType: string;
@@ -125,7 +152,7 @@ interface CenterFormState {
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
adminUserId: string;
adminUser: AdminUserSummary | null | undefined;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
adminUser: undefined,
};
}
@@ -158,6 +185,14 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
const existingAdminId = center?.adminUserId ?? null;
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -179,7 +214,7 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
onSuccess: () => {
@@ -235,12 +270,13 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<TextField
type="number"
<UserPicker
value={displayedAdminUser}
onChange={(u) => set('adminUser', u)}
label={t('partner_admin_user')}
value={form.adminUserId}
onChange={(e) => set('adminUserId', e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
</Stack>
</DialogContent>
@@ -1,18 +1,22 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
/** This detail page has no filters — only a page number worth mirroring into the URL. */
type BatchRowsFilters = Record<string, never>;
const EMPTY_FILTERS: BatchRowsFilters = {};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
export default function AdminPayoutBatchDetailPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPayoutBatchDetailScreen />
</Suspense>
);
}
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
function AdminPayoutBatchDetailScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId);
const [page, setPage] = useState(1);
const { page, goToPage } = useAdminListState<BatchRowsFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
<PageHeader
title={t('payout_batch_title', { id: batchId })}
backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
backLabel={t('back')}
/>
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</>
)}
@@ -17,7 +17,7 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, Money, StatusChip } from '@/components';
import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -30,7 +30,7 @@ import {
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'failed',
];
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
/**
* ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. Formats using the browser's **local**
* date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
* silently roll the date back/forward a day from the admin's actual wall-clock date.
*/
const isoDate = (d: Date): string => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
/**
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
@@ -173,7 +182,7 @@ export default function AdminPayoutsPage() {
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
<DialogContent>
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
type="date"
<JalaliDateField
size="small"
label={t('payout_period_start')}
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
type="date"
size="small"
label={t('payout_period_end')}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
onChange={setPeriodStart}
/>
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
<AppButton
variant="outlined"
color="primary"
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<ConfirmDialog
open={runConfirmOpen}
title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')}
body={
result ? (
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_summary_intro')}
</Typography>
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_count_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(result.eligible.length, locale)}
</Typography>
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
</Stack>
</Stack>
</Stack>
) : null
}
confirmLabel={t('payout_run')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
typedConfirmationLabel={t('payout_run_type_to_confirm')}
/>
</Dialog>
);
@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { AppButton, RatingInput } from '@/components';
import { AppButton, AppLoading, RatingInput } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews';
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warni
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
* leaves on success. `canModerate` only hides the controls — the server enforces the role scope.
*/
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
const status = params.get('status') as ModerationStatus | null;
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
}
export default function AdminReviewsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminReviewsPageInner />
</Suspense>
);
}
function AdminReviewsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
const [page, setPage] = useState(1);
const listState = useAdminListState<{ status: ModerationStatus }>({
parse: parseFilters,
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
empty: { status: DEFAULT_STATUS },
});
const status = listState.applied.status;
const page = listState.page;
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page);
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
const setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => {
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
size="small"
label={t('filter_label')}
value={status}
onChange={(e) => {
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
onChange={(e) => setStatus(e.target.value as ModerationStatus)}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
<ConfirmDialog
@@ -8,7 +8,7 @@
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant.
*/
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -31,12 +31,13 @@ import {
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
UserPicker,
type AdminTableColumn,
} from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import type { AdminRole, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin';
import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
// Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
// Batch id→name resolve (3.2) — one request for every grant row, never one per row.
const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
const userLookup = useUserLookup(userIds);
const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
const columns: AdminTableColumn<RoleGrant>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
{ key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
{
key: 'role',
header: t('role_col_role'),
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
<ConfirmDialog
open={revoking != null}
title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')}
confirmColor="error"
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
const [userId, setUserId] = useState('');
const [user, setUser] = useState<AdminUserSummary | null>(null);
const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId);
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const valid = user != null;
const onGrant = () => {
if (!valid) return;
if (!user) return;
grant.mutate(
{ userId: parsedId, role },
{ userId: user.id, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
<UserPicker
value={user}
onChange={setUser}
label={t('role_col_user')}
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
slotProps={{ htmlInput: { min: 1 } }}
placeholder={t('user_picker_search_ph')}
noOptionsText={t('user_picker_no_options')}
loadingText={t('user_picker_loading')}
/>
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
))}
</TextField>
{valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
) : null}
</Stack>
</DialogContent>
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -13,14 +13,17 @@ import {
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
Tooltip,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { useThreadScroll } from '@/components/messaging';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ROUTES } from '@/constants';
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
import { useAdminTicket, useAssignTicket, useCloseTicket, usePostAdminMessage, useReopenTicket } from '@/services/tickets';
import { TICKET_LIFECYCLE_ENABLED } from '@/services/tickets/constants';
import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
@@ -40,18 +43,22 @@ function makeClientMessageId(): string {
* The admin ticket thread (f15) — the full conversation **including internal notes** (the admin lens carries
* `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant-
* visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble`
* renders internal notes distinctly). Sends are optimistic (`usePostAdminMessage`) — the draft clears only on
* confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline
* (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's
* capabilities; the server is the real authority.
* renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
* so a staff member can never post an internal note publicly by mistake). Sends are optimistic
* (`usePostAdminMessage`) — the draft clears only on confirm; the thread opens scrolled to the newest message
* (`useThreadScroll`). When the ticket is a **refund** case linked to a booking, the admin opens the
* `RefundPanel` inline. Close/reopen/assign-to-me (REQ-063) are gated behind `TICKET_LIFECYCLE_ENABLED` — no
* live route yet — AND the principal's `canManageTickets` capability; the server is the real authority.
*/
export default function AdminTicketThreadPage() {
const t = useTranslations('admin');
const tickets = useTranslations('tickets');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id;
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
@@ -59,12 +66,20 @@ export default function AdminTicketThreadPage() {
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId);
const closeTicket = useCloseTicket(ticketId);
const reopenTicket = useReopenTicket(ticketId);
const assignTicket = useAssignTicket(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
const isInternal = mode === 'internal';
const messageCount = detail?.messages.length ?? 0;
const lastMessage = detail?.messages[messageCount - 1];
const { bottomRef } = useThreadScroll(messageCount, lastMessage?.isMine ?? false);
const send = () => {
const trimmed = body.trim();
@@ -80,16 +95,62 @@ export default function AdminTicketThreadPage() {
);
};
const onCloseConfirm = () => {
closeTicket.mutate(undefined, {
onSuccess: () => {
setCloseDialogOpen(false);
enqueueSnackbar(t('ticket_closed_ok'), { variant: 'success' });
},
});
};
const onReopenConfirm = () => {
reopenTicket.mutate(undefined, {
onSuccess: () => {
setReopenDialogOpen(false);
enqueueSnackbar(t('ticket_reopened_ok'), { variant: 'success' });
},
});
};
const onAssignMe = () => {
if (meId == null || assignTicket.isPending) return;
assignTicket.mutate({ ownerUserId: meId }, { onSuccess: () => enqueueSnackbar(t('ticket_assigned_me'), { variant: 'success' }) });
};
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
const lifecycleActions = showLifecycle && detail ? (
<>
{detail.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
{t('ticket_close')}
</AppButton>
) : (
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
{t('ticket_reopen')}
</AppButton>
)}
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
<span>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={onAssignMe}
disabled={meId == null || assignTicket.isPending}
>
{t('ticket_assign_me')}
</AppButton>
</span>
</Tooltip>
</>
) : null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
sx={{ alignSelf: 'flex-start' }}
>
<AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
@@ -106,44 +167,41 @@ export default function AdminTicketThreadPage() {
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr">
{t('ticket_thread_title', { ref: detail.referenceCode })}
</Typography>
{detail.subject ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{detail.subject}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip size="small" label={t(`tcat_${detail.category}`)} />
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
{detail.bookingId != null ? (
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
) : null}
{detail.refundId != null ? (
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
) : null}
</Stack>
<PageHeader
title={t('ticket_thread_title', { ref: detail.referenceCode })}
subtitle={detail.subject ?? undefined}
actions={lifecycleActions}
meta={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip size="small" label={t(`tcat_${detail.category}`)} />
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
{detail.bookingId != null ? (
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
) : null}
{detail.refundId != null ? (
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
) : null}
</Stack>
}
/>
{showRefund ? (
<Box>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
onClick={() => setRefundShown((v) => !v)}
>
{t('refund_open')}
</AppButton>
<Collapse in={refundShown} unmountOnExit>
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
</Collapse>
</Box>
) : null}
</Stack>
{showRefund ? (
<Box sx={{ mt: 1.5 }}>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
onClick={() => setRefundShown((v) => !v)}
>
{t('refund_open')}
</AppButton>
<Collapse in={refundShown} unmountOnExit>
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper>
</Collapse>
</Box>
) : null}
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
@@ -154,10 +212,20 @@ export default function AdminTicketThreadPage() {
authorLabel={tickets(authorLabelKey(m.authorRole))}
/>
))}
<div ref={bottomRef} />
</Box>
{caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Paper
elevation={0}
sx={{
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
<Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup
size="small"
@@ -185,11 +253,34 @@ export default function AdminTicketThreadPage() {
disabled={post.isPending || body.trim().length === 0}
sx={{ alignSelf: 'flex-start' }}
>
{t('ticket_send')}
{isInternal ? t('ticket_send_internal') : t('ticket_send')}
</AppButton>
</Stack>
</Paper>
) : null}
<ConfirmDialog
open={closeDialogOpen}
title={t('ticket_close')}
body={t('ticket_close_confirm')}
confirmLabel={t('ticket_close')}
cancelLabel={t('cancel')}
onConfirm={onCloseConfirm}
onClose={() => setCloseDialogOpen(false)}
loading={closeTicket.isPending}
confirmColor="primary"
/>
<ConfirmDialog
open={reopenDialogOpen}
title={t('ticket_reopen')}
body={t('ticket_reopen_confirm')}
confirmLabel={t('ticket_reopen')}
cancelLabel={t('cancel')}
onConfirm={onReopenConfirm}
onClose={() => setReopenDialogOpen(false)}
loading={reopenTicket.isPending}
confirmColor="primary"
/>
</>
)}
</Box>
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,9 +14,11 @@ import {
type AdminTableColumn,
} from '@/components/admin';
import { adminTicketThreadPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
import { formatShamsiDate } from '@/utils';
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {};
function parseFilters(params: URLSearchParams): AdminTicketFilters {
return {
status: (params.get('status') as TicketStatus | null) ?? undefined,
category: (params.get('category') as TicketCategory | null) ?? undefined,
referenceCode: params.get('referenceCode') ?? undefined,
};
}
function serializeFilters(filters: AdminTicketFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.category) record.category = filters.category;
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
return record;
}
/**
* The admin global ticket queue (f15) — EVERY ticket across the platform (not one viewer's), the entry point
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
* refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page
* serves from cache. This surface is staff-only — the server enforces the scope; the UI just routes here.
* refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
* browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only —
* the server enforces the scope; the UI just routes here.
*/
export default function AdminTicketsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminTicketsQueue />
</Suspense>
);
}
function AdminTicketsQueue() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY);
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY);
const [page, setPage] = useState(1);
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
parse: parseFilters,
serialize: serializeFilters,
empty: EMPTY,
});
const tickets = useAdminTickets(applied, page);
const items = tickets.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE));
const apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
const total = tickets.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn<AdminTicketSummary>[] = [
{
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
},
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
{
key: 'activity',
header: t('ticket_activity_col'),
minWidth: 140,
render: (row) => formatShamsiDate(row.createdAt, locale),
},
];
return (
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
footer={t('showing_range', { from, to, total })}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,8 +1,101 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Stack, TextField, Typography } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { AppLink } from '@/components';
import { ROUTES } from '@/constants';
import { useUserSearch } from '@/services/admin';
import { useDebouncedValue } from '@/services/search';
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
export default async function AdminUsersPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />;
const SEARCH_DEBOUNCE_MS = 300;
const MIN_QUERY_LENGTH = 2;
/** Directory role → chip label key. */
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
customer: 'user_role_customer',
nurse: 'user_role_nurse',
admin: 'user_role_admin',
partner: 'user_role_partner',
};
/**
* Read-first admin user directory (phase §3.8) — search by name/phone over the same admin user-directory
* seam `UserPicker`/`NursePicker` use (REQ-061, gap — mock-backed until delivered). Built because the REQ
* this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
* `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
* that already supports an entity filter) — a ticket-queue deep link isn't offered because the admin
* ticket queue has no actor/user filter to land on (would be a dishonest link).
*/
export default function AdminUsersPage() {
const t = useTranslations('admin');
const locale = useLocale();
const [query, setQuery] = useState('');
const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
const search = useUserSearch(debounced);
const items = search.data ?? [];
const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
const columns: AdminTableColumn<AdminUserSummary>[] = [
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
{
key: 'phone',
header: t('user_col_phone'),
render: (u) => (
<Box component="span" dir="ltr">
{u.maskedPhone}
</Box>
),
},
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
{
key: 'roles',
header: t('user_col_roles'),
render: (u) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{u.roles.map((r) => (
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
))}
</Stack>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (u) => (
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
<TextField
size="small"
label={t('user_search_label')}
placeholder={t('user_picker_search_ph')}
value={query}
onChange={(e) => setQuery(e.target.value)}
sx={{ maxWidth: 360 }}
/>
{!hasQuery ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_search_hint')}
</Typography>
) : search.isFetching ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_picker_loading')}
</Typography>
) : items.length === 0 ? (
<AdminEmptyState icon="users" title={t('user_empty')} />
) : (
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
)}
</Box>
);
}
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -15,25 +15,21 @@ import {
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
import { ROUTES, adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
useVerificationQueue,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
import { parseQueueFilters, queueCaseHref } from '../queueFilters';
/** The three credential-bearing step types — a Pass here opens the structured credential form. */
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
expired: 'rejected',
};
export default function AdminVerificationCasePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationCaseScreen />
</Suspense>
);
}
/**
* Verification case (b6 `AdminVerificationsController`) — the trust desk works one nurse: the identity on
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
@@ -55,12 +59,18 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
* number never crosses the wire. The whole verification is approvable only when every required step has
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
*
* «پرونده بعدی»/«پرونده قبلی» + arrow keys move through the **current queue page's order** without
* returning to the list: the case URL carries the queue's `status`/`search`/`page` (`queueCaseHref`), so
* re-deriving the same filters here hits `useVerificationQueue`'s cache the list already primed (no extra
* fetch in the common flow). Fetching adjacent pages is out of scope — prev/next disable at the page's ends.
*/
export default function AdminVerificationCasePage() {
function AdminVerificationCaseScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
const searchParams = useSearchParams();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
const backToQueue = useAdminBackToList(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
// Re-derive the SAME filters/page the queue list would have parsed from its own URL, so this query
// reuses the list's cached page (React Query keys structurally) instead of an unfiltered refetch.
const queueFilters = useMemo(() => parseQueueFilters(searchParams), [searchParams]);
const queuePage = useMemo(() => {
const raw = Number(searchParams.get('page'));
return Number.isInteger(raw) && raw > 0 ? raw : 1;
}, [searchParams]);
const queueQuery = useVerificationQueue(queueFilters, queuePage);
const queueItems = queueQuery.data?.items ?? [];
const caseIndex = queueItems.findIndex((item) => item.nurseVerificationId === nurseVerificationId);
const prevCase = caseIndex > 0 ? queueItems[caseIndex - 1] : null;
const nextCase = caseIndex >= 0 && caseIndex < queueItems.length - 1 ? queueItems[caseIndex + 1] : null;
const goToCase = useCallback(
(id: number) => router.push(queueCaseHref(locale, adminVerificationCasePath(id), queueFilters, queuePage)),
[router, locale, queueFilters, queuePage],
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const tag = (document.activeElement as HTMLElement | null)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (event.key === 'ArrowRight' && prevCase) goToCase(prevCase.nurseVerificationId);
else if (event.key === 'ArrowLeft' && nextCase) goToCase(nextCase.nurseVerificationId);
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [prevCase, nextCase, goToCase]);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}>
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
<AdminPageHeader title={t('ver_case_title')} />
</Stack>
<PageHeader
title={t('ver_case_title')}
onBack={backToQueue}
backLabel={t('back')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="inherit"
startIcon="back"
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
disabled={!prevCase}
>
{t('ver_prev_case')}
</AppButton>
<AppButton
variant="outlined"
color="inherit"
endIcon="forward"
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
disabled={!nextCase}
>
{t('ver_next_case')}
</AppButton>
</Stack>
}
/>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -408,24 +468,15 @@ function CredentialDialog({
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<TextField
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
fullWidth
type="date"
label={t('ver_issued_at')}
value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
type="date"
label={t('ver_expires_at')}
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
value={expiresAt || null}
onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</DialogContent>
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppIcon, StatusChip } from '@/components';
import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
import { useAdminListState } from '@/hooks';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
import type {
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
VerificationAggregateStatus,
} from '@/services/verification/types';
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
const WAITING_TIME_WARNING_HOURS = 48;
const WAITING_TIME_ALARM_HOURS = 96;
const MS_PER_HOUR = 60 * 60 * 1000;
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
suspended: 'rejected',
};
export default function AdminVerificationQueuePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationQueueScreen />
</Suspense>
);
}
/**
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
* nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces
* the step progress, the next pending step, when it was submitted, and a warning when a credential is
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh.
* nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
* server serves `counts` — REQ-062) replace the old lone select; a name/phone search follows the same
* draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
* `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) — the case
* page re-derives the same query key to reuse this cache for next/prev case navigation.
*/
export default function AdminVerificationQueuePage() {
function AdminVerificationQueueScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const state = useAdminListState<AdminVerificationQueueFilters>({
parse: parseQueueFilters,
serialize: serializeQueueFilters,
empty: EMPTY_QUEUE_FILTERS,
});
const queue = useVerificationQueue({ status: status || undefined }, page);
// Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
// render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
// Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
const applyPendingRef = useRef(false);
useEffect(() => {
if (applyPendingRef.current) {
applyPendingRef.current = false;
state.apply();
}
});
const selectStatus = (next: '' | 'pending' | 'in_review') => {
state.setDraft((d) => ({ ...d, status: next || undefined }));
applyPendingRef.current = true;
};
const queue = useVerificationQueue(state.applied, state.page);
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
const total = queue.data?.total ?? 0;
const counts = queue.data?.counts;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
const tabLabel = (base: string, count: number | undefined): string =>
count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
const footerText =
total > 0
? t('showing_range', {
from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
total,
})
: undefined;
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
{
key: 'waiting',
header: t('ver_col_waiting'),
render: (item) => {
if (!item.submittedAt) return '—';
const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
const color =
hours >= WAITING_TIME_ALARM_HOURS
? 'var(--bal-error)'
: hours >= WAITING_TIME_WARNING_HOURS
? 'var(--bal-warning)'
: undefined;
return (
<Box component="span" sx={color ? { color } : undefined}>
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
</Box>
);
},
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('ver_title')}
subtitle={t('ver_subtitle')}
actions={
<TextField
select
size="small"
label={t('ver_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as QueueStatusFilter);
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
<MenuItem value="pending">{t('agg_pending')}</MenuItem>
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem>
</TextField>
}
/>
<AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
<Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
<Tab value="" data-tab="all" label={t('filter_all')} />
<Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
<Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
</Tabs>
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<TextField
size="small"
label={t('ver_search_label')}
placeholder={t('ver_search_ph')}
value={state.draft.search ?? ''}
onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
sx={{ minWidth: 240 }}
/>
<AppButton variant="contained" color="primary" onClick={state.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={state.clear}>
{t('clear')}
</AppButton>
</Stack>
{queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
footer={footerText}
onRowClick={(item) =>
router.push(
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
)
}
/>
)}
<AdminPager
page={page}
page={state.page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page })}
indicator={t('page_indicator', { page: state.page, total: pageCount })}
/>
</Box>
);
@@ -0,0 +1,41 @@
import type { AdminVerificationQueueFilters } from '@/services/verification/types';
/**
* The queue's URL-synced filter shape — shared between the list (`page.tsx`, via `useAdminListState`)
* and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
* list primed (`status`/`search`/`page` → `useVerificationQueue`) and reuse its cached page for
* next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
*/
export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
const SEARCH_PARAM = 'q';
export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
const status = params.get('status');
return {
status: status === 'pending' || status === 'in_review' ? status : undefined,
search: params.get(SEARCH_PARAM) ?? undefined,
};
}
export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record<string, string> {
const record: Record<string, string> = {};
if (filters.status) record.status = filters.status;
if (filters.search) record[SEARCH_PARAM] = filters.search;
return record;
}
/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
* back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
export function queueCaseHref(
locale: string,
casePath: string,
filters: AdminVerificationQueueFilters,
page: number,
): string {
const params = new URLSearchParams(serializeQueueFilters(filters));
if (page > 1) params.set('page', String(page));
const qs = params.toString();
return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
}
@@ -0,0 +1,115 @@
'use client';
import type { ReactNode } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { PageHeader, StatusChip, StatusTimeline } from '@/components';
import type { StatusKind, TimelineNode, TimelineNodeState } from '@/components';
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants';
import { useAdminBackToList } from '@/hooks';
import { formatShamsiDate, formatShamsiDateTime } from '@/utils';
import { useMySponsoredBookingDetail } from '@/services/partnerCenter';
/** Same tone map as the bookings list — one status vocabulary across the portal. */
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
pending_payment: 'pending',
confirmed: 'info',
in_progress: 'active',
completed: 'verified',
disputed: 'rejected',
closed: 'neutral',
cancelled: 'rejected',
};
/** A booking's lifecycle is done once it reaches one of these — the timeline's last node reads as "current" otherwise. */
const TERMINAL_STATUSES = new Set(['completed', 'closed', 'cancelled']);
/**
* Partner portal — scoped sponsored-booking detail (f15, REQ-064). Strictly read-only and bounded to what
* the portal is allowed to see: the patient's display name, the scheduled date, the current status, and a
* server-truth status timeline — **no clinical content, no address, no money**. A bookings-list row deep-
* links here; tenancy (only bookings the signed-in center legally covers) is server-enforced.
*/
export default function PartnerBookingDetailPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const params = useParams<{ id: string }>();
const bookingId = Number(params?.id);
const goBack = useAdminBackToList(`/${locale}${ROUTES.PARTNER_BOOKINGS}`);
const detail = useMySponsoredBookingDetail(Number.isFinite(bookingId) ? bookingId : undefined);
const timelineNodes: TimelineNode[] =
detail.data?.timeline.map((entry, index, all) => {
const isLast = index === all.length - 1;
const state: TimelineNodeState = isLast && !TERMINAL_STATUSES.has(detail.data!.status) ? 'current' : 'completed';
return {
key: `${entry.status}-${index}`,
label: t(`bstatus_${entry.status}`),
timestamp: formatShamsiDateTime(entry.occurredAt, locale),
state,
};
}) ?? [];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('booking_detail_title')} onBack={goBack} backLabel={t('booking_detail_back')} />
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => detail.refetch()} />
) : !detail.data ? (
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('bookings_col_id')} #{detail.data.bookingId}
</Typography>
<StatusChip
status={PARTNER_BOOKING_STATUS_KIND[detail.data.status] ?? 'neutral'}
label={t(`bstatus_${detail.data.status}`)}
/>
</Stack>
<MetaLine label={t('booking_detail_patient')}>{detail.data.patientName}</MetaLine>
<MetaLine label={t('booking_detail_scheduled_date')}>
{formatShamsiDate(detail.data.scheduledDate, locale)}
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
<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 }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
</>
)}
</Box>
);
}
function MetaLine({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{children}
</Typography>
</Stack>
);
}
@@ -1,39 +1,77 @@
'use client';
import { useState } from 'react';
import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { partnerBookingDetailPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { formatShamsiDate } from '@/utils';
import type { SponsoredBooking } from '@/services/partnerCenter/types';
import type { SponsoredBooking, SponsoredBookingFilters } from '@/services/partnerCenter/types';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMySponsoredBookings } from '@/services/partnerCenter';
/**
* Booking lifecycle codes the center may legally cover — the read-only filter options. Stable string codes
* (the wire enum); labels are the codes themselves since `status` is a free-form summary field, not a
* localized enum in the portal contract.
*/
/** Booking lifecycle codes the center may legally cover — the read-only filter options (stable wire codes). */
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
/** Booking status → chip tone. Progression reads left-to-right calmer→done; disputed/cancelled are the alarm tier. */
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
pending_payment: 'pending',
confirmed: 'info',
in_progress: 'active',
completed: 'verified',
disputed: 'rejected',
closed: 'neutral',
cancelled: 'rejected',
};
const EMPTY_FILTERS: SponsoredBookingFilters = {};
function parseFilters(params: URLSearchParams): SponsoredBookingFilters {
return { status: params.get('status') ?? undefined };
}
function serializeFilters(filters: SponsoredBookingFilters): Record<string, string> {
return filters.status ? { status: filters.status } : {};
}
/**
* Partner portal — sponsored bookings (f15). The read-only list of bookings the signed-in center legally
* covers (portal scope; server-enforced tenancy). An optional status filter (filter+page keyed cache) and a
* dense table of id/patient/date/status. No PII beyond the summary; the center never sees clinical detail.
* covers (portal scope; server-enforced tenancy). A status filter (applied+page mirrored into the URL via
* `useAdminListState`, ui-phase-11) and a dense table of id/patient/date/status — every status label is
* translated (`bstatus_*`), never the raw wire code. Rows link to a scoped read-only detail (REQ-064).
* `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
*/
export default function PartnerBookingsPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerBookingsScreen />
</Suspense>
);
}
function PartnerBookingsScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<SponsoredBookingFilters>({
parse: parseFilters,
serialize: serializeFilters,
empty: EMPTY_FILTERS,
});
const filters = { status: status || undefined };
const bookings = useMySponsoredBookings(filters, page);
const bookings = useMySponsoredBookings(applied, page);
const items = bookings.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const total = bookings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn<SponsoredBooking>[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
@@ -42,35 +80,42 @@ export default function PartnerBookingsPage() {
{
key: 'status',
header: t('bookings_col_status'),
render: (b) => <Chip size="small" variant="outlined" label={b.status} />,
render: (b) => (
<StatusChip status={PARTNER_BOOKING_STATUS_KIND[b.status] ?? 'neutral'} label={t(`bstatus_${b.status}`)} />
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('bookings_title')}
actions={
<TextField
select
size="small"
label={t('bookings_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(1);
}}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{ta('filter_all')}</MenuItem>
{BOOKING_STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</TextField>
}
/>
<AdminPageHeader title={t('bookings_title')} />
<Stack
direction="row"
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<TextField
select
size="small"
label={t('bookings_col_status')}
value={draft.status ?? ''}
onChange={(e) => setDraft({ status: e.target.value || undefined })}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{ta('filter_all')}</MenuItem>
{BOOKING_STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>
{t(`bstatus_${s}`)}
</MenuItem>
))}
</TextField>
<AppButton variant="contained" color="primary" onClick={apply}>
{ta('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear}>
{ta('clear')}
</AppButton>
</Stack>
{bookings.isLoading ? (
<Stack sx={{ gap: 2 }}>
@@ -88,17 +133,19 @@ export default function PartnerBookingsPage() {
rows={items}
getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')}
onRowClick={(b) => router.push(`/${locale}${partnerBookingDetailPath(b.bookingId)}`)}
footer={ta('showing_range', { from, to, total })}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
indicator={ta('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
@@ -9,21 +10,86 @@ import {
AdminPager,
PartnerSettlementRow,
} from '@/components/admin';
import { useAdminListState } from '@/hooks';
import { formatIrrToToman, formatShamsiDate, toCsv } from '@/utils';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterInvoice } from '@/services/partnerCenter/types';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
const NO_FILTERS: Record<string, never> = {};
/**
* Partner portal — settlement & invoices (f15). **Merchant-of-record drives the whole view**: a non-MoR
* center settles through Balinyaar and issues no commission invoices, so it sees only the
* `settlement_not_mor` state (no table). A MoR center sees its per-booking commission invoices
* (`PartnerSettlementRow`, VAT-on-commission-only breakdown) with the masked settlement IBAN and a
* signed-URL PDF that opens in a new tab. Read-only.
* signed-URL PDF that opens in a new tab, plus a client-side CSV export of the currently loaded page. Read-
* only. `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
*/
export default function PartnerSettlementPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerSettlementScreen />
</Suspense>
);
}
/** Already-translated CSV column headers, keyed to the row shape `exportInvoicesCsv` builds below. */
interface InvoiceCsvHeaders {
invoiceNumber: string;
bookingId: string;
gross: string;
commission: string;
vat: string;
total: string;
issuedAt: string;
}
/** Builds + triggers the download of a CSV of the currently loaded invoices page (no new fetch). */
function exportInvoicesCsv(invoices: CenterInvoice[], locale: string, headers: InvoiceCsvHeaders): void {
const csv = toCsv(
invoices.map((inv) => ({
invoiceNumber: inv.invoiceNumber,
bookingId: inv.bookingId,
gross: formatIrrToToman(inv.grossIrr, locale),
commission: formatIrrToToman(inv.platformCommissionIrr, locale),
vat: formatIrrToToman(inv.vatIrr, locale),
total: formatIrrToToman(inv.totalIrr, locale),
issuedAt: formatShamsiDate(inv.issuedAt, locale),
})),
[
{ key: 'invoiceNumber', label: headers.invoiceNumber },
{ key: 'bookingId', label: headers.bookingId },
{ key: 'gross', label: headers.gross },
{ key: 'commission', label: headers.commission },
{ key: 'vat', label: headers.vat },
{ key: 'total', label: headers.total },
{ key: 'issuedAt', label: headers.issuedAt },
],
);
// UTF-8 BOM so Excel renders Persian text correctly.
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'settlement.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function PartnerSettlementScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const center = useMyPartnerCenter();
const [page, setPage] = useState(1);
// Page-only URL sync (no filters on this list) — survives refresh/back like the other portal worklists.
const { page, goToPage } = useAdminListState<Record<string, never>>({
parse: () => NO_FILTERS,
serialize: () => ({}),
empty: NO_FILTERS,
});
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page);
@@ -58,7 +124,30 @@ export default function PartnerSettlementPage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<AdminPageHeader
title={t('settlement_title')}
actions={
<AppButton
variant="outlined"
color="primary"
startIcon="download"
disabled={settlement.isLoading || invoices.length === 0}
onClick={() =>
exportInvoicesCsv(invoices, locale, {
invoiceNumber: t('csv_col_invoice_number'),
bookingId: t('csv_col_booking_id'),
gross: t('csv_col_gross'),
commission: t('csv_col_commission'),
vat: t('csv_col_vat'),
total: t('csv_col_total'),
issuedAt: t('csv_col_issued_at'),
})
}
>
{t('settlement_export_csv')}
</AppButton>
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
indicator={ta('page_indicator', { page, total: pageCount })}
/>
</Box>
);
@@ -38,4 +38,34 @@ describe('<AdminDataTable/>', () => {
fireEvent.click(screen.getByText('Beta'));
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
});
it('renders the footer line only when passed', () => {
const { rerender, container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
expect(container.querySelector('p')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} footer="Showing 12 of 2" />
</ThemeProvider>,
);
expect(screen.getByText('Showing 12 of 2')).toBeInTheDocument();
});
it('fires onSortChange with the column key when a sortable header is clicked', () => {
const onSortChange = jest.fn();
const sortableColumns: AdminTableColumn<Row>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id, sortable: true },
{ key: 'name', header: 'Name', render: (r) => r.name },
];
wrap(
<AdminDataTable
columns={sortableColumns}
rows={ROWS}
getRowKey={(r) => r.id}
sort={{ key: 'id', direction: 'asc' }}
onSortChange={onSortChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /ID/ }));
expect(onSortChange).toHaveBeenCalledWith('id');
});
});
+97 -26
View File
@@ -7,8 +7,12 @@ import {
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Typography,
} from '@mui/material';
export type AdminSortDirection = 'asc' | 'desc';
export interface AdminTableColumn<T> {
/** Stable column key (also `data-col` for tests). */
key: string;
@@ -19,6 +23,15 @@ export interface AdminTableColumn<T> {
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
align?: 'inherit' | 'left' | 'center' | 'right';
width?: number | string;
/** Floor width so a narrow value (a status chip, a short date) doesn't collapse the column on scroll. */
minWidth?: number | string;
/** Marks the column as a server-param sort target — renders a `TableSortLabel`; requires `onSortChange`. */
sortable?: boolean;
}
export interface AdminSortState {
key: string;
direction: AdminSortDirection;
}
export interface AdminDataTableProps<T> {
@@ -29,6 +42,18 @@ export interface AdminDataTableProps<T> {
dense?: boolean;
/** Accessible table name (already translated). */
ariaLabel?: string;
/** Bounds the table to a scrollable viewport with a header that stays visible while scrolling — opt in
* on long worklists (the filter object stays the query key; sort/page are just more of it, per 3.1). */
stickyHeader?: boolean;
/** `stickyHeader`'s scroll viewport height. */
stickyMaxHeight?: number | string;
/** The currently active server-param sort, or `null`/absent when unsorted. */
sort?: AdminSortState | null;
/** Fired with a sortable column's `key` when its header is clicked — the caller owns the 3-state cycle. */
onSortChange?: (key: string) => void;
/** Already-translated footer line («نمایش ۱–۲۰ از ۱۲۴») — rendered only when passed; every caller already
* holds `total` from its paginated query, so this mirrors `AdminPager`'s pre-translated-string convention. */
footer?: string;
}
/**
@@ -36,39 +61,85 @@ export interface AdminDataTableProps<T> {
* whole table scrolls horizontally inside its own container so a wide worklist never breaks the page layout
* (a hard responsive rule). Rows are optionally clickable (a queue row → its case). Header/cell alignment
* defaults to `inherit` so it follows the active text direction (RTL-safe). Colors come from the palette.
* v2 (ui-phase-11) adds optional per-column server-param sort, a sticky-header scroll viewport for long
* pages, per-column `minWidth`, and a results footer line.
* @component AdminDataTable
*/
function AdminDataTable<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps<T>) {
function AdminDataTable<T>({
columns,
rows,
getRowKey,
onRowClick,
dense = true,
ariaLabel,
stickyHeader = false,
stickyMaxHeight = 640,
sort,
onSortChange,
footer,
}: AdminDataTableProps<T>) {
return (
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflowX: 'auto' }}>
<Table size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
{columns.map((col) => (
<TableCell key={col.key} align={col.align ?? 'inherit'} sx={{ width: col.width }}>
{col.header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
<>
<TableContainer
component={Paper}
elevation={0}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
overflowX: 'auto',
...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
}}
>
<Table stickyHeader={stickyHeader} size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
<TableCell
key={col.key}
align={col.align ?? 'inherit'}
sx={{ width: col.width, minWidth: col.minWidth }}
sortDirection={col.sortable && sort?.key === col.key ? sort.direction : false}
>
{col.sortable && onSortChange ? (
<TableSortLabel
active={sort?.key === col.key}
direction={sort?.key === col.key ? sort.direction : 'asc'}
onClick={() => onSortChange(col.key)}
>
{col.header}
</TableSortLabel>
) : (
col.header
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{footer ? (
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', mt: 1 }}>
{footer}
</Typography>
) : null}
</>
);
}
@@ -33,4 +33,28 @@ describe('<AuditLogRow/>', () => {
expect(screen.getByText('0.09')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument();
});
it('toggles aria-expanded on the row header', () => {
wrap(<AuditLogRow entry={ENTRY} />);
const header = screen.getByRole('button');
expect(header).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(header);
expect(header).toHaveAttribute('aria-expanded', 'true');
});
it('falls back to #id when no actorLabel is resolved, else shows the name', () => {
const { rerender } = wrap(<AuditLogRow entry={ENTRY} />);
expect(screen.getByText('#3')).toBeInTheDocument();
rerender(
<ThemeProvider>
<AuditLogRow entry={ENTRY} actorLabel="سارا کریمی" />
</ThemeProvider>,
);
expect(screen.getByText('سارا کریمی')).toBeInTheDocument();
});
it('renders no button role for a row with no diff to expand', () => {
wrap(<AuditLogRow entry={{ ...ENTRY, changedFields: null }} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
+45 -9
View File
@@ -1,13 +1,15 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { FunctionComponent, KeyboardEvent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { AuditLogEntry } from '@/services/admin/types';
import type { AdminUserSummary, AuditLogEntry } from '@/services/admin/types';
import AppIcon from '../common/AppIcon';
export interface AuditLogRowProps {
entry: AuditLogEntry;
/** Resolved actor name (3.2's batch id→label lookup) — falls back to `#id` until the REQ-061 lookup resolves. */
actorLabel?: string;
}
/** Stringify a diff value (JSON for objects, `—` for null). */
@@ -17,17 +19,40 @@ function displayValue(v: unknown): string {
return String(v);
}
/** The actor cell's display text — the resolved name when known, else the honest `#id` fallback. */
export function actorDisplay(actorUserId: number | null, actorLabel?: string): string {
if (actorUserId == null) return '—';
return actorLabel ?? `#${actorUserId}`;
}
/** Resolve a batch `lookupUsers` result into the label `AuditLogRow`/`actorDisplay` expects. */
export function actorLabelFrom(userMap: Map<number, AdminUserSummary> | undefined, userId: number | null): string | undefined {
if (userId == null) return undefined;
return userMap?.get(userId)?.displayName;
}
/**
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old → new per field;
* PII is server-redacted as `<redacted>`). Read-only by design — there is **no** edit/delete affordance
* (phase §5). Presentational; the caller passes the paged entries.
* (phase §5). Presentational; the caller passes the paged entries + (once resolved) the actor's name. The
* expand chevron rotates and the header carries `aria-expanded` + button semantics so open/closed state is
* visible and keyboard-toggleable (ui-phase-11).
* @component AuditLogRow
*/
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel }) => {
const t = useTranslations('admin');
const locale = useLocale();
const [open, setOpen] = useState(false);
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
const hasDetail = fields.length > 0;
const toggle = () => setOpen((v) => !v);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
return (
<Paper
@@ -36,24 +61,35 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
>
<Stack
role={hasDetail ? 'button' : undefined}
tabIndex={hasDetail ? 0 : undefined}
aria-expanded={hasDetail ? open : undefined}
direction="row"
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={fields.length ? () => setOpen((v) => !v) : undefined}
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: hasDetail ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={hasDetail ? toggle : undefined}
onKeyDown={hasDetail ? onKeyDown : undefined}
>
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{entry.action}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
{actorDisplay(entry.actorUserId, actorLabel)}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(entry.occurredAt, locale)}
</Typography>
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null}
{hasDetail ? (
<AppIcon
icon="expand"
size={18}
color="var(--bal-text-secondary)"
style={{ transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)', transform: open ? 'rotate(180deg)' : 'none' }}
/>
) : null}
</Stack>
<Collapse in={open && fields.length > 0}>
<Collapse in={open && hasDetail}>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('audit_diff_title')}
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
const field = screen.getByLabelText('Type to confirm');
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: 'nope' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: ' confirm ' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
});
@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../../theme';
const useUserSearchMock = jest.fn();
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
import NursePicker from './NursePicker';
import type { AdminUserSummary } from '@/services/admin/types';
const ZAHRA: AdminUserSummary = {
id: 101,
displayName: 'زهرا رضایی',
maskedPhone: '0912•••0001',
roles: ['nurse'],
nurseProfileId: 15,
};
describe('<NursePicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
beforeEach(() => {
useUserSearchMock.mockReset();
useUserSearchMock.mockReturnValue({ data: [ZAHRA], isFetching: false });
});
it('always searches with roleFilter="nurse"', async () => {
const user = userEvent.setup();
wrap(<NursePicker value={null} onChange={jest.fn()} label="پرستار" />);
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse');
});
it('returns the picked option, carrying nurseProfileId', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
wrap(<NursePicker value={null} onChange={onChange} label="پرستار" />);
await user.click(screen.getByLabelText('پرستار'));
await user.click(await screen.findByText('زهرا رضایی'));
expect(onChange).toHaveBeenCalledWith(ZAHRA);
});
});
@@ -0,0 +1,14 @@
import { FunctionComponent } from 'react';
import UserPicker, { type UserPickerProps } from '../UserPicker';
export type NursePickerProps = Omit<UserPickerProps, 'roleFilter'>;
/**
* Thin `UserPicker` variant fixed to `roleFilter="nurse"` — the sponsored-nurse assignment picker
* (`admin/partners/[id]`). The selected `AdminUserSummary.nurseProfileId` is the id the caller actually
* needs (a different id space than `.id`, the user id) — see `services/admin/types.ts`.
* @component NursePicker
*/
const NursePicker: FunctionComponent<NursePickerProps> = (props) => <UserPicker {...props} roleFilter="nurse" />;
export default NursePicker;
@@ -0,0 +1,2 @@
export { default } from './NursePicker';
export type { NursePickerProps } from './NursePicker';
@@ -13,6 +13,11 @@ export interface SupportAlertCardProps {
canAct?: boolean;
onAssignSelf?: (alert: SupportAlert) => void;
onResolve?: (alert: SupportAlert) => void;
/** True while the caller's own id hasn't hydrated yet — disables "assign to me" rather than falling back
* to a guessed id (phase §3.2: never default assign-to-self to user #1). */
assignSelfDisabled?: boolean;
/** Already-translated tooltip shown on the disabled "assign to me" button while hydrating. */
assignSelfDisabledTitle?: string;
}
/** Alert status → semantic chip kind. */
@@ -37,7 +42,14 @@ const SEVERITY_ACCENT: Record<SupportAlertSeverity, string> = {
* namespace keyed off the stable code.
* @component SupportAlertCard
*/
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
alert,
canAct = false,
onAssignSelf,
onResolve,
assignSelfDisabled = false,
assignSelfDisabledTitle,
}) => {
const t = useTranslations('admin');
const locale = useLocale();
@@ -85,7 +97,14 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, can
{canAct && alert.status !== 'resolved' ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
{alert.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="assign" onClick={() => onAssignSelf?.(alert)}>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={() => onAssignSelf?.(alert)}
disabled={assignSelfDisabled}
title={assignSelfDisabled ? assignSelfDisabledTitle : undefined}
>
{t('alert_assign_me')}
</AppButton>
) : null}
@@ -0,0 +1,46 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../../theme';
const useUserSearchMock = jest.fn();
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
import UserPicker from './UserPicker';
import type { AdminUserSummary } from '@/services/admin/types';
const MARYAM: AdminUserSummary = { id: 2, displayName: 'مریم احمدی', maskedPhone: '0912•••0002', roles: ['admin'] };
describe('<UserPicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
beforeEach(() => {
useUserSearchMock.mockReset();
useUserSearchMock.mockReturnValue({ data: [MARYAM], isFetching: false });
});
it('renders the label and, once opened, an option with name + masked phone + id', async () => {
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={jest.fn()} label="کاربر" />);
const input = screen.getByLabelText('کاربر');
await user.click(input);
expect(await screen.findByText('مریم احمدی')).toBeInTheDocument();
expect(screen.getByText('0912•••0002 · #2')).toBeInTheDocument();
});
it('calls onChange with the selected user on pick', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={onChange} label="کاربر" />);
await user.click(screen.getByLabelText('کاربر'));
await user.click(await screen.findByText('مریم احمدی'));
expect(onChange).toHaveBeenCalledWith(MARYAM);
});
it('passes the typed query + roleFilter through to useUserSearch', async () => {
const user = userEvent.setup();
wrap(<UserPicker value={null} onChange={jest.fn()} label="پرستار" roleFilter="nurse" />);
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
await waitFor(() => expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse'));
});
});
@@ -0,0 +1,110 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useUserSearch } from '@/services/admin';
import { useDebouncedValue } from '@/services/search';
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
export interface UserPickerProps {
value: AdminUserSummary | null;
onChange: (user: AdminUserSummary | null) => void;
/** Already-translated field label. */
label: string;
placeholder?: string;
helperText?: string;
noOptionsText?: string;
loadingText?: string;
/** Narrow the search to one coarse role (`NursePicker` passes `'nurse'`). */
roleFilter?: DirectoryUserRole;
disabled?: boolean;
error?: boolean;
}
const SEARCH_DEBOUNCE_MS = 300;
/**
* Async name/phone search over the admin user directory (REQ-061, gap — mock-backed until delivered),
* replacing every raw numeric-id `TextField` on an audited action (role grants, partner-center admin
* assignment, sponsored-nurse assignment, alert assignment — phase §3.2). Each option renders
* **name + masked phone + `#id`**, never a bare id, so the caller's confirm dialog can echo a resolved
* **person** instead of `#42`. `NursePicker` is a thin `roleFilter="nurse"` wrapper over this component.
* @component UserPicker
*/
const UserPicker: FunctionComponent<UserPickerProps> = ({
value,
onChange,
label,
placeholder,
helperText,
noOptionsText,
loadingText,
roleFilter,
disabled,
error,
}) => {
const [inputValue, setInputValue] = useState('');
const debouncedQuery = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS);
const search = useUserSearch(debouncedQuery, roleFilter);
const options = search.data ?? [];
return (
<Autocomplete
value={value}
onChange={(_event, next) => onChange(next)}
inputValue={inputValue}
onInputChange={(_event, next) => setInputValue(next)}
options={options}
loading={search.isFetching}
disabled={disabled}
// The directory is already narrowed server-side (mock or real) — never re-filter client-side.
filterOptions={(opts) => opts}
getOptionLabel={(option) => option.displayName}
isOptionEqualToValue={(option, selected) => option.id === selected.id}
noOptionsText={noOptionsText}
loadingText={loadingText}
renderOption={(optionProps, option) => {
const { key, ...rest } = optionProps;
return (
<Box component="li" key={key} {...rest}>
<Stack sx={{ gap: 0.125, py: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{option.displayName}
</Typography>
<Typography component="span" variant="caption" dir="ltr" sx={{ color: 'text.secondary' }}>
{option.maskedPhone} · #{option.id}
</Typography>
</Stack>
</Box>
);
}}
renderInput={({ slotProps: autocompleteSlotProps, ...rest }) => (
<TextField
{...rest}
label={label}
placeholder={placeholder}
helperText={helperText}
error={error}
slotProps={{
...autocompleteSlotProps,
input: {
...autocompleteSlotProps.input,
endAdornment: (
<>
{search.isFetching ? <CircularProgress color="inherit" size={16} /> : null}
{autocompleteSlotProps.input.endAdornment}
</>
),
},
}}
/>
)}
/>
);
};
export default UserPicker;
@@ -0,0 +1,2 @@
export { default } from './UserPicker';
export type { UserPickerProps } from './UserPicker';
+4
View File
@@ -30,3 +30,7 @@ export { default as RefundPanel } from './RefundPanel';
export type { RefundPanelProps } from './RefundPanel';
export { default as AdminMessageBubble } from './AdminMessageBubble';
export type { AdminMessageBubbleProps } from './AdminMessageBubble';
export { default as UserPicker } from './UserPicker';
export type { UserPickerProps } from './UserPicker';
export { default as NursePicker } from './NursePicker';
export type { NursePickerProps } from './NursePicker';
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
const field = screen.getByLabelText('Type to confirm');
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: 'nope' } });
expect(confirmBtn).toBeDisabled();
fireEvent.change(field, { target: { value: ' confirm ' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
});
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/**
* When set, confirm stays disabled until the typed value case-insensitively matches ONE of these
* (e.g. the literal word «تایید» OR the exact amount digit-string) — the guard for an irreversible
* action like running a payout batch. A gate, not a payload: `onConfirm`'s signature is unchanged, the
* typed value itself is never passed to the caller.
*/
requireTypedConfirmation?: string[];
typedConfirmationLabel?: string;
typedConfirmationPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
requireReason = false,
reasonLabel,
reasonPlaceholder,
requireTypedConfirmation,
typedConfirmationLabel,
typedConfirmationPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const [typedValue, setTypedValue] = useState('');
const close = () => {
setReason('');
setTypedValue('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
setTypedValue('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
const confirmDisabled =
loading ||
(requireReason && reason.trim().length === 0) ||
(requireTypedConfirmation != null &&
!requireTypedConfirmation.some((v) => v.trim().toLowerCase() === typedValue.trim().toLowerCase()));
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
sx={{ mt: 2 }}
/>
) : null}
{requireTypedConfirmation ? (
<TextField
autoFocus
fullWidth
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
label={typedConfirmationLabel}
placeholder={typedConfirmationPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme';
import PageHeader from './PageHeader';
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
});
it('renders the meta slot below the title when provided', () => {
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('omits the back button when backTo is not given', () => {
wrap(<PageHeader title="T" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
});
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
const onBack = jest.fn();
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
});
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode;
/**
* Optional node rendered below the title/subtitle — a chip row (status/category/linked-record chips)
* or any other short meta line that isn't a button (ui-phase-11: the admin ticket thread's category/
* status/linked-booking/linked-refund chips). Kept distinct from `actions`, which is end-aligned and
* reserved for buttons.
*/
meta?: ReactNode;
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
backTo?: string;
/** Already-translated accessible label for the back button (required when `backTo` is set). */
/** Already-translated accessible label for the back button (required when `backTo` or `onBack` is set). */
backLabel?: string;
/**
* Optional back-navigation handler — an alternative to `backTo` for a caller that needs `router.back()`
* semantics (e.g. `useAdminBackToList`) rather than a fixed link target. Takes precedence over `backTo`
* when both are given (they shouldn't be).
*/
onBack?: () => void;
}
/**
* The standard page header — title + optional subtitle, an end-aligned actions slot, and an optional
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational,
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`).
* The standard page header — title + optional subtitle + optional meta row, an end-aligned actions slot,
* and an optional back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin
* alias); every area page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead.
* Presentational, caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under
* `dir="rtl"`).
* @component PageHeader
*/
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
const PageHeader: FunctionComponent<PageHeaderProps> = ({
title,
subtitle,
actions,
meta,
backTo,
backLabel,
onBack,
}) => (
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
{backTo ? (
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
{backTo || onBack ? (
<AppIconButton
icon="back"
to={onBack ? undefined : backTo}
onClick={onBack}
title={backLabel}
sx={{ mt: 0.25 }}
iconProps={{ size: 20 }}
/>
) : null}
<Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
{subtitle}
</Typography>
) : null}
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
</Box>
</Stack>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
+4
View File
@@ -121,6 +121,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
export const adminPartnerCenterPath = (centerId: number | string): string =>
`${ROUTES.ADMIN_PARTNERS}/${centerId}`;
/** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
export const partnerBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
/** The customer booking-detail view (f8) — the E2 care-record history's «مشاهده رزرو» link lands here. */
export const bookingDetailPath = (bookingId: number | string): string => `${ROUTES.BOOKINGS}/${bookingId}`;
+1
View File
@@ -2,3 +2,4 @@ export * from './auth';
export * from './capabilities';
export * from './event';
export * from './layout';
export * from './useAdminListState';
+115
View File
@@ -0,0 +1,115 @@
import { renderHook, act } from '@testing-library/react';
const mockReplace = jest.fn();
const mockBack = jest.fn();
const mockPush = jest.fn();
let mockSearchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace, back: mockBack, push: mockPush }),
usePathname: () => '/fa/admin/tickets',
useSearchParams: () => mockSearchParams,
}));
import { useAdminListState, useAdminBackToList } from './useAdminListState';
interface Filters {
status?: string;
}
const CONFIG = {
parse: (params: URLSearchParams): Filters => ({ status: params.get('status') ?? undefined }),
serialize: (filters: Filters): Record<string, string> => (filters.status ? { status: filters.status } : {}),
empty: {} as Filters,
};
describe('useAdminListState', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
mockSearchParams = new URLSearchParams();
});
it('reads the initial applied filters + page from the URL', () => {
mockSearchParams = new URLSearchParams('status=open&page=3');
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: 'open' });
expect(result.current.draft).toEqual({ status: 'open' });
expect(result.current.page).toBe(3);
});
it('defaults to page 1 with empty filters when the URL carries none', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: undefined });
expect(result.current.page).toBe(1);
});
it('editing draft never touches applied or the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: undefined });
expect(mockReplace).not.toHaveBeenCalled();
});
it('apply commits draft into applied, resets to page 1, and writes the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
act(() => result.current.apply());
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('clear resets draft + applied and writes a bare URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.clear());
expect(result.current.draft).toEqual({});
expect(result.current.applied).toEqual({});
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets', { scroll: false });
});
it('applyFilters commits an explicit value atomically (never a stale draft)', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
// No setDraft call first — applyFilters must not depend on draft having been set beforehand.
act(() => result.current.applyFilters({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('goToPage keeps applied filters and appends page to the URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.goToPage(2));
expect(result.current.page).toBe(2);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=open&page=2', { scroll: false });
});
});
describe('useAdminBackToList', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
});
it('calls router.back() when there is browser history', () => {
Object.defineProperty(window, 'history', { value: { length: 3 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockBack).toHaveBeenCalledTimes(1);
expect(mockPush).not.toHaveBeenCalled();
});
it('falls back to pushing the list href when there is no history', () => {
Object.defineProperty(window, 'history', { value: { length: 1 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockPush).toHaveBeenCalledWith('/fa/admin/tickets');
expect(mockBack).not.toHaveBeenCalled();
});
});
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
/** The URL query-param name every admin/partner worklist uses for its page number. */
const PAGE_PARAM = 'page';
export interface AdminListStateConfig<F> {
/** Rebuild a filters object from the URL's current search params (only defined keys are read). */
parse: (params: URLSearchParams) => F;
/** Serialize a filters object to a plain string record, omitting empty/absent filters. */
serialize: (filters: F) => Record<string, string>;
/** The "clear"/default filters value. */
empty: F;
}
export interface AdminListState<F> {
/** Local, uncommitted filter edits — typing here never refetches or touches the URL. */
draft: F;
setDraft: Dispatch<SetStateAction<F>>;
/** The filters actually driving the query (and the URL) — set only by `apply`/`clear`. */
applied: F;
page: number;
/** Commits `draft` into `applied`, resets to page 1, and writes both into the URL. */
apply: () => void;
/**
* Commits an EXPLICIT filters value (not `draft`) into `applied` and the URL, resetting to page 1 — for a
* discrete control (a status tab/select) that should commit the instant it changes. Prefer this over
* `setDraft(next); apply()` in the same handler: `apply()` closes over the `draft` from the render it was
* created in, so calling it synchronously right after `setDraft` would commit the OLD value, not `next`.
* Also updates `draft` to the same value, so the two states never drift apart.
*/
applyFilters: (filters: F) => void;
/** Resets both draft and applied to `empty` and writes that (page 1) into the URL. */
clear: () => void;
/** Moves to a page, keeping `applied` filters, and writes it into the URL. */
goToPage: (page: number) => void;
}
/**
* URL-synced worklist state for the admin/partner consoles — mirrors **applied** filters + the current
* page into `searchParams` via `router.replace` (never a full navigation, `scroll: false`), so browser
* back/refresh/a pasted link all reproduce the exact same queue view. Draft filter state stays local per
* the established draft-vs-applied pattern (tickets/audit): typing in `draft` never refetches and never
* touches the URL — only `apply`/`clear`/`goToPage` do, mirroring `services/search/filterParams.ts`'s
* "the filter object is the query key" model onto every other worklist.
*
* The initial `applied`/`page` are read from the URL **once, on mount** — after that the URL only ever
* follows local state, so a user typing in `draft` can never have their input clobbered by a stale
* `searchParams` re-read. Callers using this hook must be rendered inside a `<Suspense>` boundary
* (`useSearchParams` requirement) — wrap the page body the way `SearchScreen`/`search/SearchScreen.tsx`
* does.
*/
export function useAdminListState<F>({
parse,
serialize,
empty,
}: AdminListStateConfig<F>): AdminListState<F> {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [initial] = useState<{ filters: F; page: number }>(() => {
const rawPage = Number(searchParams.get(PAGE_PARAM));
return {
filters: parse(searchParams),
page: Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1,
};
});
const [draft, setDraft] = useState<F>(initial.filters);
const [applied, setApplied] = useState<F>(initial.filters);
const [page, setPage] = useState<number>(initial.page);
const writeUrl = useCallback(
(filters: F, nextPage: number) => {
const params = new URLSearchParams(serialize(filters));
if (nextPage > 1) params.set(PAGE_PARAM, String(nextPage));
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[pathname, router, serialize],
);
const apply = useCallback(() => {
setApplied(draft);
setPage(1);
writeUrl(draft, 1);
}, [draft, writeUrl]);
const applyFilters = useCallback(
(filters: F) => {
setDraft(filters);
setApplied(filters);
setPage(1);
writeUrl(filters, 1);
},
[writeUrl],
);
const clear = useCallback(() => {
setDraft(empty);
setApplied(empty);
setPage(1);
writeUrl(empty, 1);
}, [empty, writeUrl]);
const goToPage = useCallback(
(next: number) => {
setPage(next);
writeUrl(applied, next);
},
[applied, writeUrl],
);
return { draft, setDraft, applied, page, apply, applyFilters, clear, goToPage };
}
/**
* A detail page's "back" affordance: a real `router.back()` when there is browser history to return to
* (the common case — a queue row was clicked to get here, so back restores its filter/page/scroll state),
* falling back to pushing `listHref` when there isn't (a pasted/bookmarked detail link). `listHref` must
* already be locale-prefixed (`next/navigation`'s router does not add it) — pass
* `` `/${locale}${ROUTES.ADMIN_TICKETS}` ``.
*/
export function useAdminBackToList(listHref: string): () => void {
const router = useRouter();
return useCallback(() => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push(listHref);
}
}, [router, listHref]);
}
+18 -3
View File
@@ -1,7 +1,8 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { ProfileSummary } from '@/components';
import { Stack } from '@mui/material';
import { ProfileSummary, StatusChip } from '@/components';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import { useMyPartnerCenter } from '@/services/partnerCenter';
@@ -11,11 +12,14 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
* Partner-center portal shell (f15) — a **separate authz scope** from the Balinyaar admin console. A
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced
* tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name
* (skeleton while resolving) — the page-level access-denied state (403/404) stays where it is.
* (skeleton while resolving) **plus a compact merchant-of-record indicator** (ui-phase-11 — previously
* only the home page's own header showed this, so it disappeared once a center admin navigated away) —
* the page-level access-denied state (403/404) stays where it is.
* @layout PartnerLayout
*/
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter();
const sidebarItems: Array<LinkToPage> = useMemo(
@@ -31,7 +35,18 @@ const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<TopBarAndSideBarLayout
sidebarItems={sidebarItems}
identity={<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />}
identity={
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
sx={{ height: 20, fontSize: '0.6875rem', display: { xs: 'none', sm: 'inline-flex' } }}
/>
) : null}
</Stack>
}
>
{children}
</TopBarAndSideBarLayout>
@@ -4,9 +4,11 @@ import { ADMIN_PAGE_SIZE } from '../constants';
import type {
AdminApi,
AdminRole,
AdminUserSummary,
AuditFilters,
AuditLogEntry,
ConfigChange,
DirectoryUserRole,
Holiday,
HolidayFilters,
HolidayInput,
@@ -175,4 +177,18 @@ export const adminClientApi: AdminApi = {
body: JSON.stringify({ userId, role }),
});
},
// User directory (REQ-061 — routes proposed; not live). Kept real-shaped so the swap is one line.
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
const q = new URLSearchParams({ q: query });
if (roleFilter) q.set('role', roleFilter);
return unwrap(await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/search?${q.toString()}`));
},
lookupUsers: async (userIds: number[]) =>
unwrap(
await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/lookup`, {
method: 'POST',
body: JSON.stringify({ userIds }),
}),
),
};
+43
View File
@@ -2,9 +2,11 @@ import type { PageParams, Paginated } from '@/lib/api/types';
import type {
AdminApi,
AdminRole,
AdminUserSummary,
AuditFilters,
AuditLogEntry,
ConfigChange,
DirectoryUserRole,
Holiday,
HolidayFilters,
HolidayInput,
@@ -103,6 +105,34 @@ const ROLES: RoleGrant[] = [
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
];
// ── User directory (REQ-061 gap) — backs UserPicker/NursePicker + AuditLogRow's actor-name resolve.
// `phone` is the mock's own full-number field, kept out of `AdminUserSummary` — only the masked form
// ever leaves `toDirectorySummary` (the same write-then-masked PII discipline as everywhere else).
interface DirectoryEntry extends AdminUserSummary {
phone: string;
}
const DIRECTORY: DirectoryEntry[] = [
{ id: 1, displayName: 'مدیر ارشد بالین‌یار', phone: '09120000001', maskedPhone: '0912•••0001', roles: ['admin'] },
{ id: 2, displayName: 'مریم احمدی', phone: '09120000002', maskedPhone: '0912•••0002', roles: ['admin'] },
{ id: 3, displayName: 'سارا کریمی', phone: '09120000003', maskedPhone: '0912•••0003', roles: ['admin'] },
{ id: 4, displayName: 'رضا نوری', phone: '09120000004', maskedPhone: '0912•••0004', roles: ['admin'] },
{ id: 5, displayName: 'نگار صادقی', phone: '09120000005', maskedPhone: '0912•••0005', roles: ['admin'] },
{ id: 6, displayName: 'امیر حسینی', phone: '09120000006', maskedPhone: '0912•••0006', roles: ['admin'] },
{ id: 101, displayName: 'زهرا رضایی', phone: '09121110001', maskedPhone: '0912•••0001', roles: ['nurse'], nurseProfileId: 15 },
{ id: 102, displayName: 'فاطمه محمدی', phone: '09121110002', maskedPhone: '0912•••0002', roles: ['nurse'], nurseProfileId: 16 },
{ id: 103, displayName: 'لیلا صادقی', phone: '09121110003', maskedPhone: '0912•••0003', roles: ['nurse'], nurseProfileId: 17 },
{ id: 104, displayName: 'مینا رستمی', phone: '09121110004', maskedPhone: '0912•••0004', roles: ['nurse'], nurseProfileId: 18 },
{ id: 201, displayName: 'حسین یزدانی', phone: '09122220001', maskedPhone: '0912•••0001', roles: ['customer'] },
{ id: 202, displayName: 'مینا اکبری', phone: '09122220002', maskedPhone: '0912•••0002', roles: ['customer'] },
{ id: 301, displayName: 'شرکت پرستاری آرامش', phone: '09123330001', maskedPhone: '0912•••0001', roles: ['partner'] },
];
function toDirectorySummary(e: DirectoryEntry): AdminUserSummary {
const { phone: _phone, ...summary } = e;
return summary;
}
export const adminMockApi: AdminApi = {
listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
@@ -193,4 +223,17 @@ export const adminMockApi: AdminApi = {
if (r) r.revokedAt = new Date().toISOString();
return delay(undefined);
},
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
let items = DIRECTORY;
if (roleFilter) items = items.filter((e) => e.roles.includes(roleFilter));
const q = query.trim().toLowerCase();
if (q.length > 0) items = items.filter((e) => e.displayName.toLowerCase().includes(q) || e.phone.includes(q));
return delay(items.slice(0, 10).map(toDirectorySummary));
},
lookupUsers: async (userIds: number[]) => {
const ids = new Set(userIds);
return delay(DIRECTORY.filter((e) => ids.has(e.id)).map(toDirectorySummary));
},
};
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { AdminUserSummary } from '../types';
/**
* Batch idlabel resolve (REQ-061, gap) one request for every actor/owner id a page renders (`AuditLogRow`,
* role grants, ), never one request per row. Callers should memoize `userIds` (a stable array reference)
* so the query key doesn't churn every render; pass a deduped list.
*/
export function useUserLookup(userIds: number[]) {
return useQuery({
queryKey: adminKeys.userLookup(userIds),
queryFn: () => adminApi.lookupUsers(userIds),
enabled: userIds.length > 0,
staleTime: 5 * 60 * 1000,
gcTime: ADMIN_GC_TIME,
select: (data): Map<number, AdminUserSummary> => new Map(data.map((u) => [u.id, u])),
});
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { DirectoryUserRole } from '../types';
/** Minimum query length before searching — avoids a fan-out request per keystroke on 01 chars. */
const MIN_QUERY_LENGTH = 2;
/**
* Name/phone search over the admin user directory (REQ-061, gap) backs `UserPicker`/`NursePicker`.
* The caller debounces `query`; this hook only gates on length so an empty/near-empty query renders no
* options instead of the whole directory.
*/
export function useUserSearch(query: string, roleFilter?: DirectoryUserRole) {
const trimmed = query.trim();
return useQuery({
queryKey: adminKeys.userSearch(trimmed, roleFilter),
queryFn: () => adminApi.searchUsers(trimmed, roleFilter),
enabled: trimmed.length >= MIN_QUERY_LENGTH,
staleTime: 30 * 1000,
gcTime: ADMIN_GC_TIME,
});
}
+2
View File
@@ -15,3 +15,5 @@ export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
export { useAdminRoles } from './hooks/useAdminRoles';
export { useGrantRole } from './hooks/useGrantRole';
export { useRevokeRole } from './hooks/useRevokeRole';
export { useUserSearch } from './hooks/useUserSearch';
export { useUserLookup } from './hooks/useUserLookup';
+6 -1
View File
@@ -1,5 +1,5 @@
import type { PageParams } from '@/lib/api/types';
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types';
import type { AuditFilters, DirectoryUserRole, HolidayFilters, SupportAlertFilters } from './types';
/**
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
@@ -27,4 +27,9 @@ export const adminKeys = {
roles: () => [...adminKeys.all, 'roles'] as const,
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
users: () => [...adminKeys.all, 'users'] as const,
userSearch: (query: string, roleFilter?: DirectoryUserRole) =>
[...adminKeys.users(), 'search', query, roleFilter ?? null] as const,
userLookup: (userIds: number[]) => [...adminKeys.users(), 'lookup', [...userIds].sort((a, b) => a - b)] as const,
};
+25
View File
@@ -143,6 +143,26 @@ export interface SupportAlertFilters {
ownerUserId?: number;
}
// ── User directory (admin lookup; gap — REQ-061) ─────────────────────────────────────────────────────
/** The coarse app roles a directory entry may hold — enough to label a picker option. */
export type DirectoryUserRole = 'customer' | 'nurse' | 'admin' | 'partner';
/**
* `AdminUserSummaryDto` (REQ-061, gap no user-search endpoint exists yet). Backs `UserPicker`/
* `NursePicker`: search-by-name/phone + a batch idlabel lookup, so every audited action can target a
* resolved **person** instead of a hand-typed numeric id. `maskedPhone` follows the same PII discipline
* as everywhere else (never the full number). `nurseProfileId` is set only when `roles` includes `nurse`
* the id `NursePicker` actually needs for sponsorship/roster assignment (a different id space than
* `id`, the user id).
*/
export interface AdminUserSummary {
id: number;
displayName: string;
maskedPhone: string;
roles: DirectoryUserRole[];
nurseProfileId?: number | null;
}
// ── RBAC (b15 — role endpoints not yet in the contract; mock-primary, REQ-031) ─────────────────────────
/** The fine-grained admin roles the RBAC grid grants/revokes (aligned with the b2 `AdminRole` enum). */
export type AdminRole = 'super_admin' | 'admin' | 'support' | 'finance' | 'moderation';
@@ -180,4 +200,9 @@ export interface AdminApi {
listRoles(userId?: number): Promise<RoleGrant[]>;
grantRole(userId: number, role: AdminRole): Promise<void>;
revokeRole(userId: number, role: AdminRole): Promise<void>;
// user directory (deferred-if-missing — REQ-061)
/** Search by name/phone (min 2 chars); `roleFilter` narrows to one coarse role (e.g. `NursePicker`). */
searchUsers(query: string, roleFilter?: DirectoryUserRole): Promise<AdminUserSummary[]>;
/** Batch id→label resolve — powers `AuditLogRow`'s actor names without one request per row. */
lookupUsers(userIds: number[]): Promise<AdminUserSummary[]>;
}
@@ -8,6 +8,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -111,6 +112,9 @@ export const partnerCenterClientApi: PartnerCenterApi = {
if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
},
// REQ-064 — no single-booking read in the b15 contract yet; proposed shape (sibling of the list route).
getMySponsoredBookingDetail: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredBookingDetail>>(`${API}/centers/me/bookings/${bookingId}`)),
listMySettlement: async (params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
@@ -7,6 +7,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -119,6 +120,44 @@ const BOOKINGS: Record<number, SponsoredBooking[]> = {
3: [],
};
/** The normal-path lifecycle ladder a booking climbs before any dispute/cancellation branch. */
const BOOKING_STATUS_LADDER = ['confirmed', 'in_progress', 'completed', 'closed'] as const;
/**
* A synthetic 2-4-step status timeline consistent with the booking's current `status` (REQ-064 the wire
* has no timeline endpoint yet). Earlier steps get earlier (larger days-ago) timestamps than later ones.
*/
function buildBookingTimeline(status: string): { status: string; occurredAt: string }[] {
if (status === 'pending_payment') return [{ status: 'pending_payment', occurredAt: isoDaysAgo(1) }];
if (status === 'cancelled') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(4) },
{ status: 'cancelled', occurredAt: isoDaysAgo(3) },
];
}
if (status === 'disputed') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(6) },
{ status: 'in_progress', occurredAt: isoDaysAgo(4) },
{ status: 'completed', occurredAt: isoDaysAgo(3) },
{ status: 'disputed', occurredAt: isoDaysAgo(1) },
];
}
const stepIndex = BOOKING_STATUS_LADDER.indexOf(status as (typeof BOOKING_STATUS_LADDER)[number]);
if (stepIndex === -1) return [{ status, occurredAt: isoDaysAgo(1) }];
return BOOKING_STATUS_LADDER.slice(0, stepIndex + 1).map((s, i) => ({
status: s,
occurredAt: isoDaysAgo(stepIndex - i + 1),
}));
}
function findSponsoredBooking(bookingId: number): SponsoredBooking {
const all = Object.values(BOOKINGS).flat();
const booking = all.find((b) => b.bookingId === bookingId);
if (!booking) throw new Error(`Mock sponsored booking ${bookingId} not found`);
return booking;
}
/** Build a reconciling commission invoice (VAT on the commission line only; total = comm + bnpl + vat). */
function makeInvoice(id: number, bookingId: number, grossIrr: bigint, commissionIrr: bigint, bnplIrr: bigint, vatRate: number, days: number): CenterInvoice {
const vatIrr = (commissionIrr * BigInt(Math.round(vatRate * 100))) / BigInt(100);
@@ -241,6 +280,10 @@ export const partnerCenterMockApi: PartnerCenterApi = {
if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params));
},
getMySponsoredBookingDetail: async (bookingId: number): Promise<SponsoredBookingDetail> => {
const booking = findSponsoredBooking(bookingId);
return delay({ ...booking, timeline: buildBookingTimeline(booking.status) });
},
listMySettlement: async (params) => {
const center = centerById(MOCK_MY_CENTER_ID);
// Non-MoR centers issue no commission invoices here — the portal renders the "via Balinyaar" state.
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/**
* The scoped, read-only booking detail behind a sponsored-bookings-list row (REQ-064) dates + a status
* timeline only, no clinical content. `bookingId` is `undefined` while the route param hasn't resolved yet
* (mirrors the disabled-until-ready pattern used by the nurse payout detail hook).
*/
export function useMySponsoredBookingDetail(bookingId: number | undefined) {
return useQuery({
queryKey: centerKeys.mySponsoredBookingDetail(bookingId ?? -1),
queryFn: () => partnerCenterApi.getMySponsoredBookingDetail(bookingId as number),
enabled: bookingId != null && Number.isFinite(bookingId),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -13,4 +13,5 @@ export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCe
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySponsoredBookingDetail } from './hooks/useMySponsoredBookingDetail';
export { useMySettlement } from './hooks/useMySettlement';
@@ -21,5 +21,6 @@ export const centerKeys = {
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySponsoredBookingDetail: (bookingId: number) => [...centerKeys.myCenter(), 'bookings', bookingId] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
};
+19 -3
View File
@@ -72,6 +72,16 @@ export interface SponsoredBooking {
status: string;
}
/**
* The portal's scoped, read-only booking detail (REQ-064) `SponsoredBooking` plus a server-truth status
* timeline. Deliberately bounded to dates/status/patient display name: no clinical content, no address, no
* money the portal never sees more than a center legally needs to confirm a booking happened.
*/
export interface SponsoredBookingDetail extends SponsoredBooking {
/** Server-truth status timeline for this booking — dates only, no clinical content (portal scope). */
timeline: { status: string; occurredAt: string }[];
}
/** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
@@ -104,10 +114,14 @@ export interface PartnerCenterFilters {
isActive?: boolean;
}
/** Bookings list filter (portal). */
export interface SponsoredBookingFilters {
/**
* Bookings list filter (portal). A `type` (not `interface`) so it satisfies `useAdminListState`'s
* `Record<string, unknown>` generic constraint a plain interface has no implicit index signature and
* TS rejects it as a type argument there, even though it's structurally identical.
*/
export type SponsoredBookingFilters = {
status?: string;
}
};
/**
* The partner-center API seam admin-side management + the center-scoped portal reads. The real client
@@ -127,6 +141,8 @@ export interface PartnerCenterApi {
getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
/** REQ-064 — the scoped read-only detail behind a bookings-list row (dates + status timeline only). */
getMySponsoredBookingDetail(bookingId: number): Promise<SponsoredBookingDetail>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
}
@@ -262,4 +262,18 @@ export const ticketsClientApi: TicketsApi = {
}),
),
// Lifecycle (REQ-063 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship;
// gated behind `TICKET_LIFECYCLE_ENABLED` on the caller side until then.
closeTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/close`, { method: 'POST' });
},
reopenTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/reopen`, { method: 'POST' });
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/assign`, {
method: 'POST',
body: JSON.stringify({ ownerUserId }),
});
},
};
@@ -67,6 +67,8 @@ interface StoredTicket {
messages: StoredMessage[];
/** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */
unread: number;
/** The staff member the ticket is assigned to (REQ-063, mock-only — no wire field yet). */
assigneeUserId: number | null;
}
const CUSTOMER = MOCK_VIEWER_USER_ID.customer;
@@ -117,6 +119,7 @@ const tickets: StoredTicket[] = [
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 1,
assigneeUserId: null,
messages: [
{ id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) },
{ id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) },
@@ -137,6 +140,7 @@ const tickets: StoredTicket[] = [
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
assigneeUserId: null,
messages: [
{ id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) },
{ id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) },
@@ -154,6 +158,7 @@ const tickets: StoredTicket[] = [
closedAt: isoMinsAgo(4_000),
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
assigneeUserId: ADMIN,
messages: [
{ id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) },
{ id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) },
@@ -280,6 +285,7 @@ function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail
closedAt: t.closedAt,
participants: t.participants,
messages,
assigneeUserId: t.assigneeUserId,
};
}
@@ -343,6 +349,7 @@ export const ticketsMockApi: TicketsApi = {
refundId: body.refundId ?? null,
openedById: opener,
closedAt: null,
assigneeUserId: null,
participants,
unread: 0,
messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }],
@@ -424,4 +431,25 @@ export const ticketsMockApi: TicketsApi = {
t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
return { messageId: id, ticketId, sentAt };
},
// Lifecycle (REQ-063). Mock is the source of truth here — no wire route exists yet.
closeTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'closed';
t.closedAt = new Date().toISOString();
},
reopenTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'open';
t.closedAt = null;
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.assigneeUserId = ownerUserId;
},
};
+8
View File
@@ -43,6 +43,14 @@ export const TICKETS_ATTACHMENTS_ENABLED = false;
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000;
/**
* Ticket lifecycle controls (close/reopen/assign) capability gate mirrors the `TICKETS_ATTACHMENTS_ENABLED`
* pattern above. Default **off**: the backend has no close/reopen/assign routes yet (REQ-063), so the
* real-path controls stay hidden rather than pointing at a route that would 404. Flip once the endpoints
* land no component change beyond this flag.
*/
export const TICKET_LIFECYCLE_ENABLED = false;
/**
* DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message
* body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Assign a ticket to a staff owner (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job;
* today the only caller is "assign to me"). Invalidates the admin thread + queue on success.
*/
export function useAssignTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, { ownerUserId: number }>({
mutationFn: ({ ownerUserId }) => ticketsApi.assignTicket(ticketId, ownerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Close an open ticket (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket leaves the open worklist immediately.
*/
export function useCloseTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.closeTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Reopen a closed ticket (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket reappears in the open worklist immediately.
*/
export function useReopenTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.reopenTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
+5
View File
@@ -15,3 +15,8 @@ export { useAdminTickets } from './hooks/useAdminTickets';
export { useAdminTicket } from './hooks/useAdminTicket';
export { useAdminTicketThread } from './hooks/useAdminTicketThread';
export { usePostAdminMessage } from './hooks/usePostAdminMessage';
// Ticket lifecycle (ui-phase-11, REQ-063) — close/reopen/assign, gated behind TICKET_LIFECYCLE_ENABLED.
export { useCloseTicket } from './hooks/useCloseTicket';
export { useReopenTicket } from './hooks/useReopenTicket';
export { useAssignTicket } from './hooks/useAssignTicket';
+15
View File
@@ -176,6 +176,12 @@ export interface AdminTicketDetail {
closedAt: string | null;
participants: TicketParticipant[];
messages: AdminTicketMessage[];
/**
* The staff member the ticket is assigned to, or `null` when unassigned. **Not on the wire yet** the
* b15 admin DTOs carry no assignment field (REQ-063). Mock-only until delivered; the real mapper always
* yields `null` (never fabricates an owner).
*/
assigneeUserId?: number | null;
}
export interface AdminTicketSummary {
id: number;
@@ -229,4 +235,13 @@ export interface TicketsApi {
listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>;
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
/**
* Ticket lifecycle mutations (REQ-063 no live route yet, gated behind `TICKET_LIFECYCLE_ENABLED`). A
* resolved ticket has no way to leave the admin queue today; these three close the loop. `assignTicket`
* sets the (currently mock-only) `assigneeUserId`.
*/
closeTicket(ticketId: number): Promise<void>;
reopenTicket(ticketId: number): Promise<void>;
assignTicket(ticketId: number, ownerUserId: number): Promise<void>;
}
@@ -6,6 +6,7 @@ import type {
AdminVerificationCase,
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
AdminVerificationQueuePage,
AdminVerificationStepDetail,
CredentialDetailsInput,
DecideStepInput,
@@ -216,9 +217,12 @@ export const verificationClientApi: VerificationApi = {
listVerificationQueue: async (
filters: AdminVerificationQueueFilters,
params: PageParams,
): Promise<Paginated<AdminVerificationQueueItem>> => {
): Promise<AdminVerificationQueuePage> => {
const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status);
// REQ-062: proposed `q` search param — the server ignores it today (no-op, never a 400) until the
// endpoint gains the filter; the client sends it so the swap is a no-op once it lands.
if (filters.search) query.set('q', filters.search);
query.set('page', String(params.page ?? 1));
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
const page = unwrap(
@@ -226,6 +230,8 @@ export const verificationClientApi: VerificationApi = {
);
// REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint
// exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact.
// REQ-062: `counts` stays undefined on the real path — the queue screen renders the status tabs
// without badge counts until the endpoint serves the whole-desk totals.
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
},
@@ -388,13 +388,23 @@ export const verificationMockApi: VerificationApi = {
listVerificationQueue: async (filters, params) => {
await sleep(MOCK_LATENCY_MS);
// REQ-062: whole-desk counts, computed over the ENTIRE unfiltered set — never the current
// status/search/page slice — so the status-tab badges reflect the true queue at all times.
const counts = {
pending: adminCases.filter((record) => record.status === 'pending').length,
in_review: adminCases.filter((record) => record.status === 'in_review').length,
};
// Default (no status filter) shows the whole desk — both `pending` and `in_review`.
const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem);
const search = filters.search?.trim().toLowerCase();
const matched = adminCases
.filter((record) => wanted.includes(record.status))
.filter((record) => !search || record.nurseName.toLowerCase().includes(search))
.map(toQueueItem);
const page = params.page ?? 1;
const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize, counts };
},
getVerificationCase: async (nurseVerificationId) => {
+18 -2
View File
@@ -197,6 +197,22 @@ export interface AdminVerificationQueueItem {
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */
export interface AdminVerificationQueueFilters {
status?: 'pending' | 'in_review';
/**
* Case-insensitive name/phone search (REQ-062, filed by ui-phase-11 not yet on the wire). The mock
* matches against the seeded `nurseName`; the real client maps it to a proposed `q` query param and the
* server currently ignores it (no-ops, never throws) until the endpoint gains the filter.
*/
search?: string;
}
/**
* The queue list response the standard paginated envelope plus optional whole-desk `counts` for the
* status tabs (REQ-062). `counts` reflects the **entire unfiltered queue** (not the current page/status/
* search), so the tab badges never drift from the true `pending`/`in_review` totals. Mock-tolerant:
* `undefined` on the real path until the endpoint serves it callers render the tabs without counts.
*/
export interface AdminVerificationQueuePage extends Paginated<AdminVerificationQueueItem> {
counts?: { pending: number; in_review: number };
}
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
@@ -276,8 +292,8 @@ export interface VerificationApi {
getTrustBadge(nurseId: number): Promise<TrustBadge>;
// --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<Paginated<AdminVerificationQueueItem>>;
/** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
+1
View File
@@ -6,5 +6,6 @@ export * from './navigation';
export * from './number';
export * from './sessionStorage';
export * from './sleep';
export * from './toCsv';
export * from './type';
export * from './text';
+45
View File
@@ -0,0 +1,45 @@
import { toCsv } from './toCsv';
describe('toCsv', () => {
const headers = [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Name' },
];
it('serializes a header row + one row per item', () => {
const csv = toCsv(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
headers,
);
expect(csv).toBe('ID,Name\r\n1,Alice\r\n2,Bob');
});
it('quotes a field containing a comma', () => {
const csv = toCsv([{ id: 1, name: 'Doe, Jane' }], headers);
expect(csv).toBe('ID,Name\r\n1,"Doe, Jane"');
});
it('quotes a field containing an embedded quote, doubling it', () => {
const csv = toCsv([{ id: 1, name: 'Say "hi"' }], headers);
expect(csv).toBe('ID,Name\r\n1,"Say ""hi"""');
});
it('uses CRLF line endings throughout', () => {
const csv = toCsv(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
headers,
);
expect(csv.split('\r\n')).toHaveLength(3);
expect(csv).not.toMatch(/(?<!\r)\n/);
});
it('renders an empty rows list as just the header row', () => {
expect(toCsv([], headers)).toBe('ID,Name');
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* A small, dependency-free CSV serializer (RFC 4180-ish). Used for client-side "export the currently loaded
* page" affordances (e.g. the partner settlement invoices table) no server round-trip, no library.
*/
export interface CsvColumn {
/** The row-object key this column reads. */
key: string;
/** Already-translated column header. */
label: string;
}
/** Wraps a field in double quotes (doubling any internal quote) when it contains a comma, quote, or newline. */
function quoteField(value: string): string {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/**
* Builds a CSV string (header row + one row per item, `\r\n` line endings Excel expects CRLF) from a list
* of plain row objects and a column spec. Values are stringified as-is (callers pre-format money/dates);
* any field containing a comma, double quote, or newline is quoted per RFC 4180.
*/
export function toCsv(rows: Record<string, string | number>[], headers: CsvColumn[]): string {
const lines = [
headers.map((h) => quoteField(h.label)).join(','),
...rows.map((row) => headers.map((h) => quoteField(String(row[h.key] ?? ''))).join(',')),
];
return lines.join('\r\n');
}