frontend phase 15
This commit is contained in:
@@ -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 0–1 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 0–1). 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user