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>
);