frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -0,0 +1,162 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
import { useAdminCapabilities } 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';
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
'evv_no_show',
'evv_location_mismatch',
'verification_expired',
'shared_sim',
'payment_anomaly',
'fraud_signal',
'nurse_clawback',
'emergency',
];
/**
* 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() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
const [type, setType] = useState<SupportAlertType | ''>('');
const [page, setPage] = useState(1);
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 onAssignSelf = (alert: SupportAlert) => {
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
);
};
const onResolveConfirm = (note?: string) => {
if (!resolving) return;
resolve.mutate(
{ alertId: resolving.id, note: note ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('alert_resolved'), { variant: 'success' });
setResolving(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('alert_title')}
subtitle={t('alert_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label={t('alert_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`astatus_${s}`)}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label={t('alert_col_type')}
value={type}
onChange={(e) => {
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
sx={{ minWidth: 180 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`atype_${ty}`)}
</MenuItem>
))}
</TextField>
</Stack>
}
/>
{alerts.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={110} />)}</Stack>
) : alerts.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => alerts.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="alerts" title={t('alert_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((alert) => (
<SupportAlertCard
key={alert.id}
alert={alert}
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
/>
))}
</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 })}
/>
<ConfirmDialog
open={resolving != null}
title={t('alert_resolve_title')}
confirmLabel={t('alert_resolve')}
cancelLabel={t('cancel')}
onConfirm={onResolveConfirm}
onClose={() => setResolving(null)}
loading={resolve.isPending}
requireReason
reasonLabel={t('note_label')}
reasonPlaceholder={t('alert_resolve_note_ph')}
/>
</Box>
);
}
@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin';
const EMPTY: AuditFilters = {};
/**
* 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
* filter draft is committed to the query only on Apply, so typing never refetches; the applied filters +
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
const [page, setPage] = useState(1);
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 apply = () => {
setApplied(draft);
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('audit_title')} subtitle={t('audit_subtitle')} />
<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('audit_col_entity')}
placeholder={t('audit_entity_type_ph')}
value={draft.entityType ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityType: e.target.value || undefined }))}
sx={{ minWidth: 200 }}
/>
<TextField
size="small"
label="ID"
placeholder={t('audit_entity_id_ph')}
value={draft.entityId ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
<TextField
size="small"
type="date"
label={t('audit_from')}
value={draft.from ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
size="small"
type="date"
label={t('audit_to')}
value={draft.to ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
slotProps={{ inputLabel: { shrink: true } }}
/>
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
{t('clear')}
</AppButton>
</Stack>
{audit.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : audit.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => audit.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="audit" title={t('audit_empty')} />
) : (
<Stack sx={{ gap: 1 }}>
{items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
</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 })}
/>
</Box>
);
}
@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Drawer,
FormControlLabel,
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } 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';
const GROUP_ORDER = ['fees', 'deadlines', 'evv', 'bnpl', 'cancellation', 'other'] as const;
type GroupKey = (typeof GROUP_ORDER)[number];
/** Validate a candidate value against a config's `data_type` (+ the 01 rate rule). Returns an i18n key or null. */
function validate(config: PlatformConfig, value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return 'cfg_empty_error';
if (config.dataType === 'int') {
if (!/^-?\d+$/.test(trimmed)) return 'cfg_int_error';
}
if (config.dataType === 'int' || config.dataType === 'decimal') {
const n = Number(trimmed);
if (Number.isNaN(n)) return 'cfg_int_error';
if (RATE_CONFIG_KEYS.includes(config.key) && (n < 0 || n > 1)) return 'cfg_range_error';
}
if (config.dataType === 'json') {
try {
JSON.parse(trimmed);
} catch {
return 'cfg_json_error';
}
}
return null;
}
/**
* Platform config editor (f15) — every `platform_configs` row grouped by concern, each with a typed input
* by `data_type` and boundary validation (a rate is 01). Saving is audited server-side and takes effect
* immediately without re-pricing already-computed rows — the save dialog says so. The change-history drawer
* proves the value in effect at any past moment. The client never re-parses config beyond rendering by
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1);
const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null);
const grouped = useMemo(() => {
const items = configs.data?.items ?? [];
const byKey = new Map(Object.entries(CONFIG_GROUPS).flatMap(([g, keys]) => keys.map((k) => [k, g as GroupKey])));
const result: Record<GroupKey, PlatformConfig[]> = { fees: [], deadlines: [], evv: [], bnpl: [], cancellation: [], other: [] };
for (const c of items) result[byKey.get(c.key) ?? 'other'].push(c);
return result;
}, [configs.data]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('cfg_title')} subtitle={t('cfg_subtitle')} />
{configs.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={96} />)}</Stack>
) : configs.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => configs.refetch()} />
) : (configs.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="config" title={t('cfg_title')} />
) : (
GROUP_ORDER.filter((g) => grouped[g].length > 0).map((g) => (
<Stack key={g} sx={{ gap: 1.5 }}>
<Typography variant="overline" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t(`cfg_group_${g}`)}
</Typography>
{grouped[g].map((config) => (
<ConfigRow
key={config.key}
config={config}
canEdit={caps.canConfig}
onEdit={setEditing}
onHistory={(c) => setHistoryKey(c.key)}
/>
))}
</Stack>
))
)}
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box>
);
}
/** The typed, validated, audited edit dialog for one config row. */
function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const update = useUpdatePlatformConfig();
const [value, setValue] = useState(config.value);
const errorKey = validate(config, value);
const isBool = config.dataType === 'bool';
const onSave = () => {
if (errorKey) return;
update.mutate(
{ key: config.key, value: isBool ? value : value.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('cfg_saved'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={update.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800, fontFamily: 'monospace' }}>{config.key}</DialogTitle>
<DialogContent>
{config.description ? (
<DialogContentText sx={{ mb: 2 }}>{config.description}</DialogContentText>
) : null}
{isBool ? (
<FormControlLabel
control={<Switch checked={value === 'true'} onChange={(e) => setValue(e.target.checked ? 'true' : 'false')} />}
label={t(`dtype_bool`)}
/>
) : (
<TextField
fullWidth
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')}
error={!!errorKey}
helperText={errorKey ? t(errorKey) : undefined}
slotProps={{ input: { sx: config.dataType === 'json' ? { fontFamily: 'monospace' } : undefined } }}
/>
)}
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 600 }}>
{t('cfg_save_confirm_body')}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending} sx={{ m: 0 }}>
{update.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
/** The change-history drawer for one config key. */
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
const history = useConfigChangeHistory(configKey, 1, 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';
return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} 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={{ m: 0, minWidth: 0 }}>
<AppIcon icon="close" />
</AppButton>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : (history.data?.items.length ?? 0) === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cfg_history_empty')}
</Typography>
) : (
<Stack sx={{ gap: 1.5 }}>
{history.data?.items.map((change) => (
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(change.occurredAt, locale)}
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
</Typography>
</Box>
))}
</Stack>
)}
</Drawer>
);
}
@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
MenuItem,
Skeleton,
Stack,
Switch,
TextField,
} from '@mui/material';
import { AppButton } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin';
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/**
* Iranian-holiday calendar manager (f15). Lists `iranian_holidays`, each with its Shamsi date, name, type,
* and an `is_bank_closed` flag — the flag that shifts payout scheduling (the copy surfaces that consequence).
* The client only maintains the calendar the **server** uses for the next-business-day shift; it never
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
{ key: 'name', header: t('hol_col_name'), render: (h) => h.nameFa },
{ key: 'type', header: t('hol_col_type'), render: (h) => <Chip size="small" variant="outlined" label={t(`htype_${h.type}`)} /> },
{
key: 'bank',
header: t('hol_col_bank'),
render: (h) => (
<Chip
size="small"
label={h.isBankClosed ? t('yes') : t('no')}
sx={{
bgcolor: h.isBankClosed ? 'var(--bal-warning)' : 'var(--bal-divider)',
color: h.isBankClosed ? 'var(--bal-warning-contrast)' : 'var(--bal-text-secondary)',
fontWeight: 700,
}}
/>
),
},
...(caps.canConfig
? [
{
key: 'actions',
header: '',
render: (h: Holiday) => (
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)} sx={{ m: 0 }}>
{t('cfg_edit')}
</AppButton>
),
} as AdminTableColumn<Holiday>,
]
: []),
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('hol_title')}
subtitle={t('hol_subtitle')}
actions={
caps.canConfig ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')} sx={{ m: 0 }}>
{t('hol_add')}
</AppButton>
) : undefined
}
/>
{holidays.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : holidays.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => holidays.refetch()} />
) : (holidays.data?.items.length ?? 0) === 0 ? (
<AdminEmptyState icon="calendar" title={t('hol_empty')} />
) : (
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)}
{editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : null}
</Box>
);
}
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,
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
});
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
const onSave = () => {
if (!valid) return;
upsert.mutate(
{ ...form, nameFa: form.nameFa.trim() },
{
onSuccess: () => {
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
type="date"
label={t('hol_col_date')}
value={form.holidayDate}
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
disabled={!!holiday}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label={t('hol_name_fa')}
value={form.nameFa}
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
/>
<TextField
select
label={t('hol_col_type')}
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
>
{HOLIDAY_TYPES.map((ty) => (
<MenuItem key={ty} value={ty}>
{t(`htype_${ty}`)}
</MenuItem>
))}
</TextField>
<FormControlLabel
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
label={t('hol_bank_hint')}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending} sx={{ m: 0 }}>
{upsert.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -1,8 +1,81 @@
import { getTranslations } from 'next-intl/server';
import { PlaceholderScreen } from '@/components';
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Typography } from '@mui/material';
import { AppIcon, AppLink } from '@/components';
import { AdminPageHeader } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
export default async function AdminOverviewPage() {
const t = await getTranslations('nav');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
/**
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
*/
export default function AdminOverviewPage() {
const t = useTranslations('admin');
const tNav = useTranslations('nav');
const locale = useLocale();
const caps = useAdminCapabilities();
// `key` doubles as the `nav` i18n key for the card label.
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
].filter((c) => c.enabled);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
gap: 2,
}}
>
{consoles.map((c) => (
<AppLink
key={c.key}
to={`/${locale}${c.route}`}
color="inherit"
underline="none"
sx={{ display: 'block', height: '100%' }}
>
<Paper
elevation={0}
sx={{
p: 3,
height: '100%',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 1.5,
cursor: 'pointer',
transition: 'border-color 150ms ease, box-shadow 150ms ease',
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
}}
>
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{tNav(c.key)}
</Typography>
</Paper>
</AppLink>
))}
</Box>
</Box>
);
}
@@ -0,0 +1,278 @@
'use client';
import { ReactNode, useState } from 'react';
import { useParams, useRouter } 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 { ROUTES } from '@/constants';
import {
usePartnerCenter,
useCenterSponsoredNurses,
useVerifyPartnerCenter,
useSetPartnerCenterActive,
useAssignNurseToPartnerCenter,
} from '@/services/partnerCenter';
import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
/**
* Partner-center admin detail (f15) — the licensing/settlement record for one center, its lifecycle actions,
* and its sponsored-nurse roster. Admins with `canManagePartners` may verify & activate a center (records
* licensing approval), suspend/reactivate it, edit it, and add/remove sponsored nurses. The settlement IBAN
* is only ever shown masked (last-4); it is never rendered in plaintext (write-then-masked). The server
* enforces every command's scope — the capability flag only hides controls.
*/
export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
const centerId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const center = usePartnerCenter(centerId || null);
const roster = useCenterSponsoredNurses(centerId || null);
const verify = useVerifyPartnerCenter(centerId);
const setActive = useSetPartnerCenterActive(centerId);
const assignNurse = useAssignNurseToPartnerCenter(centerId);
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState('');
const data = center.data;
const onVerifyConfirm = () => {
verify.mutate(undefined, {
onSuccess: () => {
enqueueSnackbar(t('partner_verified_toast'), { variant: 'success' });
setConfirmVerify(false);
},
});
};
const onAssign = () => {
const nurseProfileId = Number(assignId);
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId('');
},
},
);
};
const onRemove = (nurseProfileId: number) => {
assignNurse.mutate(
{ nurseProfileId, unlink: true },
{ onSuccess: () => enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }) },
);
};
const back = (
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
if (center.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<Skeleton variant="rounded" height={80} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Box>
);
}
if (center.isError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => center.refetch()} />
</Box>
);
}
if (!data) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminEmptyState icon="partners" title={t('partner_empty')} />
</Box>
);
}
const commissionPercent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(
data.commissionRate,
);
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>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
<DetailRow label={t('partner_tech_director')}>{data.technicalDirectorLicenseNo ?? '—'}</DetailRow>
<DetailRow label={t('partner_enamad')}>{data.enamadCode ?? '—'}</DetailRow>
<DetailRow label={t('partner_commission')}>{commissionPercent}</DetailRow>
<DetailRow label={t('partner_is_mor')}>{t(data.isMerchantOfRecord ? 'yes' : 'no')}</DetailRow>
<DetailRow label={t('partner_iban')}>
<Box component="span" dir="ltr">
{data.settlementIbanMasked ?? '—'}
</Box>
</DetailRow>
</Stack>
</Paper>
{caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{data.verifiedAt == null ? (
<AppButton
variant="contained"
color="primary"
startIcon="verified"
onClick={() => setConfirmVerify(true)}
disabled={verify.isPending}
sx={{ m: 0 }}
>
{t('partner_verify')}
</AppButton>
) : null}
<AppButton
variant="outlined"
color="primary"
onClick={() => setActive.mutate(!data.isActive)}
disabled={setActive.isPending}
sx={{ m: 0 }}
>
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
</AppButton>
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)} sx={{ m: 0 }}>
{t('partner_edit')}
</AppButton>
</Stack>
) : null}
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('partner_roster_title')}
</Typography>
{roster.isLoading ? (
<Skeleton variant="rounded" height={120} />
) : (
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{(roster.data ?? []).map((nurse) => (
<Stack
key={nurse.nurseProfileId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{nurse.name}
</Typography>
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} />
</Stack>
{caps.canManagePartners ? (
<AppButton
variant="text"
color="error"
startIcon="delete"
onClick={() => onRemove(nurse.nurseProfileId)}
disabled={assignNurse.isPending}
sx={{ m: 0 }}
>
{t('partner_unlink_nurse')}
</AppButton>
) : null}
</Stack>
))}
</Stack>
</Paper>
)}
{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 }}
/>
<AppButton
variant="contained"
color="primary"
startIcon="assign"
onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0}
sx={{ m: 0, mt: 0.25 }}
>
{t('partner_assign_nurse')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={confirmVerify}
title={t('partner_verify')}
body={t('partner_verify_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onVerifyConfirm}
onClose={() => setConfirmVerify(false)}
loading={verify.isPending}
/>
{editing ? <PartnerCenterFormDialog center={data} onClose={() => setEditing(false)} /> : null}
</Box>
);
}
/** One label/value line in the license/settlement block. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1.5, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 600, textAlign: 'end' }}>
{children}
</Typography>
</Stack>
);
}
@@ -0,0 +1,257 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
Skeleton,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
type AdminTableColumn,
} from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified',
pending_verification: 'pending',
suspended: 'rejected',
draft: 'neutral',
};
/**
* Partner-center admin list (f15) — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد
* اعتماد الکترونیکی) that may be the merchant-of-record. Each row shows whether it issues invoices, its
* sponsored-nurse count, and its onboarding state; a row opens the center detail. Admins with
* `canManagePartners` may create a new center (inactive until verified). The full IBAN is write-then-masked —
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
{ key: 'mor', header: t('partner_col_mor'), render: (c) => t(c.isMerchantOfRecord ? 'yes' : 'no') },
{ key: 'nurses', header: t('partner_col_nurses'), render: (c) => c.sponsoredNurseCount },
{
key: 'state',
header: t('partner_col_state'),
render: (c) => <StatusChip status={CENTER_STATE_KIND[c.onboardingState]} label={t(`center_state_${c.onboardingState}`)} />,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('partner_title')}
subtitle={t('partner_subtitle')}
actions={
caps.canManagePartners ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)} sx={{ m: 0 }}>
{t('partner_create')}
</AppButton>
) : undefined
}
/>
{centers.isLoading ? (
<Skeleton variant="rounded" height={220} />
) : centers.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => centers.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="partners" title={t('partner_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
/>
)}
<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 })}
/>
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
</Box>
);
}
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
interface CenterFormState {
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorLicenseNo: string;
enamadCode: string;
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
adminUserId: string;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
return {
name: center?.name ?? '',
legalEntityType: center?.legalEntityType ?? '',
mohEstablishmentPermitNo: center?.mohEstablishmentPermitNo ?? '',
technicalDirectorLicenseNo: center?.technicalDirectorLicenseNo ?? '',
enamadCode: center?.enamadCode ?? '',
// Write-then-masked: always blank on open. On edit, a blank IBAN keeps the existing masked value.
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
};
}
/**
* The create/edit dialog for a partner center — shared by the list (create, `center=null`) and the detail
* (edit, `center` prefilled). `settlementIban` is write-then-masked: the field is always blank on open and a
* blank submit on edit keeps the stored masked value. Validates name + permit non-empty, `commissionRate ∈
* [0, 1)`, and (create only) an IBAN when the center is merchant-of-record.
*/
export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCenter | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = center != null;
const create = useCreatePartnerCenter();
const update = useUpdatePartnerCenter(center?.id ?? 0);
const mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const commission = Number(form.commissionRate);
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
const valid =
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
const onSave = () => {
if (!valid) return;
const input: PartnerCenterInput = {
name: form.name.trim(),
legalEntityType: form.legalEntityType.trim(),
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
enamadCode: form.enamadCode.trim() || null,
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
};
mutation.mutate(input, {
onSuccess: () => {
enqueueSnackbar(t('partner_saved'), { variant: 'success' });
onClose();
},
});
};
return (
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
<TextField
label={t('partner_legal_type')}
value={form.legalEntityType}
onChange={(e) => set('legalEntityType', e.target.value)}
/>
<TextField
label={t('partner_permit')}
value={form.mohEstablishmentPermitNo}
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
/>
<TextField
label={t('partner_tech_director_license')}
value={form.technicalDirectorLicenseNo}
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
/>
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
<TextField
label={t('partner_iban')}
value={form.settlementIban}
onChange={(e) => set('settlementIban', e.target.value)}
helperText={t('partner_iban_write_hint')}
placeholder={center?.settlementIbanMasked ?? undefined}
slotProps={{ htmlInput: { dir: 'ltr' } }}
/>
<Box>
<FormControlLabel
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
label={t('partner_is_mor')}
/>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{t('partner_is_mor_hint')}
</Typography>
</Box>
<TextField
type="number"
label={t('partner_commission')}
value={form.commissionRate}
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
<TextField
type="number"
label={t('partner_admin_user')}
value={form.adminUserId}
onChange={(e) => set('adminUserId', e.target.value)}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending} sx={{ m: 0 }}>
{mutation.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,295 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import { useParams, useRouter } 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 type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } 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';
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
/**
* Admin payout-batch detail (f15) — one batch expanded: its window + holiday-shifted processing date, and its
* paginated per-payout rows (money decomposition, masked IBAN, transfer reference, status). A failed payout
* can be retried (idempotency-keyed) and a reconciled bank transfer reference recorded — both gated on
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
export default function AdminPayoutBatchDetailPage() {
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 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={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={96} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => detail.refetch()} />
) : !data ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<StatusChip
status={BATCH_STATUS_KIND[data.batch.status]}
label={t(`batch_status_${data.batch.status}`)}
sx={{ alignSelf: 'flex-start' }}
/>
<MetaLine label={t('payout_col_period')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
<MetaLine label={t('payout_col_processing')}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(data.batch.processingDate, locale)}</span>
{data.batch.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('payout_rows_title')}
</Typography>
{data.payouts.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
data.payouts.map((row) => (
<PayoutRowCard key={row.id} row={row} batchId={batchId} canPayout={caps.canPayout} />
))
)}
</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 })}
/>
</>
)}
</Box>
);
}
/**
* One `nurse_payouts` row — the money decomposition (`gross clawback = net`), masked IBAN + transfer
* reference, status, and (for a failed payout) the reason + an idempotency-keyed retry. Recording a
* reconciled bank transfer reference is an inline per-row action. Both writes are gated on `canPayout`.
*/
const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; canPayout: boolean }> = ({
row,
batchId,
canPayout,
}) => {
const t = useTranslations('admin');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const retry = useRetryPayout();
const record = useRecordTransferReference();
const [retryOpen, setRetryOpen] = useState(false);
const [reference, setReference] = useState('');
const onRetryConfirm = () => {
retry.mutate(
{ payoutId: row.id, idempotencyKey: crypto?.randomUUID?.() ?? String(Date.now()), batchId },
{
onSuccess: () => {
setRetryOpen(false);
enqueueSnackbar(t('saved'), { variant: 'success' });
},
},
);
};
const onRecord = () => {
record.mutate(
{ payoutId: row.id, reference: reference.trim(), batchId },
{
onSuccess: () => {
enqueueSnackbar(t('payout_ref_saved'), { variant: 'success' });
setReference('');
},
},
);
};
return (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`}
</Typography>
<StatusChip status={PAYOUT_STATUS_KIND[row.status]} label={t(`pstatus_${row.status}`)} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_decomp', {
gross: formatIrrToToman(row.grossEarningsIrr, locale),
clawback: formatIrrToToman(row.clawbackAppliedIrr, locale),
net: formatIrrToToman(row.netAmountIrr, locale),
})}
</Typography>
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
<Field label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{row.maskedIban}
</Box>
</Field>
<Field label={t('payout_row_ref')}>
<Box component="span" dir="ltr">
{row.transferReference ?? '—'}
</Box>
</Field>
</Stack>
{row.status === 'failed' ? (
<Box
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('payout_failure_reason', { reason: row.failureReason ?? '—' })}
</Typography>
{canPayout ? (
<Box sx={{ mt: 1 }}>
<AppButton
variant="outlined"
color="error"
onClick={() => setRetryOpen(true)}
disabled={retry.isPending}
sx={{ m: 0 }}
>
{t('payout_retry')}
</AppButton>
</Box>
) : null}
</Box>
) : null}
{canPayout ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.5 }}>
<TextField
size="small"
label={t('payout_record_ref')}
placeholder={t('payout_record_ref_ph')}
value={reference}
onChange={(e) => setReference(e.target.value)}
slotProps={{ htmlInput: { dir: 'ltr' } }}
sx={{ minWidth: 220 }}
/>
<AppButton
variant="contained"
color="primary"
onClick={onRecord}
disabled={reference.trim().length === 0 || record.isPending}
sx={{ m: 0 }}
>
{record.isPending ? t('saving') : t('save')}
</AppButton>
</Stack>
) : null}
</Stack>
<ConfirmDialog
open={retryOpen}
title={t('payout_retry')}
body={t('payout_retry_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onRetryConfirm}
onClose={() => setRetryOpen(false)}
loading={retry.isPending}
confirmColor="error"
/>
</Paper>
);
};
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Box sx={{ fontWeight: 600, typography: 'body2' }}>{children}</Box>
</Stack>
);
const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack sx={{ gap: 0.25 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{children}
</Typography>
</Stack>
);
@@ -0,0 +1,377 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
MenuItem,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
ConfirmDialog,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
import { formatIrrToToman, 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';
/** Batch lifecycle → semantic chip color (server truth; the client only renders it). */
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'draft',
'processing',
'partially_failed',
'completed',
'failed',
];
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
/**
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
* entry point to previewing + running the next batch. Money is IRR digit-strings rendered as display-only
* Toman; the server owns eligibility and the holiday-shifted processing date — the client never computes
* them. Running a batch moves money, so it is gated (`canPayout`), idempotency-keyed, and confirmed.
*/
export default function AdminPayoutsPage() {
const t = useTranslations('admin');
const tCommon = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const [status, setStatus] = useState<PayoutBatchStatus | ''>('');
const [page, setPage] = useState(1);
const [previewOpen, setPreviewOpen] = useState(false);
const filters = { status: status || undefined };
const batches = usePayoutBatches(filters, page);
const items = batches.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((batches.data?.total ?? 0) / PAYOUTS_PAGE_SIZE));
const columns: AdminTableColumn<PayoutBatchSummary>[] = [
{
key: 'period',
header: t('payout_col_period'),
render: (b) => `${formatShamsiDate(b.periodStart, locale)} ${formatShamsiDate(b.periodEnd, locale)}`,
},
{
key: 'count',
header: t('payout_col_count'),
render: (b) => b.payoutCount,
},
{
key: 'total',
header: t('payout_col_total'),
render: (b) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
},
{
key: 'status',
header: t('payout_col_status'),
render: (b) => <StatusChip status={BATCH_STATUS_KIND[b.status]} label={t(`batch_status_${b.status}`)} />,
},
{
key: 'processing',
header: t('payout_col_processing'),
render: (b) => (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<span>{formatShamsiDate(b.processingDate, locale)}</span>
{b.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('payout_title')}
subtitle={t('payout_subtitle')}
actions={
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
select
size="small"
label={t('payout_col_status')}
value={status}
onChange={(e) => {
setStatus(e.target.value as PayoutBatchStatus | '');
setPage(1);
}}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{BATCH_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`batch_status_${s}`)}
</MenuItem>
))}
</TextField>
{caps.canPayout ? (
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)} sx={{ m: 0 }}>
{t('payout_preview')}
</AppButton>
) : null}
</Stack>
}
/>
{batches.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
) : batches.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => batches.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(b) => b.id}
onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)}
ariaLabel={t('payout_title')}
/>
)}
<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 })}
/>
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
</Box>
);
}
/**
* The eligibility dry-run + run-batch dialog. Preview is a mutation (runs only when the admin asks), and its
* eligible/skipped breakdown + the server's holiday-shifted processing date are read straight from the
* mutation's `data`. Running is idempotency-keyed and confirmed; on success it deep-links to the new batch.
*/
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
const t = useTranslations('admin');
const tCommon = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const { enqueueSnackbar } = useSnackbar();
// Default the window to the last 7 days (end = today). A lazy initializer runs the `Date` read once.
const [periodStart, setPeriodStart] = useState(() =>
isoDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),
);
const [periodEnd, setPeriodEnd] = useState(() => isoDate(new Date()));
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
const preview = usePreviewPayoutBatch();
const run = useRunPayoutBatch();
const result = preview.data;
const onPreview = () => {
if (!periodStart || !periodEnd) return;
preview.mutate({ periodStart, periodEnd });
};
const onRunConfirm = () => {
const idempotencyKey = crypto?.randomUUID
? crypto.randomUUID()
: `batch_${periodStart}_${periodEnd}_${Date.now()}`;
run.mutate(
{ periodStart, periodEnd, idempotencyKey },
{
onSuccess: (batch) => {
setRunConfirmOpen(false);
onClose();
enqueueSnackbar(t('payout_ran'), { variant: 'success' });
router.push(`/${locale}${adminPayoutBatchPath(batch.id)}`);
},
},
);
};
return (
<Dialog open onClose={run.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<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"
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 } }}
/>
<AppButton
variant="outlined"
color="primary"
onClick={onPreview}
disabled={!periodStart || !periodEnd || preview.isPending}
sx={{ m: 0 }}
>
{t('payout_preview')}
</AppButton>
</Stack>
{result ? (
<Stack sx={{ gap: 2, mt: 2.5 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
{result.holidayShifted ? (
<Chip
size="small"
label={t('payout_holiday_shift')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_eligible_nurses')}
</Typography>
{result.eligible.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
</Typography>
) : (
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
{result.eligible.map((n) => (
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
{!n.hasVerifiedPrimaryIban ? (
<Chip
size="small"
label={t('payout_no_iban')}
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
</Typography>
</Stack>
))}
</Stack>
)}
</Stack>
{result.skipped.length > 0 ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('payout_skipped')}
</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
{result.skipped.map((n) => (
<Stack
key={n.nurseId}
direction="row"
sx={{ p: 1.5, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{n.nurseName ?? `#${n.nurseId}`}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{n.reason}
</Typography>
</Stack>
))}
</Stack>
</Stack>
) : null}
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('payout_eligibility_note')}
</Typography>
</Stack>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
{canPayout ? (
<AppButton
variant="contained"
color="primary"
onClick={() => setRunConfirmOpen(true)}
disabled={!result || run.isPending}
sx={{ m: 0 }}
>
{t('payout_run')}
</AppButton>
) : null}
</DialogActions>
<ConfirmDialog
open={runConfirmOpen}
title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')}
confirmLabel={t('payout_run')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
/>
</Dialog>
);
}
@@ -0,0 +1,198 @@
'use client';
import { 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 { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } 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';
/** The moderation worklist tabs — the four `moderationStatus` values; the queue defaults to the pending backlog. */
const MODERATION_STATUSES: readonly ModerationStatus[] = ['pending_moderation', 'published', 'hidden', 'rejected'];
/** Status → MUI chip color. **Never** styles `pending_moderation` like a published review (it reads as a warning). */
const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warning' | 'error'> = {
pending_moderation: 'warning',
published: 'success',
hidden: 'default',
rejected: 'error',
};
/**
* Review moderation queue (f15) — the admin worklist over `reviews` awaiting a decision (b14). Each row carries
* moderation internals (`lowRatingAlertId`, the nurse/booking context) that are **never** rendered on a
* customer/nurse surface. A review is born `pending_moderation` and is never public / never counted until an
* admin publishes it, so the card presents pending content as an under-review item, not as a published review.
* 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.
*/
export default function AdminReviewsPage() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
const [page, setPage] = useState(1);
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page);
const moderate = useModerateReview();
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => {
if (!pending) return;
moderate.mutate(
{ reviewId: pending.item.id, action: pending.action, reason },
{
onSuccess: () => {
enqueueSnackbar(t('mod_done'), { variant: 'success' });
setPending(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('mod_title')}
subtitle={t('mod_subtitle')}
actions={
<TextField
select
size="small"
label={t('filter_label')}
value={status}
onChange={(e) => {
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`mstatus_${s}`)}
</MenuItem>
))}
</TextField>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={150} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="moderation" title={t('mod_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<ModerationCard
key={item.id}
item={item}
canModerate={caps.canModerate}
onAct={(action) => setPending({ item, action })}
/>
))}
</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 })}
/>
<ConfirmDialog
open={pending != null}
title={pending ? t(`mod_${pending.action}`) : ''}
body={pending ? t(`mod_confirm_${pending.action}`) : undefined}
confirmLabel={pending ? t(`mod_${pending.action}`) : t('confirm')}
cancelLabel={t('cancel')}
confirmColor={pending?.action === 'reject' ? 'error' : 'primary'}
loading={moderate.isPending}
requireReason={requireReason}
reasonLabel={t('reason_label')}
onConfirm={onConfirm}
onClose={() => setPending(null)}
/>
</Box>
);
}
/** One review awaiting a decision. Presentational; the actions bubble up to the page-level confirm dialog. */
function ModerationCard({
item,
canModerate,
onAct,
}: {
item: ModerationQueueItem;
canModerate: boolean;
onAct: (action: ModerationAction) => void;
}) {
const t = useTranslations('admin');
const tReviews = useTranslations('reviews');
return (
<Paper
elevation={0}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
<Chip size="small" color={STATUS_CHIP_COLOR[item.moderationStatus]} label={t(`mstatus_${item.moderationStatus}`)} />
{item.lowRatingAlertId != null ? (
<Chip size="small" color="warning" variant="outlined" label={t('mod_low_rating')} />
) : null}
</Stack>
{item.body ? (
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{item.body}
</Typography>
) : null}
{item.tagCodes.length > 0 ? (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{item.tagCodes.map((code) => (
<Chip key={code} size="small" variant="outlined" label={tReviews(`tag_${code}`)} />
))}
</Stack>
) : null}
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_nurse', { id: item.nurseProfileId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('mod_booking', { id: item.bookingId })}
</Typography>
</Stack>
{canModerate ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')} sx={{ m: 0 }}>
{t('mod_publish')}
</AppButton>
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')} sx={{ m: 0 }}>
{t('mod_hide')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')} sx={{ m: 0 }}>
{t('mod_reject')}
</AppButton>
</Stack>
) : null}
</Paper>
);
}
@@ -0,0 +1,205 @@
'use client';
/**
* RBAC roles grid (f15) — **DEFERRED-IF-MISSING.** The b15 contract does not yet expose role grant/revoke
* endpoints, so this console is served by the admin **mock** (REQ-031); an info banner says so. When the
* endpoints land, only `services/admin/apis` flips — this screen is unchanged.
*
* Grants/revokes are gated on `canManageRoles` (only a `super_admin`); the server remains the authority. The
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant.
*/
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuItem,
Skeleton,
Stack,
TextField,
} from '@mui/material';
import { AppAlert, AppButton } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
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';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
export default function AdminRolesPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const roles = useAdminRoles();
const revoke = useRevokeRole();
const [granting, setGranting] = useState(false);
const [revoking, setRevoking] = useState<RoleGrant | null>(null);
// Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
const columns: AdminTableColumn<RoleGrant>[] = [
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
{
key: 'role',
header: t('role_col_role'),
render: (g) => <Chip size="small" variant="outlined" label={t(`role_${g.role}`)} />,
},
{ key: 'granted', header: t('role_col_granted'), render: (g) => formatShamsiDate(g.grantedAt, locale) },
...(caps.canManageRoles
? [
{
key: 'actions',
header: '',
align: 'right',
render: (g: RoleGrant) => (
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)} sx={{ m: 0 }}>
{t('role_revoke')}
</AppButton>
),
} as AdminTableColumn<RoleGrant>,
]
: []),
];
const onRevokeConfirm = () => {
if (!revoking) return;
revoke.mutate(
{ userId: revoking.userId, role: revoking.role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
setRevoking(null);
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('role_title')}
subtitle={t('role_subtitle')}
actions={
caps.canManageRoles ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)} sx={{ m: 0 }}>
{t('role_grant')}
</AppButton>
) : undefined
}
/>
<AppAlert severity="info" variant="outlined">
{t('role_deferred')}
</AppAlert>
{roles.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : roles.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => roles.refetch()} />
) : active.length === 0 ? (
<AdminEmptyState icon="roles" title={t('role_title')} />
) : (
<AdminDataTable
columns={columns}
rows={active}
getRowKey={(g) => `${g.userId}:${g.role}`}
ariaLabel={t('role_title')}
/>
)}
{granting ? <GrantRoleDialog onClose={() => setGranting(false)} /> : null}
<ConfirmDialog
open={revoking != null}
title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')}
confirmColor="error"
loading={revoke.isPending}
onConfirm={onRevokeConfirm}
onClose={() => setRevoking(null)}
/>
</Box>
);
}
/** Collect a target user id + a role, then grant it (inline confirm copy once both are set). */
function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
const [userId, setUserId] = useState('');
const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId);
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const onGrant = () => {
if (!valid) return;
grant.mutate(
{ userId: parsedId, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={grant.isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
label={t('role_col_user')}
type="number"
value={userId}
onChange={(e) => setUserId(e.target.value)}
slotProps={{ htmlInput: { min: 1 } }}
/>
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
<MenuItem key={r} value={r}>
{t(`role_${r}`)}
</MenuItem>
))}
</TextField>
{valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
) : null}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending} sx={{ m: 0 }}>
{grant.isPending ? t('saving') : t('confirm')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,198 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Collapse,
Paper,
Skeleton,
Stack,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { ROUTES } from '@/constants';
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
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). */
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
function authorLabelKey(role: TicketAuthorRole): string {
return `author_${role === 'admin' ? 'support' : role}`;
}
/** A client id so the optimistic bubble reconciles to the server message by identity (never double-rendered). */
function makeClientMessageId(): string {
return crypto?.randomUUID?.() ?? String(Date.now());
}
/**
* 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.
*/
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 params = useParams<{ id: string }>();
const parsed = Number(params?.id);
const ticketId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false);
const isInternal = mode === 'internal';
const send = () => {
const trimmed = body.trim();
if (!trimmed || post.isPending) return;
post.mutate(
{ body: trimmed, isInternal, clientMessageId: makeClientMessageId() },
{
onSuccess: () => {
setBody('');
enqueueSnackbar(t('ticket_sent'), { variant: 'success' });
},
},
);
};
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
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={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%' }} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%', alignSelf: 'flex-end' }} />
</Stack>
) : isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : !detail ? (
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<>
<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>
{showRefund ? (
<Box>
<AppButton
variant="outlined"
color="primary"
startIcon="refunds"
onClick={() => setRefundShown((v) => !v)}
sx={{ m: 0 }}
>
{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>
</Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
{detail.messages.map((m) => (
<AdminMessageBubble
key={m.clientMessageId ?? String(m.id)}
message={m}
authorLabel={tickets(authorLabelKey(m.authorRole))}
/>
))}
</Box>
{caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup
size="small"
exclusive
value={mode}
onChange={(_e, next: 'reply' | 'internal' | null) => next && setMode(next)}
>
<ToggleButton value="reply">{t('ticket_public_reply')}</ToggleButton>
<ToggleButton value="internal">{t('ticket_internal_note')}</ToggleButton>
</ToggleButtonGroup>
<TextField
multiline
minRows={2}
maxRows={6}
size="small"
placeholder={isInternal ? t('ticket_composer_internal_ph') : t('ticket_composer_public_ph')}
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<AppButton
variant="contained"
color="primary"
startIcon="send"
onClick={send}
disabled={post.isPending || body.trim().length === 0}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('ticket_send')}
</AppButton>
</Stack>
</Paper>
) : null}
</>
)}
</Box>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useState } 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 type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
type AdminTableColumn,
} from '@/components/admin';
import { adminTicketThreadPath } from '@/constants';
import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
/** Queue status → chip color: an open ticket is pending work, a closed one is neutral (phase §5). */
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {};
/**
* 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.
*/
export default function AdminTicketsPage() {
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 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 columns: AdminTableColumn<AdminTicketSummary>[] = [
{
key: 'ref',
header: t('ticket_col_ref'),
render: (row) => (
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
{row.referenceCode}
</Box>
),
},
{ key: 'subject', header: t('ticket_col_subject'), render: (row) => row.subject ?? '—' },
{
key: 'category',
header: t('ticket_col_category'),
render: (row) => <Chip size="small" label={t(`tcat_${row.category}`)} />,
},
{
key: 'status',
header: t('ticket_col_status'),
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
},
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('ticket_title')} subtitle={t('ticket_subtitle')} />
<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('ticket_col_status')}
value={draft.status ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, status: (e.target.value || undefined) as TicketStatus | undefined }))}
sx={{ minWidth: 140 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{t(`tstatus_${s}`)}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
label={t('ticket_col_category')}
value={draft.category ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, category: (e.target.value || undefined) as TicketCategory | undefined }))}
sx={{ minWidth: 160 }}
>
<MenuItem value="">{t('filter_all')}</MenuItem>
{CATEGORIES.map((c) => (
<MenuItem key={c} value={c}>
{t(`tcat_${c}`)}
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={t('ticket_col_ref')}
placeholder={t('ticket_search_ref_ph')}
value={draft.referenceCode ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
sx={{ minWidth: 220 }}
/>
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
{t('clear')}
</AppButton>
</Stack>
{tickets.isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1, 2, 3].map((k) => (
<Skeleton key={k} variant="rounded" height={48} />
))}
</Stack>
) : tickets.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => tickets.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="support" title={t('ticket_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
/>
)}
<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 })}
/>
</Box>
);
}
@@ -0,0 +1,452 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Skeleton,
Stack,
TextField,
Typography,
} from '@mui/material';
import { AppButton, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
/** 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'];
/** Per-step status → chip kind. `expired`/`failed` read red; `in_review`/`pending` amber; `passed` green. */
const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'pending',
passed: 'verified',
failed: 'rejected',
expired: 'rejected',
};
/**
* 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
* short-lived URL on demand), and the manual-step decisions. A credential-bearing step records the
* (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.
*/
export default function AdminVerificationCasePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
);
const approve = useApproveVerification();
const reject = useRejectVerification();
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
const onApprove = () => {
approve.mutate(nurseVerificationId, {
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setApproveOpen(false);
backToQueue();
},
});
};
const onReject = (reason?: string) => {
reject.mutate(
{ nurseVerificationId, reason: reason ?? '' },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
backToQueue();
},
},
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}>
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back')}
</AppButton>
<AdminPageHeader title={t('ver_case_title')} />
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={220} />
<Skeleton variant="rounded" height={160} />
</Stack>
) : isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
) : !data ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('ver_identity_name')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{data.identityName}
</Typography>
</Box>
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_steps_title')}</Typography>
{data.steps.map((step) => (
<StepCard
key={step.id}
step={step}
nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify}
/>
))}
</Stack>
{data.credentials.length > 0 ? (
<Stack sx={{ gap: 1.5 }}>
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
{data.credentials.map((cred) => (
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(`step_${cred.credentialType}`)}
</Typography>
{cred.expiresAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_expires_at')}: {formatShamsiDate(cred.expiresAt, locale)}
</Typography>
) : null}
</Stack>
<Typography variant="body2">{cred.holderNameSnapshot}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{cred.issuingAuthority}
</Typography>
</Box>
))}
</Stack>
) : null}
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => setApproveOpen(true)}
disabled={!allPassed || !caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_approve')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={!caps.canVerify}
sx={{ m: 0 }}
>
{t('ver_reject_all')}
</AppButton>
</Stack>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('ver_approve_hint')}
</Typography>
</Stack>
</>
)}
<ConfirmDialog
open={approveOpen}
title={t('ver_approve')}
body={t('ver_approve_confirm')}
confirmLabel={t('confirm')}
cancelLabel={t('cancel')}
onConfirm={onApprove}
onClose={() => setApproveOpen(false)}
loading={approve.isPending}
/>
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_all')}
body={t('ver_reject_confirm')}
confirmLabel={t('ver_reject_all')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={reject.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
</Box>
);
}
/** One step of the case: label + status chip (+ automated badge), its documents, and — for a decidable
* manual step — Pass / Reject. A credential-bearing Pass opens the structured credential form. */
function StepCard({
step,
nurseVerificationId,
canVerify,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
canVerify: boolean;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [rejectOpen, setRejectOpen] = useState(false);
const [credentialOpen, setCredentialOpen] = useState(false);
const isManual = !step.isAutomated;
const isCredentialStep = CREDENTIAL_STEP_CODES.includes(step.code);
const isDecidable = isManual && (step.status === 'in_review' || step.status === 'pending');
const onPass = () => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: true } },
{ onSuccess: () => enqueueSnackbar(t('ver_decided'), { variant: 'success' }) },
);
};
const onReject = (reason?: string) => {
decide.mutate(
{ stepId: step.id, nurseVerificationId, input: { approve: false, rejectionReason: reason ?? '' } },
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
setRejectOpen(false);
},
},
);
};
return (
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{t(`step_${step.code}`)}
</Typography>
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
{step.isAutomated ? (
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 600 }} />
) : null}
</Stack>
{step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} />
))}
</Stack>
) : isManual ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('ver_no_documents')}
</Typography>
) : null}
{step.failureReason ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)', mt: 1, display: 'block' }}>
{step.failureReason}
</Typography>
) : null}
{isDecidable && canVerify ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_pass')}
</AppButton>
<AppButton
variant="outlined"
color="error"
onClick={() => setRejectOpen(true)}
disabled={decide.isPending}
sx={{ m: 0 }}
>
{t('ver_reject')}
</AppButton>
</Stack>
) : null}
<ConfirmDialog
open={rejectOpen}
title={t('ver_reject_step')}
confirmLabel={t('ver_reject')}
cancelLabel={t('cancel')}
onConfirm={onReject}
onClose={() => setRejectOpen(false)}
loading={decide.isPending}
requireReason
reasonLabel={t('reason_label')}
reasonPlaceholder={t('ver_reject_reason_ph')}
confirmColor="error"
/>
{isCredentialStep && credentialOpen ? (
<CredentialDialog step={step} nurseVerificationId={nurseVerificationId} onClose={() => setCredentialOpen(false)} />
) : null}
</Box>
);
}
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
function CredentialDialog({
step,
nurseVerificationId,
onClose,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const decide = useDecideStep();
const [credentialNumber, setCredentialNumber] = useState('');
const [holderName, setHolderName] = useState('');
const [issuingAuthority, setIssuingAuthority] = useState('');
const [issuedAt, setIssuedAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const expiryRequired = step.code === 'criminal_record';
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
const onSubmit = () => {
if (expiryMissing) return;
decide.mutate(
{
stepId: step.id,
nurseVerificationId,
input: {
approve: true,
credentialNumber: credentialNumber.trim() || undefined,
holderName: holderName.trim() || undefined,
issuingAuthority: issuingAuthority.trim() || undefined,
issuedAt: issuedAt || undefined,
expiresAt: expiresAt || undefined,
},
},
{
onSuccess: () => {
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
onClose();
},
},
);
};
return (
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}>
<TextField
fullWidth
autoFocus
label={t('ver_credential_number')}
value={credentialNumber}
onChange={(e) => setCredentialNumber(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_holder_name')}
helperText={t('ver_holder_hint')}
value={holderName}
onChange={(e) => setHolderName(e.target.value)}
/>
<TextField
fullWidth
label={t('ver_issuing_authority')}
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
<TextField
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)}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending} sx={{ m: 0 }}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={onSubmit}
disabled={decide.isPending || expiryMissing}
sx={{ m: 0 }}
>
{decide.isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,147 @@
'use client';
import { useState } 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 type { StatusKind } from '@/components';
import {
AdminDataTable,
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
type QueueStatusFilter = '' | 'pending' | 'in_review';
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
not_started: 'neutral',
pending: 'pending',
in_review: 'info',
approved: 'verified',
rejected: 'rejected',
suspended: 'rejected',
};
/**
* 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.
*/
export default function AdminVerificationQueuePage() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>('');
const [page, setPage] = useState(1);
const queue = useVerificationQueue({ status: status || undefined }, page);
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{
key: 'nurse',
header: t('ver_col_nurse'),
render: (item) => (
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
<Box sx={{ fontWeight: 700 }}>{item.nurseName}</Box>
{item.hasExpiringCredential ? (
<Chip
size="small"
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
label={t('ver_expiring_warning')}
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
/>
) : null}
</Stack>
),
},
{
key: 'status',
header: t('ver_col_status'),
render: (item) => <StatusChip status={AGG_STATUS_KIND[item.status]} label={t(`agg_${item.status}`)} />,
},
{
key: 'step',
header: t('ver_col_step'),
render: (item) => (
<Stack sx={{ gap: 0.25 }}>
<Box>{t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })}</Box>
<Box sx={{ color: 'text.secondary', fontSize: 13 }}>
{t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })}
</Box>
</Stack>
),
},
{
key: 'submitted',
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
];
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>
}
/>
{queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
) : queue.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="verified" title={t('ver_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
/>
)}
<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 })}
/>
</Box>
);
}
@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
import type { SponsoredBooking } 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.
*/
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
/**
* 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.
*/
export default function PartnerBookingsPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const filters = { status: status || undefined };
const bookings = useMySponsoredBookings(filters, page);
const items = bookings.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
const columns: AdminTableColumn<SponsoredBooking>[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
{ key: 'patient', header: t('bookings_col_patient'), render: (b) => b.patientName },
{ key: 'date', header: t('bookings_col_date'), render: (b) => formatShamsiDate(b.scheduledDate, locale) },
{
key: 'status',
header: t('bookings_col_status'),
render: (b) => <Chip size="small" variant="outlined" label={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>
}
/>
{bookings.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1, 2].map((k) => (
<Skeleton key={k} variant="rounded" height={56} />
))}
</Stack>
) : bookings.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => bookings.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="bookings" title={t('bookings_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
/>
</Box>
);
}
@@ -0,0 +1,12 @@
'use client';
import type { ReactNode } from 'react';
import { PartnerLayout } from '@/layout';
/*
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state).
*/
export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
return <PartnerLayout>{children}</PartnerLayout>;
}
@@ -0,0 +1,63 @@
'use client';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack } from '@mui/material';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { StatusChip } from '@/components';
import type { SponsoredNurse } from '@/services/partnerCenter/types';
import { useMySponsoredNurses } from '@/services/partnerCenter';
/**
* Partner portal — sponsored-nurses roster (f15). The read-only list of nurses the signed-in center
* sponsors (portal scope; server-enforced tenancy). Two columns: name + a verification `StatusChip`. Not
* paginated (the portal roster is a bounded set), so no pager.
*/
export default function PartnerNursesPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const nurses = useMySponsoredNurses();
const items = nurses.data ?? [];
const columns: AdminTableColumn<SponsoredNurse>[] = [
{
key: 'name',
header: t('nurses_col_name'),
render: (n) => n.name,
},
{
key: 'verified',
header: t('nurses_col_verified'),
render: (n) => (
<StatusChip
status={n.isVerified ? 'verified' : 'neutral'}
label={n.isVerified ? ta('center_state_verified') : ta('center_state_pending_verification')}
/>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('nurses_title')} />
{nurses.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1, 2].map((k) => (
<Skeleton key={k} variant="rounded" height={56} />
))}
</Stack>
) : nurses.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => nurses.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="patients" title={t('nurses_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(n) => n.nurseProfileId}
ariaLabel={t('nurses_title')}
/>
)}
</Box>
);
}
@@ -0,0 +1,130 @@
'use client';
import { useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
import { StatusChip } from '@/components';
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
import { useMyPartnerCenter } from '@/services/partnerCenter';
/**
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
*/
export default function PartnerHomePage() {
const t = useTranslations('partner');
const center = useMyPartnerCenter();
if (center.isLoading) {
return (
<Stack sx={{ gap: 3 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={220} />
</Stack>
);
}
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
if (center.isError || !center.data) {
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
}
const c = center.data;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('home_title')}
subtitle={t('home_subtitle')}
actions={
<StatusChip
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
/>
}
/>
<OnboardingBanner state={c.onboardingState} />
<LicenseBlock center={c} />
</Box>
);
}
/**
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
*/
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
const t = useTranslations('partner');
const ta = useTranslations('admin');
if (state === 'verified') {
return (
<Box>
<StatusChip status="verified" label={ta('center_state_verified')} />
</Box>
);
}
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
draft: { severity: 'warning', key: 'state_banner_draft' },
pending_verification: { severity: 'info', key: 'state_banner_pending' },
suspended: { severity: 'warning', key: 'state_banner_suspended' },
};
const { severity, key } = banner[state];
return (
<Alert severity={severity} sx={{ borderRadius: 2 }}>
{t(key)}
</Alert>
);
}
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
function LicenseBlock({ center }: { center: PartnerCenter }) {
const t = useTranslations('partner');
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
{t('license_title')}
</Typography>
<Stack sx={{ gap: 1.5 }}>
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
<DetailRow label={t('enamad')} value={center.enamadCode} />
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
{center.isMerchantOfRecord ? (
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
) : null}
</Stack>
</Paper>
);
}
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
return (
<Stack
direction="row"
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
{ltr ? (
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{value ?? '—'}
</Typography>
) : (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{value ?? '—'}
</Typography>
)}
</Stack>
);
}
@@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AdminEmptyState,
AdminErrorState,
AdminPageHeader,
AdminPager,
PartnerSettlementRow,
} from '@/components/admin';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
/**
* 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.
*/
export default function PartnerSettlementPage() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const center = useMyPartnerCenter();
const [page, setPage] = useState(1);
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page);
if (center.isLoading) {
return (
<Stack sx={{ gap: 3 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (center.isError || !center.data) {
return <AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => center.refetch()} />;
}
const c = center.data;
if (!c.isMerchantOfRecord) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<Alert severity="info" sx={{ borderRadius: 2 }}>
{t('settlement_not_mor')}
</Alert>
</Box>
);
}
const invoices = settlement.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((settlement.data?.total ?? 0) / PARTNER_PAGE_SIZE));
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} />
<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' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_iban')}
</Typography>
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
{c.settlementIbanMasked ?? '—'}
</Typography>
</Stack>
</Paper>
{settlement.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={200} />
))}
</Stack>
) : settlement.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => settlement.refetch()} />
) : invoices.length === 0 ? (
<AdminEmptyState icon="payment" title={t('settlement_empty')} />
) : (
<Stack sx={{ gap: 2 }}>
{invoices.map((inv) => (
<PartnerSettlementRow
key={inv.id}
invoice={inv}
onDownloadPdf={(i) => {
if (i.pdfUrl) window.open(i.pdfUrl, '_blank', 'noopener');
}}
/>
))}
</Stack>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })}
/>
</Box>
);
}