ui phase 11
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppLoading } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { useAuth } from '@/context/auth';
|
||||
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
|
||||
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
|
||||
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
|
||||
|
||||
interface AlertFilters {
|
||||
status?: SupportAlertStatus;
|
||||
type?: SupportAlertType;
|
||||
}
|
||||
|
||||
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
|
||||
const TYPES: readonly SupportAlertType[] = [
|
||||
'low_rating',
|
||||
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
|
||||
'emergency',
|
||||
];
|
||||
|
||||
const EMPTY: AlertFilters = { status: 'open' };
|
||||
|
||||
function parseFilters(params: URLSearchParams): AlertFilters {
|
||||
const status = params.get('status') as SupportAlertStatus | null;
|
||||
const type = params.get('type') as SupportAlertType | null;
|
||||
return {
|
||||
status: status && STATUSES.includes(status) ? status : undefined,
|
||||
type: type && TYPES.includes(type) ? type : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFilters(filters: AlertFilters): Record<string, string> {
|
||||
const r: Record<string, string> = {};
|
||||
if (filters.status) r.status = filters.status;
|
||||
if (filters.type) r.type = filters.type;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support-alert triage board (f15) — the **internal-only** worklist over `support_alerts`. Filter by
|
||||
* type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner
|
||||
* surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
|
||||
*/
|
||||
export default function AdminAlertsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminAlertsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAlertsPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [authState] = useAuth();
|
||||
const meId = authState.currentUser?.id ?? 1;
|
||||
const meId = authState.currentUser?.id;
|
||||
|
||||
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
|
||||
const [type, setType] = useState<SupportAlertType | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
|
||||
const { applied: filters, page } = listState;
|
||||
const [resolving, setResolving] = useState<SupportAlert | null>(null);
|
||||
|
||||
const filters = { status: status || undefined, type: type || undefined };
|
||||
const alerts = useSupportAlerts(filters, page);
|
||||
const assign = useAssignSupportAlert();
|
||||
const resolve = useResolveSupportAlert();
|
||||
|
||||
const items = alerts.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE));
|
||||
const total = alerts.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
|
||||
|
||||
// Assign-to-self MUST NEVER default to a guessed user (the fixed defect: `?? 1`) — the button is simply
|
||||
// disabled with a "loading your account" tooltip until the real id has hydrated.
|
||||
const onAssignSelf = (alert: SupportAlert) => {
|
||||
if (meId == null) return;
|
||||
assign.mutate(
|
||||
{ alertId: alert.id, ownerUserId: meId },
|
||||
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
|
||||
@@ -68,6 +102,11 @@ export default function AdminAlertsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const setStatusFilter = (value: SupportAlertStatus | '') =>
|
||||
listState.applyFilters({ ...filters, status: value || undefined });
|
||||
const setTypeFilter = (value: SupportAlertType | '') =>
|
||||
listState.applyFilters({ ...filters, type: value || undefined });
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
|
||||
select
|
||||
size="small"
|
||||
label={t('alert_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as SupportAlertStatus | '');
|
||||
setPage(1);
|
||||
}}
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
|
||||
select
|
||||
size="small"
|
||||
label={t('alert_col_type')}
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as SupportAlertType | '');
|
||||
setPage(1);
|
||||
}}
|
||||
value={filters.type ?? ''}
|
||||
onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
|
||||
canAct={caps.canManageAlerts}
|
||||
onAssignSelf={onAssignSelf}
|
||||
onResolve={setResolving}
|
||||
assignSelfDisabled={meId == null}
|
||||
assignSelfDisabledTitle={t('assign_me_loading')}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppButton } from '@/components';
|
||||
import { AppButton, AppLoading, JalaliDateField } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
|
||||
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
|
||||
import type { AuditFilters } from '@/services/admin/types';
|
||||
import { useAuditLogs } from '@/services/admin';
|
||||
import { useAuditLogs, useUserLookup } from '@/services/admin';
|
||||
|
||||
const EMPTY: AuditFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): AuditFilters {
|
||||
return {
|
||||
entityType: params.get('entityType') ?? undefined,
|
||||
entityId: params.get('entityId') ?? undefined,
|
||||
from: params.get('from') ?? undefined,
|
||||
to: params.get('to') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFilters(filters: AuditFilters): Record<string, string> {
|
||||
const r: Record<string, string> = {};
|
||||
if (filters.entityType) r.entityType = filters.entityType;
|
||||
if (filters.entityId) r.entityId = filters.entityId;
|
||||
if (filters.from) r.from = filters.from;
|
||||
if (filters.to) r.to = filters.to;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only audit-log viewer (f15) — a read-only, filtered, paginated table of every admin state change,
|
||||
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
|
||||
@@ -17,24 +37,33 @@ const EMPTY: AuditFilters = {};
|
||||
* page are the cache key, so switching filters/pages never refetches data already held.
|
||||
*/
|
||||
export default function AdminAuditPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminAuditPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuditPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
|
||||
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
|
||||
const { draft, setDraft, applied, page } = listState;
|
||||
|
||||
const audit = useAuditLogs(applied, page);
|
||||
const items = audit.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((audit.data?.total ?? 0) / AUDIT_PAGE_SIZE));
|
||||
const total = audit.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * AUDIT_PAGE_SIZE + 1;
|
||||
const to = (page - 1) * AUDIT_PAGE_SIZE + items.length;
|
||||
|
||||
const apply = () => {
|
||||
setApplied(draft);
|
||||
setPage(1);
|
||||
};
|
||||
const clear = () => {
|
||||
setDraft(EMPTY);
|
||||
setApplied(EMPTY);
|
||||
setPage(1);
|
||||
};
|
||||
// Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
|
||||
const actorIds = useMemo(
|
||||
() => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
|
||||
(a, b) => a - b,
|
||||
),
|
||||
[audit.data?.items],
|
||||
);
|
||||
const userLookup = useUserLookup(actorIds);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
|
||||
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('audit_from')}
|
||||
value={draft.from ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={draft.from ?? null}
|
||||
onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
|
||||
max={draft.to}
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('audit_to')}
|
||||
value={draft.to ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
value={draft.to ?? null}
|
||||
onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
|
||||
min={draft.from}
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||
<AppButton variant="contained" color="primary" onClick={listState.apply}>
|
||||
{t('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||
<AppButton variant="text" color="inherit" onClick={listState.clear}>
|
||||
{t('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{items.map((entry) => (
|
||||
<AuditLogRow key={entry.id} entry={entry} />
|
||||
<AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{items.length > 0 ? (
|
||||
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
|
||||
) : null}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
|
||||
import type { PlatformConfig } from '@/services/admin/types';
|
||||
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
|
||||
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
|
||||
* `data_type` (phase §5).
|
||||
*/
|
||||
export default function AdminConfigPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminConfigPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminConfigPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const configs = usePlatformConfigs(1);
|
||||
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
|
||||
const configs = usePlatformConfigs(listState.page);
|
||||
const [editing, setEditing] = useState<PlatformConfig | null>(null);
|
||||
const [historyKey, setHistoryKey] = useState<string | null>(null);
|
||||
|
||||
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
|
||||
))
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={listState.page}
|
||||
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
|
||||
onNext={() => listState.goToPage(listState.page + 1)}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', {
|
||||
page: listState.page,
|
||||
total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
|
||||
})}
|
||||
/>
|
||||
|
||||
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
|
||||
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
|
||||
</Box>
|
||||
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
|
||||
autoFocus
|
||||
multiline={config.dataType === 'json'}
|
||||
minRows={config.dataType === 'json' ? 4 : 1}
|
||||
type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
label={t('cfg_col_value')}
|
||||
@@ -179,15 +200,22 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
|
||||
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const history = useConfigChangeHistory(configKey, 1, configKey != null);
|
||||
const [page, setPage] = useState(1);
|
||||
const history = useConfigChangeHistory(configKey, page, configKey != null);
|
||||
// RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
|
||||
const anchor = locale === 'fa' ? 'left' : 'right';
|
||||
const pageCount = Math.max(1, Math.ceil((history.data?.total ?? 0) / (history.data?.pageSize ?? 1)));
|
||||
|
||||
const close = () => {
|
||||
setPage(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
||||
<Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
|
||||
<AppIcon icon="close" />
|
||||
</AppButton>
|
||||
</Stack>
|
||||
@@ -213,6 +241,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -16,13 +16,24 @@ import {
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { AppButton } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
|
||||
import { AppButton, AppLoading, JalaliDateField } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
|
||||
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
|
||||
import { useHolidays, useUpsertHoliday } from '@/services/admin';
|
||||
|
||||
/** Today's LOCAL date as ISO `YYYY-MM-DD` — never `toISOString()`, which converts to UTC first and can
|
||||
* land on the wrong day near local midnight (the same class of bug fixed in the payout window default). */
|
||||
function todayLocalIso(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
|
||||
|
||||
/**
|
||||
@@ -32,11 +43,21 @@ const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'nationa
|
||||
* computes the shift itself (phase §5).
|
||||
*/
|
||||
export default function AdminHolidaysPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminHolidaysPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminHolidaysPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
const holidays = useHolidays({}, 1);
|
||||
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
|
||||
const holidays = useHolidays({}, listState.page);
|
||||
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
|
||||
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
|
||||
|
||||
const columns: AdminTableColumn<Holiday>[] = [
|
||||
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
|
||||
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
|
||||
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={listState.page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page: listState.page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
|
||||
) : null}
|
||||
@@ -103,18 +134,16 @@ export default function AdminHolidaysPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const TODAY_ISO = ''; // seeded below via state default so no Date at module load
|
||||
|
||||
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertHoliday();
|
||||
const [form, setForm] = useState<HolidayInput>({
|
||||
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
|
||||
const [form, setForm] = useState<HolidayInput>(() => ({
|
||||
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
|
||||
nameFa: holiday?.nameFa ?? '',
|
||||
type: holiday?.type ?? 'official',
|
||||
isBankClosed: holiday?.isBankClosed ?? true,
|
||||
});
|
||||
}));
|
||||
|
||||
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
|
||||
|
||||
@@ -136,13 +165,11 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('hol_col_date')}
|
||||
value={form.holidayDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
|
||||
value={form.holidayDate || null}
|
||||
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
|
||||
disabled={!!holiday}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('hol_name_fa')}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, StatusChip, TrustBadge } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import type { AdminUserSummary } from '@/services/admin/types';
|
||||
import {
|
||||
usePartnerCenter,
|
||||
useCenterSponsoredNurses,
|
||||
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
|
||||
export default function AdminPartnerCenterDetailPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
const parsed = Number(params?.id);
|
||||
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
|
||||
const [confirmVerify, setConfirmVerify] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [assignId, setAssignId] = useState('');
|
||||
const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
|
||||
|
||||
const data = center.data;
|
||||
|
||||
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
};
|
||||
|
||||
const onAssign = () => {
|
||||
const nurseProfileId = Number(assignId);
|
||||
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
|
||||
const nurseProfileId = assignNurseUser?.nurseProfileId;
|
||||
if (nurseProfileId == null) return;
|
||||
assignNurse.mutate(
|
||||
{ nurseProfileId, unlink: false },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
|
||||
setAssignId('');
|
||||
setAssignNurseUser(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const back = (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="partners"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
);
|
||||
const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
|
||||
|
||||
if (center.isLoading) {
|
||||
return (
|
||||
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
{back}
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('partner_detail_title')}
|
||||
</Typography>
|
||||
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
|
||||
{data.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('partner_detail_title')}
|
||||
subtitle={data.name}
|
||||
onBack={goBack}
|
||||
backLabel={t('back')}
|
||||
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
|
||||
/>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
|
||||
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
|
||||
{caps.canManagePartners ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label={t('partner_assign_nurse_ph')}
|
||||
value={assignId}
|
||||
onChange={(e) => setAssignId(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 260 }}>
|
||||
<NursePicker
|
||||
value={assignNurseUser}
|
||||
onChange={setAssignNurseUser}
|
||||
label={t('partner_assign_nurse_ph')}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
noOptionsText={t('user_picker_no_options')}
|
||||
loadingText={t('user_picker_loading')}
|
||||
/>
|
||||
</Box>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon="assign"
|
||||
onClick={onAssign}
|
||||
disabled={assignNurse.isPending || Number(assignId) <= 0}
|
||||
disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
|
||||
sx={{ mt: 0.25 }}
|
||||
>
|
||||
{t('partner_assign_nurse')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, AppLoading, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -24,14 +24,21 @@ import {
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
AdminPager,
|
||||
UserPicker,
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { adminPartnerCenterPath } from '@/constants';
|
||||
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
|
||||
import type { AdminUserSummary } from '@/services/admin/types';
|
||||
import { useUserLookup } from '@/services/admin';
|
||||
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
|
||||
|
||||
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
|
||||
type PartnersListFilters = Record<string, never>;
|
||||
const EMPTY_FILTERS: PartnersListFilters = {};
|
||||
|
||||
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
|
||||
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||
verified: 'verified',
|
||||
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||
* it is only ever entered here, never displayed (the list carries no IBAN at all).
|
||||
*/
|
||||
export default function AdminPartnersPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminPartnersPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPartnersPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const [page, setPage] = useState(1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const listState = useAdminListState<PartnersListFilters>({
|
||||
parse: () => EMPTY_FILTERS,
|
||||
serialize: () => ({}),
|
||||
empty: EMPTY_FILTERS,
|
||||
});
|
||||
const page = listState.page;
|
||||
|
||||
const centers = usePartnerCenters({}, page);
|
||||
const items = centers.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||
const total = centers.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
|
||||
const to = (page - 1) * PARTNER_PAGE_SIZE + items.length;
|
||||
|
||||
const columns: AdminTableColumn<PartnerCenter>[] = [
|
||||
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
|
||||
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
|
||||
getRowKey={(c) => c.id}
|
||||
ariaLabel={t('partner_title')}
|
||||
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
|
||||
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
|
||||
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
|
||||
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs — except
|
||||
* `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
|
||||
* edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
|
||||
interface CenterFormState {
|
||||
name: string;
|
||||
legalEntityType: string;
|
||||
@@ -125,7 +152,7 @@ interface CenterFormState {
|
||||
settlementIban: string;
|
||||
isMerchantOfRecord: boolean;
|
||||
commissionRate: string;
|
||||
adminUserId: string;
|
||||
adminUser: AdminUserSummary | null | undefined;
|
||||
}
|
||||
|
||||
function initialForm(center: PartnerCenter | null): CenterFormState {
|
||||
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
|
||||
settlementIban: '',
|
||||
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
|
||||
commissionRate: center != null ? String(center.commissionRate) : '',
|
||||
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
|
||||
adminUser: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +185,14 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
|
||||
const mutation = isEdit ? update : create;
|
||||
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
|
||||
|
||||
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
|
||||
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
|
||||
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
|
||||
const existingAdminId = center?.adminUserId ?? null;
|
||||
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
|
||||
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
|
||||
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
|
||||
|
||||
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
@@ -179,7 +214,7 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
|
||||
settlementIban: form.settlementIban.trim() || null,
|
||||
isMerchantOfRecord: form.isMerchantOfRecord,
|
||||
commissionRate: commission,
|
||||
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
|
||||
adminUserId: displayedAdminUser?.id ?? null,
|
||||
};
|
||||
mutation.mutate(input, {
|
||||
onSuccess: () => {
|
||||
@@ -235,12 +270,13 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
|
||||
onChange={(e) => set('commissionRate', e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
<UserPicker
|
||||
value={displayedAdminUser}
|
||||
onChange={(u) => set('adminUser', u)}
|
||||
label={t('partner_admin_user')}
|
||||
value={form.adminUserId}
|
||||
onChange={(e) => set('adminUserId', e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
noOptionsText={t('user_picker_no_options')}
|
||||
loadingText={t('user_picker_loading')}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
|
||||
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
/** This detail page has no filters — only a page number worth mirroring into the URL. */
|
||||
type BatchRowsFilters = Record<string, never>;
|
||||
const EMPTY_FILTERS: BatchRowsFilters = {};
|
||||
|
||||
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
draft: 'neutral',
|
||||
processing: 'info',
|
||||
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
|
||||
*/
|
||||
export default function AdminPayoutBatchDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminPayoutBatchDetailScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
|
||||
function AdminPayoutBatchDetailScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const params = useParams<{ batchId: string }>();
|
||||
const batchId = Number(params?.batchId);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const { page, goToPage } = useAdminListState<BatchRowsFilters>({
|
||||
parse: () => EMPTY_FILTERS,
|
||||
serialize: () => ({}),
|
||||
empty: EMPTY_FILTERS,
|
||||
});
|
||||
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
|
||||
const data = detail.data;
|
||||
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('payout_batch_title', { id: batchId })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('payout_batch_title', { id: batchId })}
|
||||
backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
|
||||
backLabel={t('back')}
|
||||
/>
|
||||
|
||||
{detail.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, Money, StatusChip } from '@/components';
|
||||
import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import type { AdminTableColumn } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { adminPayoutBatchPath } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
|
||||
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
|
||||
'failed',
|
||||
];
|
||||
|
||||
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
|
||||
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
/**
|
||||
* ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. Formats using the browser's **local**
|
||||
* date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
|
||||
* silently roll the date back/forward a day from the admin's actual wall-clock date.
|
||||
*/
|
||||
const isoDate = (d: Date): string => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
|
||||
@@ -173,7 +182,7 @@ export default function AdminPayoutsPage() {
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
|
||||
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
size="small"
|
||||
label={t('payout_period_start')}
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label={t('payout_period_end')}
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
onChange={setPeriodStart}
|
||||
/>
|
||||
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
<ConfirmDialog
|
||||
open={runConfirmOpen}
|
||||
title={t('payout_run_confirm_title')}
|
||||
body={t('payout_run_confirm_body')}
|
||||
body={
|
||||
result ? (
|
||||
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_run_summary_intro')}
|
||||
</Typography>
|
||||
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_run_count_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatNumber(result.eligible.length, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_col_processing')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatShamsiDate(result.processingDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null
|
||||
}
|
||||
confirmLabel={t('payout_run')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onRunConfirm}
|
||||
onClose={() => setRunConfirmOpen(false)}
|
||||
loading={run.isPending}
|
||||
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
|
||||
typedConfirmationLabel={t('payout_run_type_to_confirm')}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, RatingInput } from '@/components';
|
||||
import { AppButton, AppLoading, RatingInput } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
|
||||
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
|
||||
import { useModerationQueue, useModerateReview } from '@/services/reviews';
|
||||
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warni
|
||||
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
|
||||
* leaves on success. `canModerate` only hides the controls — the server enforces the role scope.
|
||||
*/
|
||||
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
|
||||
|
||||
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
|
||||
const status = params.get('status') as ModerationStatus | null;
|
||||
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
|
||||
}
|
||||
|
||||
export default function AdminReviewsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminReviewsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminReviewsPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<{ status: ModerationStatus }>({
|
||||
parse: parseFilters,
|
||||
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
|
||||
empty: { status: DEFAULT_STATUS },
|
||||
});
|
||||
const status = listState.applied.status;
|
||||
const page = listState.page;
|
||||
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
|
||||
|
||||
const queue = useModerationQueue({ status }, page);
|
||||
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
|
||||
const items = queue.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
|
||||
|
||||
const setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
|
||||
|
||||
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
|
||||
|
||||
const onConfirm = (reason?: string) => {
|
||||
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
|
||||
size="small"
|
||||
label={t('filter_label')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as ModerationStatus);
|
||||
setPage(1);
|
||||
}}
|
||||
onChange={(e) => setStatus(e.target.value as ModerationStatus)}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
{MODERATION_STATUSES.map((s) => (
|
||||
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
|
||||
* revoke and grant.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -31,12 +31,13 @@ import {
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
ConfirmDialog,
|
||||
UserPicker,
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import type { AdminRole, RoleGrant } from '@/services/admin/types';
|
||||
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin';
|
||||
import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
|
||||
import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
|
||||
|
||||
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
|
||||
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
|
||||
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
|
||||
// Only active grants — a revoked grant leaves the grid.
|
||||
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
|
||||
|
||||
// Batch id→name resolve (3.2) — one request for every grant row, never one per row.
|
||||
const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
|
||||
const userLookup = useUserLookup(userIds);
|
||||
const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
|
||||
|
||||
const columns: AdminTableColumn<RoleGrant>[] = [
|
||||
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
|
||||
{ key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
|
||||
{
|
||||
key: 'role',
|
||||
header: t('role_col_role'),
|
||||
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
|
||||
<ConfirmDialog
|
||||
open={revoking != null}
|
||||
title={t('role_revoke')}
|
||||
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
|
||||
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
|
||||
confirmLabel={t('role_revoke')}
|
||||
cancelLabel={t('cancel')}
|
||||
confirmColor="error"
|
||||
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const grant = useGrantRole();
|
||||
|
||||
const [userId, setUserId] = useState('');
|
||||
const [user, setUser] = useState<AdminUserSummary | null>(null);
|
||||
const [role, setRole] = useState<AdminRole>('support');
|
||||
|
||||
const parsedId = Number(userId);
|
||||
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
|
||||
const valid = user != null;
|
||||
|
||||
const onGrant = () => {
|
||||
if (!valid) return;
|
||||
if (!user) return;
|
||||
grant.mutate(
|
||||
{ userId: parsedId, role },
|
||||
{ userId: user.id, role },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('role_updated'), { variant: 'success' });
|
||||
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
<UserPicker
|
||||
value={user}
|
||||
onChange={setUser}
|
||||
label={t('role_col_user')}
|
||||
type="number"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
noOptionsText={t('user_picker_no_options')}
|
||||
loadingText={t('user_picker_loading')}
|
||||
/>
|
||||
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
|
||||
{ROLES.map((r) => (
|
||||
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||
))}
|
||||
</TextField>
|
||||
{valid ? (
|
||||
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
|
||||
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -13,14 +13,17 @@ import {
|
||||
TextField,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, PageHeader, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useThreadScroll } from '@/components/messaging';
|
||||
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
|
||||
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
|
||||
import { useAuth } from '@/context/auth';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
|
||||
import { useAdminTicket, useAssignTicket, useCloseTicket, usePostAdminMessage, useReopenTicket } from '@/services/tickets';
|
||||
import { TICKET_LIFECYCLE_ENABLED } from '@/services/tickets/constants';
|
||||
import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
|
||||
|
||||
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
|
||||
@@ -40,18 +43,22 @@ function makeClientMessageId(): string {
|
||||
* The admin ticket thread (f15) — the full conversation **including internal notes** (the admin lens carries
|
||||
* `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant-
|
||||
* visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble`
|
||||
* renders internal notes distinctly). Sends are optimistic (`usePostAdminMessage`) — the draft clears only on
|
||||
* confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline
|
||||
* (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's
|
||||
* capabilities; the server is the real authority.
|
||||
* renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
|
||||
* so a staff member can never post an internal note publicly by mistake). Sends are optimistic
|
||||
* (`usePostAdminMessage`) — the draft clears only on confirm; the thread opens scrolled to the newest message
|
||||
* (`useThreadScroll`). When the ticket is a **refund** case linked to a booking, the admin opens the
|
||||
* `RefundPanel` inline. Close/reopen/assign-to-me (REQ-063) are gated behind `TICKET_LIFECYCLE_ENABLED` — no
|
||||
* live route yet — AND the principal's `canManageTickets` capability; the server is the real authority.
|
||||
*/
|
||||
export default function AdminTicketThreadPage() {
|
||||
const t = useTranslations('admin');
|
||||
const tickets = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [authState] = useAuth();
|
||||
const meId = authState.currentUser?.id;
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
const parsed = Number(params?.id);
|
||||
@@ -59,12 +66,20 @@ export default function AdminTicketThreadPage() {
|
||||
|
||||
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
|
||||
const post = usePostAdminMessage(ticketId);
|
||||
const closeTicket = useCloseTicket(ticketId);
|
||||
const reopenTicket = useReopenTicket(ticketId);
|
||||
const assignTicket = useAssignTicket(ticketId);
|
||||
|
||||
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
|
||||
const [body, setBody] = useState('');
|
||||
const [refundShown, setRefundShown] = useState(false);
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
||||
const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
|
||||
|
||||
const isInternal = mode === 'internal';
|
||||
const messageCount = detail?.messages.length ?? 0;
|
||||
const lastMessage = detail?.messages[messageCount - 1];
|
||||
const { bottomRef } = useThreadScroll(messageCount, lastMessage?.isMine ?? false);
|
||||
|
||||
const send = () => {
|
||||
const trimmed = body.trim();
|
||||
@@ -80,16 +95,62 @@ export default function AdminTicketThreadPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const onCloseConfirm = () => {
|
||||
closeTicket.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setCloseDialogOpen(false);
|
||||
enqueueSnackbar(t('ticket_closed_ok'), { variant: 'success' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onReopenConfirm = () => {
|
||||
reopenTicket.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setReopenDialogOpen(false);
|
||||
enqueueSnackbar(t('ticket_reopened_ok'), { variant: 'success' });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onAssignMe = () => {
|
||||
if (meId == null || assignTicket.isPending) return;
|
||||
assignTicket.mutate({ ownerUserId: meId }, { onSuccess: () => enqueueSnackbar(t('ticket_assigned_me'), { variant: 'success' }) });
|
||||
};
|
||||
|
||||
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
|
||||
const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
|
||||
|
||||
const lifecycleActions = showLifecycle && detail ? (
|
||||
<>
|
||||
{detail.status === 'open' ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
|
||||
{t('ticket_close')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
|
||||
{t('ticket_reopen')}
|
||||
</AppButton>
|
||||
)}
|
||||
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
|
||||
<span>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="assign"
|
||||
onClick={onAssignMe}
|
||||
disabled={meId == null || assignTicket.isPending}
|
||||
>
|
||||
{t('ticket_assign_me')}
|
||||
</AppButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
<AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
|
||||
@@ -106,44 +167,41 @@ export default function AdminTicketThreadPage() {
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr">
|
||||
{t('ticket_thread_title', { ref: detail.referenceCode })}
|
||||
</Typography>
|
||||
{detail.subject ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{detail.subject}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip size="small" label={t(`tcat_${detail.category}`)} />
|
||||
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
|
||||
{detail.bookingId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
|
||||
) : null}
|
||||
{detail.refundId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
|
||||
) : null}
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('ticket_thread_title', { ref: detail.referenceCode })}
|
||||
subtitle={detail.subject ?? undefined}
|
||||
actions={lifecycleActions}
|
||||
meta={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip size="small" label={t(`tcat_${detail.category}`)} />
|
||||
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
|
||||
{detail.bookingId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
|
||||
) : null}
|
||||
{detail.refundId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
|
||||
) : null}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{showRefund ? (
|
||||
<Box>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refunds"
|
||||
onClick={() => setRefundShown((v) => !v)}
|
||||
>
|
||||
{t('refund_open')}
|
||||
</AppButton>
|
||||
<Collapse in={refundShown} unmountOnExit>
|
||||
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
{showRefund ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refunds"
|
||||
onClick={() => setRefundShown((v) => !v)}
|
||||
>
|
||||
{t('refund_open')}
|
||||
</AppButton>
|
||||
<Collapse in={refundShown} unmountOnExit>
|
||||
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
|
||||
@@ -154,10 +212,20 @@ export default function AdminTicketThreadPage() {
|
||||
authorLabel={tickets(authorLabelKey(m.authorRole))}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</Box>
|
||||
|
||||
{caps.canManageTickets ? (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
@@ -185,11 +253,34 @@ export default function AdminTicketThreadPage() {
|
||||
disabled={post.isPending || body.trim().length === 0}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('ticket_send')}
|
||||
{isInternal ? t('ticket_send_internal') : t('ticket_send')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={closeDialogOpen}
|
||||
title={t('ticket_close')}
|
||||
body={t('ticket_close_confirm')}
|
||||
confirmLabel={t('ticket_close')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onCloseConfirm}
|
||||
onClose={() => setCloseDialogOpen(false)}
|
||||
loading={closeTicket.isPending}
|
||||
confirmColor="primary"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={reopenDialogOpen}
|
||||
title={t('ticket_reopen')}
|
||||
body={t('ticket_reopen_confirm')}
|
||||
confirmLabel={t('ticket_reopen')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onReopenConfirm}
|
||||
onClose={() => setReopenDialogOpen(false)}
|
||||
loading={reopenTicket.isPending}
|
||||
confirmColor="primary"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, AppLoading, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { adminTicketThreadPath } from '@/constants';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { useAdminTickets } from '@/services/tickets';
|
||||
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
|
||||
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
|
||||
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
|
||||
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
|
||||
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
|
||||
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||
const EMPTY: AdminTicketFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): AdminTicketFilters {
|
||||
return {
|
||||
status: (params.get('status') as TicketStatus | null) ?? undefined,
|
||||
category: (params.get('category') as TicketCategory | null) ?? undefined,
|
||||
referenceCode: params.get('referenceCode') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFilters(filters: AdminTicketFilters): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
if (filters.status) record.status = filters.status;
|
||||
if (filters.category) record.category = filters.category;
|
||||
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (f15) — EVERY ticket across the platform (not one viewer's), the entry point
|
||||
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
|
||||
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
|
||||
* refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page
|
||||
* serves from cache. This surface is staff-only — the server enforces the scope; the UI just routes here.
|
||||
* refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
|
||||
* browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only —
|
||||
* the server enforces the scope; the UI just routes here.
|
||||
*/
|
||||
export default function AdminTicketsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminTicketsQueue />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTicketsQueue() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [page, setPage] = useState(1);
|
||||
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
|
||||
parse: parseFilters,
|
||||
serialize: serializeFilters,
|
||||
empty: EMPTY,
|
||||
});
|
||||
|
||||
const tickets = useAdminTickets(applied, page);
|
||||
const items = tickets.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE));
|
||||
|
||||
const apply = () => {
|
||||
setApplied(draft);
|
||||
setPage(1);
|
||||
};
|
||||
const clear = () => {
|
||||
setDraft(EMPTY);
|
||||
setApplied(EMPTY);
|
||||
setPage(1);
|
||||
};
|
||||
const total = tickets.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
|
||||
const to = items.length === 0 ? 0 : from + items.length - 1;
|
||||
|
||||
const columns: AdminTableColumn<AdminTicketSummary>[] = [
|
||||
{
|
||||
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
|
||||
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
|
||||
},
|
||||
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
|
||||
{
|
||||
key: 'activity',
|
||||
header: t('ticket_activity_col'),
|
||||
minWidth: 140,
|
||||
render: (row) => formatShamsiDate(row.createdAt, locale),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
|
||||
getRowKey={(row) => row.id}
|
||||
ariaLabel={t('ticket_title')}
|
||||
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
|
||||
footer={t('showing_range', { from, to, total })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,101 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AdminDataTable, AdminEmptyState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
|
||||
import { AppLink } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useUserSearch } from '@/services/admin';
|
||||
import { useDebouncedValue } from '@/services/search';
|
||||
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
/** Directory role → chip label key. */
|
||||
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
|
||||
customer: 'user_role_customer',
|
||||
nurse: 'user_role_nurse',
|
||||
admin: 'user_role_admin',
|
||||
partner: 'user_role_partner',
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-first admin user directory (phase §3.8) — search by name/phone over the same admin user-directory
|
||||
* seam `UserPicker`/`NursePicker` use (REQ-061, gap — mock-backed until delivered). Built because the REQ
|
||||
* this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
|
||||
* `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
|
||||
* that already supports an entity filter) — a ticket-queue deep link isn't offered because the admin
|
||||
* ticket queue has no actor/user filter to land on (would be a dishonest link).
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
|
||||
const search = useUserSearch(debounced);
|
||||
const items = search.data ?? [];
|
||||
const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
|
||||
|
||||
const columns: AdminTableColumn<AdminUserSummary>[] = [
|
||||
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
|
||||
{
|
||||
key: 'phone',
|
||||
header: t('user_col_phone'),
|
||||
render: (u) => (
|
||||
<Box component="span" dir="ltr">
|
||||
{u.maskedPhone}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
|
||||
{
|
||||
key: 'roles',
|
||||
header: t('user_col_roles'),
|
||||
render: (u) => (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{u.roles.map((r) => (
|
||||
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
|
||||
))}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
render: (u) => (
|
||||
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('user_search_label')}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
sx={{ maxWidth: 360 }}
|
||||
/>
|
||||
|
||||
{!hasQuery ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('user_search_hint')}
|
||||
</Typography>
|
||||
) : search.isFetching ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('user_picker_loading')}
|
||||
</Typography>
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="users" title={t('user_empty')} />
|
||||
) : (
|
||||
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -15,25 +15,21 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminEmptyState,
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
ConfirmDialog,
|
||||
DocumentViewer,
|
||||
} from '@/components/admin';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
|
||||
import { ROUTES, adminVerificationCasePath } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
|
||||
import {
|
||||
useApproveVerification,
|
||||
useDecideStep,
|
||||
useRejectVerification,
|
||||
useVerificationCase,
|
||||
useVerificationQueue,
|
||||
} from '@/services/verification';
|
||||
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
|
||||
import { parseQueueFilters, queueCaseHref } from '../queueFilters';
|
||||
|
||||
/** The three credential-bearing step types — a Pass here opens the structured credential form. */
|
||||
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
|
||||
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
|
||||
expired: 'rejected',
|
||||
};
|
||||
|
||||
export default function AdminVerificationCasePage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminVerificationCaseScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification case (b6 `AdminVerificationsController`) — the trust desk works one nurse: the identity on
|
||||
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
|
||||
@@ -55,12 +59,18 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
|
||||
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
|
||||
* number never crosses the wire. The whole verification is approvable only when every required step has
|
||||
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
|
||||
*
|
||||
* «پرونده بعدی»/«پرونده قبلی» + arrow keys move through the **current queue page's order** without
|
||||
* returning to the list: the case URL carries the queue's `status`/`search`/`page` (`queueCaseHref`), so
|
||||
* re-deriving the same filters here hits `useVerificationQueue`'s cache the list already primed (no extra
|
||||
* fetch in the common flow). Fetching adjacent pages is out of scope — prev/next disable at the page's ends.
|
||||
*/
|
||||
export default function AdminVerificationCasePage() {
|
||||
function AdminVerificationCaseScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ nurseId: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const nurseVerificationId = Number(params?.nurseId);
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
|
||||
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
|
||||
const backToQueue = useAdminBackToList(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
|
||||
|
||||
// Re-derive the SAME filters/page the queue list would have parsed from its own URL, so this query
|
||||
// reuses the list's cached page (React Query keys structurally) instead of an unfiltered refetch.
|
||||
const queueFilters = useMemo(() => parseQueueFilters(searchParams), [searchParams]);
|
||||
const queuePage = useMemo(() => {
|
||||
const raw = Number(searchParams.get('page'));
|
||||
return Number.isInteger(raw) && raw > 0 ? raw : 1;
|
||||
}, [searchParams]);
|
||||
const queueQuery = useVerificationQueue(queueFilters, queuePage);
|
||||
const queueItems = queueQuery.data?.items ?? [];
|
||||
const caseIndex = queueItems.findIndex((item) => item.nurseVerificationId === nurseVerificationId);
|
||||
const prevCase = caseIndex > 0 ? queueItems[caseIndex - 1] : null;
|
||||
const nextCase = caseIndex >= 0 && caseIndex < queueItems.length - 1 ? queueItems[caseIndex + 1] : null;
|
||||
|
||||
const goToCase = useCallback(
|
||||
(id: number) => router.push(queueCaseHref(locale, adminVerificationCasePath(id), queueFilters, queuePage)),
|
||||
[router, locale, queueFilters, queuePage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const tag = (document.activeElement as HTMLElement | null)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
if (event.key === 'ArrowRight' && prevCase) goToCase(prevCase.nurseVerificationId);
|
||||
else if (event.key === 'ArrowLeft' && nextCase) goToCase(nextCase.nurseVerificationId);
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [prevCase, nextCase, goToCase]);
|
||||
|
||||
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
|
||||
|
||||
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
<AdminPageHeader title={t('ver_case_title')} />
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('ver_case_title')}
|
||||
onBack={backToQueue}
|
||||
backLabel={t('back')}
|
||||
actions={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon="back"
|
||||
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
|
||||
disabled={!prevCase}
|
||||
>
|
||||
{t('ver_prev_case')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
endIcon="forward"
|
||||
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
|
||||
disabled={!nextCase}
|
||||
>
|
||||
{t('ver_next_case')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -408,24 +468,15 @@ function CredentialDialog({
|
||||
value={issuingAuthority}
|
||||
onChange={(e) => setIssuingAuthority(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
|
||||
<JalaliDateField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('ver_issued_at')}
|
||||
value={issuedAt}
|
||||
onChange={(e) => setIssuedAt(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('ver_expires_at')}
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
value={expiresAt || null}
|
||||
onChange={setExpiresAt}
|
||||
required={expiryRequired}
|
||||
error={expiryMissing}
|
||||
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useEffect, useRef } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppIcon, StatusChip } from '@/components';
|
||||
import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -14,13 +14,21 @@ import {
|
||||
} from '@/components/admin';
|
||||
import type { AdminTableColumn } from '@/components/admin';
|
||||
import { adminVerificationCasePath } from '@/constants';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { useVerificationQueue } from '@/services/verification';
|
||||
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
|
||||
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
|
||||
import type {
|
||||
AdminVerificationQueueFilters,
|
||||
AdminVerificationQueueItem,
|
||||
VerificationAggregateStatus,
|
||||
} from '@/services/verification/types';
|
||||
import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
|
||||
|
||||
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
|
||||
type QueueStatusFilter = '' | 'pending' | 'in_review';
|
||||
/** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
|
||||
const WAITING_TIME_WARNING_HOURS = 48;
|
||||
const WAITING_TIME_ALARM_HOURS = 96;
|
||||
const MS_PER_HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
|
||||
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
|
||||
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
|
||||
suspended: 'rejected',
|
||||
};
|
||||
|
||||
export default function AdminVerificationQueuePage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminVerificationQueueScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
|
||||
* nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces
|
||||
* the step progress, the next pending step, when it was submitted, and a warning when a credential is
|
||||
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached
|
||||
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh.
|
||||
* nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
|
||||
* server serves `counts` — REQ-062) replace the old lone select; a name/phone search follows the same
|
||||
* draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
|
||||
* `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) — the case
|
||||
* page re-derives the same query key to reuse this cache for next/prev case navigation.
|
||||
*/
|
||||
export default function AdminVerificationQueuePage() {
|
||||
function AdminVerificationQueueScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState<QueueStatusFilter>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const state = useAdminListState<AdminVerificationQueueFilters>({
|
||||
parse: parseQueueFilters,
|
||||
serialize: serializeQueueFilters,
|
||||
empty: EMPTY_QUEUE_FILTERS,
|
||||
});
|
||||
|
||||
const queue = useVerificationQueue({ status: status || undefined }, page);
|
||||
// Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
|
||||
// render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
|
||||
// Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
|
||||
const applyPendingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (applyPendingRef.current) {
|
||||
applyPendingRef.current = false;
|
||||
state.apply();
|
||||
}
|
||||
});
|
||||
|
||||
const selectStatus = (next: '' | 'pending' | 'in_review') => {
|
||||
state.setDraft((d) => ({ ...d, status: next || undefined }));
|
||||
applyPendingRef.current = true;
|
||||
};
|
||||
|
||||
const queue = useVerificationQueue(state.applied, state.page);
|
||||
const items = queue.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
|
||||
const total = queue.data?.total ?? 0;
|
||||
const counts = queue.data?.counts;
|
||||
const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
|
||||
|
||||
const tabLabel = (base: string, count: number | undefined): string =>
|
||||
count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
|
||||
|
||||
const footerText =
|
||||
total > 0
|
||||
? t('showing_range', {
|
||||
from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
|
||||
to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
|
||||
total,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
|
||||
{
|
||||
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
|
||||
header: t('ver_col_submitted'),
|
||||
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
|
||||
},
|
||||
{
|
||||
key: 'waiting',
|
||||
header: t('ver_col_waiting'),
|
||||
render: (item) => {
|
||||
if (!item.submittedAt) return '—';
|
||||
const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
|
||||
const color =
|
||||
hours >= WAITING_TIME_ALARM_HOURS
|
||||
? 'var(--bal-error)'
|
||||
: hours >= WAITING_TIME_WARNING_HOURS
|
||||
? 'var(--bal-warning)'
|
||||
: undefined;
|
||||
return (
|
||||
<Box component="span" sx={color ? { color } : undefined}>
|
||||
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('ver_title')}
|
||||
subtitle={t('ver_subtitle')}
|
||||
actions={
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('ver_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as QueueStatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
<MenuItem value="pending">{t('agg_pending')}</MenuItem>
|
||||
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem>
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
<AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
|
||||
|
||||
<Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
|
||||
<Tab value="" data-tab="all" label={t('filter_all')} />
|
||||
<Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
|
||||
<Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
|
||||
</Tabs>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('ver_search_label')}
|
||||
placeholder={t('ver_search_ph')}
|
||||
value={state.draft.search ?? ''}
|
||||
onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
|
||||
sx={{ minWidth: 240 }}
|
||||
/>
|
||||
<AppButton variant="contained" color="primary" onClick={state.apply}>
|
||||
{t('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={state.clear}>
|
||||
{t('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{queue.isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
|
||||
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
|
||||
rows={items}
|
||||
getRowKey={(item) => item.nurseVerificationId}
|
||||
ariaLabel={t('ver_title')}
|
||||
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
|
||||
footer={footerText}
|
||||
onRowClick={(item) =>
|
||||
router.push(
|
||||
queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
page={state.page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
|
||||
onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page: state.page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AdminVerificationQueueFilters } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* The queue's URL-synced filter shape — shared between the list (`page.tsx`, via `useAdminListState`)
|
||||
* and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
|
||||
* list primed (`status`/`search`/`page` → `useVerificationQueue`) and reuse its cached page for
|
||||
* next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
|
||||
*/
|
||||
export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
|
||||
|
||||
/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
|
||||
const SEARCH_PARAM = 'q';
|
||||
|
||||
export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
|
||||
const status = params.get('status');
|
||||
return {
|
||||
status: status === 'pending' || status === 'in_review' ? status : undefined,
|
||||
search: params.get(SEARCH_PARAM) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
if (filters.status) record.status = filters.status;
|
||||
if (filters.search) record[SEARCH_PARAM] = filters.search;
|
||||
return record;
|
||||
}
|
||||
|
||||
/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
|
||||
* back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
|
||||
export function queueCaseHref(
|
||||
locale: string,
|
||||
casePath: string,
|
||||
filters: AdminVerificationQueueFilters,
|
||||
page: number,
|
||||
): string {
|
||||
const params = new URLSearchParams(serializeQueueFilters(filters));
|
||||
if (page > 1) params.set('page', String(page));
|
||||
const qs = params.toString();
|
||||
return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { PageHeader, StatusChip, StatusTimeline } from '@/components';
|
||||
import type { StatusKind, TimelineNode, TimelineNodeState } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminBackToList } from '@/hooks';
|
||||
import { formatShamsiDate, formatShamsiDateTime } from '@/utils';
|
||||
import { useMySponsoredBookingDetail } from '@/services/partnerCenter';
|
||||
|
||||
/** Same tone map as the bookings list — one status vocabulary across the portal. */
|
||||
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
|
||||
pending_payment: 'pending',
|
||||
confirmed: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
disputed: 'rejected',
|
||||
closed: 'neutral',
|
||||
cancelled: 'rejected',
|
||||
};
|
||||
|
||||
/** A booking's lifecycle is done once it reaches one of these — the timeline's last node reads as "current" otherwise. */
|
||||
const TERMINAL_STATUSES = new Set(['completed', 'closed', 'cancelled']);
|
||||
|
||||
/**
|
||||
* Partner portal — scoped sponsored-booking detail (f15, REQ-064). Strictly read-only and bounded to what
|
||||
* the portal is allowed to see: the patient's display name, the scheduled date, the current status, and a
|
||||
* server-truth status timeline — **no clinical content, no address, no money**. A bookings-list row deep-
|
||||
* links here; tenancy (only bookings the signed-in center legally covers) is server-enforced.
|
||||
*/
|
||||
export default function PartnerBookingDetailPage() {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const params = useParams<{ id: string }>();
|
||||
const bookingId = Number(params?.id);
|
||||
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.PARTNER_BOOKINGS}`);
|
||||
const detail = useMySponsoredBookingDetail(Number.isFinite(bookingId) ? bookingId : undefined);
|
||||
|
||||
const timelineNodes: TimelineNode[] =
|
||||
detail.data?.timeline.map((entry, index, all) => {
|
||||
const isLast = index === all.length - 1;
|
||||
const state: TimelineNodeState = isLast && !TERMINAL_STATUSES.has(detail.data!.status) ? 'current' : 'completed';
|
||||
return {
|
||||
key: `${entry.status}-${index}`,
|
||||
label: t(`bstatus_${entry.status}`),
|
||||
timestamp: formatShamsiDateTime(entry.occurredAt, locale),
|
||||
state,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={t('booking_detail_title')} onBack={goBack} backLabel={t('booking_detail_back')} />
|
||||
|
||||
{detail.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
) : detail.isError ? (
|
||||
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => detail.refetch()} />
|
||||
) : !detail.data ? (
|
||||
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('bookings_col_id')} #{detail.data.bookingId}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
status={PARTNER_BOOKING_STATUS_KIND[detail.data.status] ?? 'neutral'}
|
||||
label={t(`bstatus_${detail.data.status}`)}
|
||||
/>
|
||||
</Stack>
|
||||
<MetaLine label={t('booking_detail_patient')}>{detail.data.patientName}</MetaLine>
|
||||
<MetaLine label={t('booking_detail_scheduled_date')}>
|
||||
{formatShamsiDate(detail.data.scheduledDate, locale)}
|
||||
</MetaLine>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_detail_timeline_title')}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<StatusTimeline nodes={timelineNodes} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaLine({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,77 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppButton, AppLoading, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
|
||||
import type { AdminTableColumn } from '@/components/admin';
|
||||
import { partnerBookingDetailPath } from '@/constants';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import type { SponsoredBooking } from '@/services/partnerCenter/types';
|
||||
import type { SponsoredBooking, SponsoredBookingFilters } from '@/services/partnerCenter/types';
|
||||
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||
import { useMySponsoredBookings } from '@/services/partnerCenter';
|
||||
|
||||
/**
|
||||
* Booking lifecycle codes the center may legally cover — the read-only filter options. Stable string codes
|
||||
* (the wire enum); labels are the codes themselves since `status` is a free-form summary field, not a
|
||||
* localized enum in the portal contract.
|
||||
*/
|
||||
/** Booking lifecycle codes the center may legally cover — the read-only filter options (stable wire codes). */
|
||||
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
|
||||
|
||||
/** Booking status → chip tone. Progression reads left-to-right calmer→done; disputed/cancelled are the alarm tier. */
|
||||
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
|
||||
pending_payment: 'pending',
|
||||
confirmed: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
disputed: 'rejected',
|
||||
closed: 'neutral',
|
||||
cancelled: 'rejected',
|
||||
};
|
||||
|
||||
const EMPTY_FILTERS: SponsoredBookingFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): SponsoredBookingFilters {
|
||||
return { status: params.get('status') ?? undefined };
|
||||
}
|
||||
|
||||
function serializeFilters(filters: SponsoredBookingFilters): Record<string, string> {
|
||||
return filters.status ? { status: filters.status } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner portal — sponsored bookings (f15). The read-only list of bookings the signed-in center legally
|
||||
* covers (portal scope; server-enforced tenancy). An optional status filter (filter+page keyed cache) and a
|
||||
* dense table of id/patient/date/status. No PII beyond the summary; the center never sees clinical detail.
|
||||
* covers (portal scope; server-enforced tenancy). A status filter (applied+page mirrored into the URL via
|
||||
* `useAdminListState`, ui-phase-11) and a dense table of id/patient/date/status — every status label is
|
||||
* translated (`bstatus_*`), never the raw wire code. Rows link to a scoped read-only detail (REQ-064).
|
||||
* `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
|
||||
*/
|
||||
export default function PartnerBookingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<PartnerBookingsScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PartnerBookingsScreen() {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<SponsoredBookingFilters>({
|
||||
parse: parseFilters,
|
||||
serialize: serializeFilters,
|
||||
empty: EMPTY_FILTERS,
|
||||
});
|
||||
|
||||
const filters = { status: status || undefined };
|
||||
const bookings = useMySponsoredBookings(filters, page);
|
||||
const bookings = useMySponsoredBookings(applied, page);
|
||||
|
||||
const items = bookings.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||
const total = bookings.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
|
||||
const to = items.length === 0 ? 0 : from + items.length - 1;
|
||||
|
||||
const columns: AdminTableColumn<SponsoredBooking>[] = [
|
||||
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
|
||||
@@ -42,35 +80,42 @@ export default function PartnerBookingsPage() {
|
||||
{
|
||||
key: 'status',
|
||||
header: t('bookings_col_status'),
|
||||
render: (b) => <Chip size="small" variant="outlined" label={b.status} />,
|
||||
render: (b) => (
|
||||
<StatusChip status={PARTNER_BOOKING_STATUS_KIND[b.status] ?? 'neutral'} label={t(`bstatus_${b.status}`)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('bookings_title')}
|
||||
actions={
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('bookings_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">{ta('filter_all')}</MenuItem>
|
||||
{BOOKING_STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
<AdminPageHeader title={t('bookings_title')} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('bookings_col_status')}
|
||||
value={draft.status ?? ''}
|
||||
onChange={(e) => setDraft({ status: e.target.value || undefined })}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">{ta('filter_all')}</MenuItem>
|
||||
{BOOKING_STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{t(`bstatus_${s}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||
{ta('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||
{ta('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{bookings.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -88,17 +133,19 @@ export default function PartnerBookingsPage() {
|
||||
rows={items}
|
||||
getRowKey={(b) => b.bookingId}
|
||||
ariaLabel={t('bookings_title')}
|
||||
onRowClick={(b) => router.push(`/${locale}${partnerBookingDetailPath(b.bookingId)}`)}
|
||||
footer={ta('showing_range', { from, to, total })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={ta('prev_page')}
|
||||
nextLabel={ta('next_page')}
|
||||
indicator={ta('page_indicator', { page })}
|
||||
indicator={ta('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading } from '@/components';
|
||||
import {
|
||||
AdminEmptyState,
|
||||
AdminErrorState,
|
||||
@@ -9,21 +10,86 @@ import {
|
||||
AdminPager,
|
||||
PartnerSettlementRow,
|
||||
} from '@/components/admin';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { formatIrrToToman, formatShamsiDate, toCsv } from '@/utils';
|
||||
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||
import type { CenterInvoice } from '@/services/partnerCenter/types';
|
||||
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
|
||||
|
||||
const NO_FILTERS: Record<string, never> = {};
|
||||
|
||||
/**
|
||||
* Partner portal — settlement & invoices (f15). **Merchant-of-record drives the whole view**: a non-MoR
|
||||
* center settles through Balinyaar and issues no commission invoices, so it sees only the
|
||||
* `settlement_not_mor` state (no table). A MoR center sees its per-booking commission invoices
|
||||
* (`PartnerSettlementRow`, VAT-on-commission-only breakdown) with the masked settlement IBAN and a
|
||||
* signed-URL PDF that opens in a new tab. Read-only.
|
||||
* signed-URL PDF that opens in a new tab, plus a client-side CSV export of the currently loaded page. Read-
|
||||
* only. `useSearchParams` (inside `useAdminListState`) needs a `<Suspense>` boundary.
|
||||
*/
|
||||
export default function PartnerSettlementPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<PartnerSettlementScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/** Already-translated CSV column headers, keyed to the row shape `exportInvoicesCsv` builds below. */
|
||||
interface InvoiceCsvHeaders {
|
||||
invoiceNumber: string;
|
||||
bookingId: string;
|
||||
gross: string;
|
||||
commission: string;
|
||||
vat: string;
|
||||
total: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/** Builds + triggers the download of a CSV of the currently loaded invoices page (no new fetch). */
|
||||
function exportInvoicesCsv(invoices: CenterInvoice[], locale: string, headers: InvoiceCsvHeaders): void {
|
||||
const csv = toCsv(
|
||||
invoices.map((inv) => ({
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
bookingId: inv.bookingId,
|
||||
gross: formatIrrToToman(inv.grossIrr, locale),
|
||||
commission: formatIrrToToman(inv.platformCommissionIrr, locale),
|
||||
vat: formatIrrToToman(inv.vatIrr, locale),
|
||||
total: formatIrrToToman(inv.totalIrr, locale),
|
||||
issuedAt: formatShamsiDate(inv.issuedAt, locale),
|
||||
})),
|
||||
[
|
||||
{ key: 'invoiceNumber', label: headers.invoiceNumber },
|
||||
{ key: 'bookingId', label: headers.bookingId },
|
||||
{ key: 'gross', label: headers.gross },
|
||||
{ key: 'commission', label: headers.commission },
|
||||
{ key: 'vat', label: headers.vat },
|
||||
{ key: 'total', label: headers.total },
|
||||
{ key: 'issuedAt', label: headers.issuedAt },
|
||||
],
|
||||
);
|
||||
// UTF-8 BOM so Excel renders Persian text correctly.
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'settlement.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function PartnerSettlementScreen() {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const center = useMyPartnerCenter();
|
||||
const [page, setPage] = useState(1);
|
||||
// Page-only URL sync (no filters on this list) — survives refresh/back like the other portal worklists.
|
||||
const { page, goToPage } = useAdminListState<Record<string, never>>({
|
||||
parse: () => NO_FILTERS,
|
||||
serialize: () => ({}),
|
||||
empty: NO_FILTERS,
|
||||
});
|
||||
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
|
||||
const settlement = useMySettlement(page);
|
||||
|
||||
@@ -58,7 +124,30 @@ export default function PartnerSettlementPage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('settlement_title')} />
|
||||
<AdminPageHeader
|
||||
title={t('settlement_title')}
|
||||
actions={
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="download"
|
||||
disabled={settlement.isLoading || invoices.length === 0}
|
||||
onClick={() =>
|
||||
exportInvoicesCsv(invoices, locale, {
|
||||
invoiceNumber: t('csv_col_invoice_number'),
|
||||
bookingId: t('csv_col_booking_id'),
|
||||
gross: t('csv_col_gross'),
|
||||
commission: t('csv_col_commission'),
|
||||
vat: t('csv_col_vat'),
|
||||
total: t('csv_col_total'),
|
||||
issuedAt: t('csv_col_issued_at'),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('settlement_export_csv')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={ta('prev_page')}
|
||||
nextLabel={ta('next_page')}
|
||||
indicator={ta('page_indicator', { page })}
|
||||
indicator={ta('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user