frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AdminDataTable, { type AdminTableColumn } from './AdminDataTable';
interface Row {
id: number;
name: string;
}
const ROWS: Row[] = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
];
const COLUMNS: AdminTableColumn<Row>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id },
{ key: 'name', header: 'Name', render: (r) => r.name },
];
describe('<AdminDataTable/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders headers and every row cell', () => {
wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('exposes a data-col attribute per column', () => {
const { container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
expect(container.querySelectorAll('[data-col="name"]').length).toBe(2);
});
it('calls onRowClick with the clicked row', () => {
const onRowClick = jest.fn();
wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} onRowClick={onRowClick} />);
fireEvent.click(screen.getByText('Beta'));
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
});
});
@@ -0,0 +1,75 @@
import { ReactNode } from 'react';
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@mui/material';
export interface AdminTableColumn<T> {
/** Stable column key (also `data-col` for tests). */
key: string;
/** Already-translated header label. */
header: string;
/** Cell renderer for a row. */
render: (row: T) => ReactNode;
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
align?: 'inherit' | 'left' | 'center' | 'right';
width?: number | string;
}
export interface AdminDataTableProps<T> {
columns: AdminTableColumn<T>[];
rows: T[];
getRowKey: (row: T) => string | number;
onRowClick?: (row: T) => void;
dense?: boolean;
/** Accessible table name (already translated). */
ariaLabel?: string;
}
/**
* The shared dense worklist table for the backoffice. Columns are declared with a typed `render`; the
* whole table scrolls horizontally inside its own container so a wide worklist never breaks the page layout
* (a hard responsive rule). Rows are optionally clickable (a queue row → its case). Header/cell alignment
* defaults to `inherit` so it follows the active text direction (RTL-safe). Colors come from the palette.
* @component AdminDataTable
*/
function AdminDataTable<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps<T>) {
return (
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflowX: 'auto' }}>
<Table size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableHead>
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
{columns.map((col) => (
<TableCell key={col.key} align={col.align ?? 'inherit'} sx={{ width: col.width }}>
{col.header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
export default AdminDataTable;
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AdminEmptyState from './AdminEmptyState';
describe('<AdminEmptyState/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the title and body', () => {
wrap(<AdminEmptyState title="Queue clear" body="Nothing to review" />);
expect(screen.getByText('Queue clear')).toBeInTheDocument();
expect(screen.getByText('Nothing to review')).toBeInTheDocument();
});
it('renders without a body', () => {
wrap(<AdminEmptyState title="Empty" />);
expect(screen.getByText('Empty')).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { FunctionComponent } from 'react';
import { Paper, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
export interface AdminEmptyStateProps {
/** AppIcon registry name. */
icon?: string;
/** Already-translated title. */
title: string;
/** Optional already-translated body. */
body?: string;
}
/**
* The shared empty-state panel for admin worklists ("Queue clear", "No open alerts", …). Dashed border,
* muted icon; tokens only.
* @component AdminEmptyState
*/
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = ({ icon = 'info', title, body }) => (
<Paper
elevation={0}
sx={{ p: 5, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{body}
</Typography>
) : null}
</Paper>
);
export default AdminEmptyState;
@@ -0,0 +1,15 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AdminErrorState from './AdminErrorState';
describe('<AdminErrorState/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the message and calls onRetry on click', () => {
const onRetry = jest.fn();
wrap(<AdminErrorState message="Failed" retryLabel="Retry" onRetry={onRetry} />);
expect(screen.getByText('Failed')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Retry/ }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,32 @@
import { FunctionComponent } from 'react';
import { Paper, Typography } from '@mui/material';
import AppButton from '../common/AppButton';
export interface AdminErrorStateProps {
/** Already-translated message. */
message: string;
/** Already-translated retry label. */
retryLabel: string;
onRetry: () => void;
}
/**
* The shared error panel for admin worklists — a muted message + a retry button. `clientFetch` already
* toasts 401/403/5xx, so this is the inline recovery affordance, not the notification.
* @component AdminErrorState
*/
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = ({ message, retryLabel, onRetry }) => (
<Paper
elevation={0}
sx={{ p: 5, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
</Paper>
);
export default AdminErrorState;
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
import AdminMessageBubble from './AdminMessageBubble';
import type { AdminTicketMessage } from '@/services/tickets/types';
const baseMsg: AdminTicketMessage = {
id: 1,
ticketId: 12,
body: 'hello there',
authorRole: 'admin',
createdAt: '2026-01-01T00:00:00Z',
isMine: true,
isInternal: false,
sendStatus: 'sent',
};
describe('<AdminMessageBubble/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the body and author label', () => {
const { container } = wrap(<AdminMessageBubble message={baseMsg} authorLabel="Support" />);
expect(screen.getByText('hello there')).toBeInTheDocument();
expect(screen.getByText('Support')).toBeInTheDocument();
expect(container.querySelector('[data-internal="false"]')).toBeInTheDocument();
});
it('marks an internal note distinctly with the internal badge', () => {
const { container } = wrap(
<AdminMessageBubble message={{ ...baseMsg, isInternal: true }} authorLabel="Support" />,
);
expect(container.querySelector('[data-internal="true"]')).toBeInTheDocument();
expect(screen.getByText('ticket_internal_badge')).toBeInTheDocument();
});
});
@@ -0,0 +1,71 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Stack, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { AdminTicketMessage } from '@/services/tickets/types';
export interface AdminMessageBubbleProps {
message: AdminTicketMessage;
/** Already-translated author-role label. */
authorLabel: string;
}
/**
* One message in the **admin** ticket thread. Unlike the user-side `MessageBubble`, this renders
* `isInternal` notes **distinctly** (dashed warning-tinted panel + an "Internal note" badge) so staff can
* never confuse an internal note with a participant-visible reply. Internal notes exist only on the admin
* surface — the user-app types never carry `isInternal` (phase §5). Aligns start/end by `isMine`
* (RTL-safe via `alignSelf`); a pending/failed optimistic send is dimmed/marked.
* @component AdminMessageBubble
*/
const AdminMessageBubble: FunctionComponent<AdminMessageBubbleProps> = ({ message, authorLabel }) => {
const t = useTranslations('admin');
const locale = useLocale();
const internal = message.isInternal;
const mine = message.isMine;
return (
<Box
data-internal={internal}
sx={{
alignSelf: mine ? 'flex-end' : 'flex-start',
maxWidth: { xs: '90%', sm: '75%' },
opacity: message.sendStatus === 'sending' ? 0.6 : 1,
}}
>
<Box
sx={{
p: 1.5,
borderRadius: 2,
border: internal ? '1px dashed' : '1px solid',
borderColor: internal ? 'var(--bal-warning)' : 'divider',
bgcolor: internal ? 'var(--bal-secondary-soft)' : mine ? 'var(--bal-primary-soft)' : 'background.paper',
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{authorLabel}
</Typography>
{internal ? (
<Chip
size="small"
label={t('ticket_internal_badge')}
sx={{ height: 20, bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 700 }}
/>
) : null}
</Stack>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.body}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
{formatShamsiDateTime(message.createdAt, locale)}
{message.sendStatus === 'failed' ? ` · ${t('error_generic')}` : ''}
</Typography>
</Box>
</Box>
);
};
export default AdminMessageBubble;
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AdminPageHeader from './AdminPageHeader';
describe('<AdminPageHeader/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the title and subtitle', () => {
wrap(<AdminPageHeader title="Backoffice" subtitle="Run the marketplace" />);
expect(screen.getByRole('heading', { name: 'Backoffice' })).toBeInTheDocument();
expect(screen.getByText('Run the marketplace')).toBeInTheDocument();
});
it('renders the actions slot when provided', () => {
wrap(<AdminPageHeader title="T" actions={<button>Do</button>} />);
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
});
it('omits the subtitle when not given', () => {
wrap(<AdminPageHeader title="T" />);
expect(screen.getByRole('heading', { name: 'T' })).toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import { FunctionComponent, ReactNode } from 'react';
import { Box, Stack, Typography } from '@mui/material';
export interface AdminPageHeaderProps {
/** Already-translated title (i18n is the caller's job — labels are keys). */
title: string;
/** Optional already-translated subtitle. */
subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode;
}
/**
* The standard backoffice page header — a title + optional subtitle and an end-aligned actions slot. Shared
* by every admin console so the worklists read as one system (phase §3). Presentational; RTL-safe (logical
* flex, no directional hard-coding).
* @component AdminPageHeader
*/
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = ({ title, subtitle, actions }) => (
<Stack
direction="row"
sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{title}
</Typography>
{subtitle ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{subtitle}
</Typography>
) : null}
</Box>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
</Stack>
);
export default AdminPageHeader;
@@ -0,0 +1,34 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
import AdminPager from './AdminPager';
describe('<AdminPager/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const base = { prevLabel: 'Prev', nextLabel: 'Next', indicator: 'Page 2' };
it('renders nothing when there is a single page', () => {
const { container } = wrap(
<AdminPager page={1} pageCount={1} onPrev={jest.fn()} onNext={jest.fn()} {...base} />,
);
expect(container).toBeEmptyDOMElement();
});
it('fires onPrev/onNext and disables at the ends', () => {
const onPrev = jest.fn();
const onNext = jest.fn();
wrap(<AdminPager page={2} pageCount={3} onPrev={onPrev} onNext={onNext} {...base} />);
fireEvent.click(screen.getByRole('button', { name: 'Prev' }));
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onPrev).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
expect(screen.getByText('Page 2')).toBeInTheDocument();
});
it('disables prev on the first page', () => {
wrap(<AdminPager page={1} pageCount={3} onPrev={jest.fn()} onNext={jest.fn()} {...base} />);
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled();
});
});
@@ -0,0 +1,48 @@
import { FunctionComponent } from 'react';
import { useLocale } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppButton from '../common/AppButton';
export interface AdminPagerProps {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
/** Already-translated labels — `indicator` is a template string that received {page}/{total}. */
prevLabel: string;
nextLabel: string;
indicator: string;
}
/**
* Prev/next pager for admin worklists — rendered only when there is more than one page. Locale-aware digits
* are the caller's job for the indicator; the labels are passed already-translated.
* @component AdminPager
*/
const AdminPager: FunctionComponent<AdminPagerProps> = ({
page,
pageCount,
onPrev,
onNext,
prevLabel,
nextLabel,
indicator,
}) => {
useLocale();
if (pageCount <= 1) return null;
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center', mt: 1 }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
{prevLabel}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{indicator}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
{nextLabel}
</AppButton>
</Stack>
);
};
export default AdminPager;
@@ -0,0 +1,36 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
import AuditLogRow from './AuditLogRow';
import type { AuditLogEntry } from '@/services/admin/types';
const ENTRY: AuditLogEntry = {
id: 1,
entityType: 'PlatformConfig',
entityId: 'vat_rate',
action: 'updated',
actorUserId: 3,
occurredAt: '2026-01-01T00:00:00Z',
changedFields: { Value: { old: '0.09', new: '0.10' } },
};
describe('<AuditLogRow/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the entity and action', () => {
wrap(<AuditLogRow entry={ENTRY} />);
expect(screen.getByText('PlatformConfig #vat_rate')).toBeInTheDocument();
expect(screen.getByText('updated')).toBeInTheDocument();
});
it('reveals the changed-fields diff on expand', () => {
wrap(<AuditLogRow entry={ENTRY} />);
// Collapsed diff is not visible; expand by clicking the row header.
fireEvent.click(screen.getByText('PlatformConfig #vat_rate'));
expect(screen.getByText('Value')).toBeInTheDocument();
expect(screen.getByText('0.09')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument();
});
});
@@ -0,0 +1,85 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { AuditLogEntry } from '@/services/admin/types';
import AppIcon from '../common/AppIcon';
export interface AuditLogRowProps {
entry: AuditLogEntry;
}
/** Stringify a diff value (JSON for objects, `—` for null). */
function displayValue(v: unknown): string {
if (v == null) return '—';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
/**
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old → new per field;
* PII is server-redacted as `<redacted>`). Read-only by design — there is **no** edit/delete affordance
* (phase §5). Presentational; the caller passes the paged entries.
* @component AuditLogRow
*/
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
const t = useTranslations('admin');
const locale = useLocale();
const [open, setOpen] = useState(false);
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
return (
<Paper
elevation={0}
data-audit-id={entry.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
>
<Stack
direction="row"
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={fields.length ? () => setOpen((v) => !v) : undefined}
>
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{entry.action}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(entry.occurredAt, locale)}
</Typography>
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null}
</Stack>
<Collapse in={open && fields.length > 0}>
<Box sx={{ px: 1.5, pb: 1.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('audit_diff_title')}
</Typography>
<Table size="small" sx={{ mt: 0.5 }}>
<TableHead>
<TableRow>
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_field')}</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_old')}</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_new')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{fields.map(([field, delta]) => (
<TableRow key={field}>
<TableCell sx={{ fontWeight: 600 }}>{field}</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{displayValue(delta.old)}</TableCell>
<TableCell>{displayValue(delta.new)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
</Collapse>
</Paper>
);
};
export default AuditLogRow;
@@ -0,0 +1,47 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
import ConfigRow from './ConfigRow';
import type { PlatformConfig } from '@/services/admin/types';
const CONFIG: PlatformConfig = {
key: 'vat_rate',
value: '0.10',
dataType: 'decimal',
description: 'VAT on commission',
updatedAt: '2026-01-01T00:00:00Z',
updatedBy: 'admin',
};
describe('<ConfigRow/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the key, value and description', () => {
wrap(<ConfigRow config={CONFIG} />);
expect(screen.getByText('vat_rate')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument();
expect(screen.getByText('VAT on commission')).toBeInTheDocument();
});
it('shows the edit control only when canEdit and fires onEdit', () => {
const onEdit = jest.fn();
const { rerender } = wrap(<ConfigRow config={CONFIG} onEdit={onEdit} />);
expect(screen.queryByText('cfg_edit')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<ConfigRow config={CONFIG} canEdit onEdit={onEdit} />
</ThemeProvider>,
);
fireEvent.click(screen.getByText('cfg_edit'));
expect(onEdit).toHaveBeenCalledWith(CONFIG);
});
it('fires onHistory', () => {
const onHistory = jest.fn();
wrap(<ConfigRow config={CONFIG} onHistory={onHistory} />);
fireEvent.click(screen.getByText('cfg_history'));
expect(onHistory).toHaveBeenCalledWith(CONFIG);
});
});
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { PlatformConfig } from '@/services/admin/types';
import AppButton from '../common/AppButton';
export interface ConfigRowProps {
config: PlatformConfig;
/** Whether the current admin may edit config (finance/admin). Server enforces; this hides the control. */
canEdit?: boolean;
onEdit?: (config: PlatformConfig) => void;
onHistory?: (config: PlatformConfig) => void;
}
/**
* One platform-config row: the key + description, the current value rendered by `dataType` (bool → chip,
* json → monospace, else text), the last-updated meta, and Edit / History affordances. Presentational —
* the typed-input edit dialog + the change-history drawer live in the config screen; this row only emits
* the intents. The client never re-parses config beyond rendering by `dataType` (phase §5).
* @component ConfigRow
*/
const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false, onEdit, onHistory }) => {
const t = useTranslations('admin');
const locale = useLocale();
return (
<Paper
elevation={0}
data-config-key={config.key}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, fontFamily: 'monospace' }}>
{config.key}
</Typography>
<Chip size="small" variant="outlined" label={t(`dtype_${config.dataType}`)} />
</Stack>
{config.description ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{config.description}
</Typography>
) : null}
</Box>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="text" color="primary" startIcon="history" onClick={() => onHistory?.(config)} sx={{ m: 0 }}>
{t('cfg_history')}
</AppButton>
{canEdit ? (
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={() => onEdit?.(config)} sx={{ m: 0 }}>
{t('cfg_edit')}
</AppButton>
) : null}
</Stack>
</Stack>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mt: 1.5, flexWrap: 'wrap' }}>
{config.dataType === 'bool' ? (
<Chip
size="small"
label={config.value}
sx={{ bgcolor: config.value === 'true' ? 'var(--bal-success)' : 'var(--bal-divider)', color: config.value === 'true' ? 'var(--bal-success-contrast)' : 'var(--bal-text-secondary)', fontWeight: 700 }}
/>
) : (
<Typography
variant="body2"
data-config-value
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 1 }}
>
{config.value}
</Typography>
)}
{config.updatedAt ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(config.updatedAt, locale)}
{config.updatedBy ? ` · ${t('cfg_updated_by', { actor: config.updatedBy })}` : ''}
</Typography>
) : null}
</Stack>
</Paper>
);
};
export default ConfigRow;
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import ConfirmDialog from './ConfirmDialog';
describe('<ConfirmDialog/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
const base = {
title: 'Run batch?',
confirmLabel: 'Run',
cancelLabel: 'Cancel',
onClose: jest.fn(),
};
it('renders the title and body when open', () => {
wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />);
expect(screen.getByText('Run batch?')).toBeInTheDocument();
expect(screen.getByText('This moves money')).toBeInTheDocument();
});
it('calls onConfirm with no reason for a plain confirm', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
expect(onConfirm).toHaveBeenCalledWith(undefined);
});
it('disables confirm until a required reason is entered, then passes it', () => {
const onConfirm = jest.fn();
wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
expect(confirmBtn).toBeDisabled();
fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } });
expect(confirmBtn).not.toBeDisabled();
fireEvent.click(confirmBtn);
expect(onConfirm).toHaveBeenCalledWith('bad docs');
});
it('calls onClose from cancel', () => {
const onClose = jest.fn();
wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
'use client';
import { FunctionComponent, ReactNode, useState } from 'react';
import {
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField,
} from '@mui/material';
import AppButton from '../common/AppButton';
export interface ConfirmDialogProps {
open: boolean;
/** Already-translated title. */
title: string;
/** Already-translated body (string or node). */
body?: ReactNode;
confirmLabel: string;
cancelLabel: string;
/** Called with the entered reason (undefined when `requireReason` is false). */
onConfirm: (reason?: string) => void;
onClose: () => void;
loading?: boolean;
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
/**
* The shared confirmation dialog behind every audited/irreversible admin action — approve/reject a
* verification, run/retry a payout, save a config, resolve an alert, verify a center. Optionally collects a
* **required reason** (reject/hide/resolve) and disables confirm until it is provided. The dialog owns the
* reason field only; the caller owns the mutation and closes on success. `loading` disables the buttons and
* shows a spinner so a double-submit is impossible.
* @component ConfirmDialog
*/
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
open,
title,
body,
confirmLabel,
cancelLabel,
onConfirm,
onClose,
loading = false,
requireReason = false,
reasonLabel,
reasonPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
const close = () => {
setReason('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
};
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{title}</DialogTitle>
<DialogContent>
{body ? (
typeof body === 'string' ? (
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
) : (
body
)
) : null}
{requireReason ? (
<TextField
autoFocus
fullWidth
multiline
minRows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
label={reasonLabel}
placeholder={reasonPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading} sx={{ m: 0 }}>
{cancelLabel}
</AppButton>
<AppButton
variant="contained"
color={confirmColor}
onClick={confirm}
disabled={confirmDisabled}
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
sx={{ m: 0 }}
>
{confirmLabel}
</AppButton>
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;
@@ -0,0 +1,48 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
const mockUseDocUrl = jest.fn();
jest.mock('@/services/verification', () => ({ useVerificationDocumentUrl: (...a: unknown[]) => mockUseDocUrl(...a) }));
import DocumentViewer from './DocumentViewer';
import type { VerificationDocument } from '@/services/verification/types';
const DOC: VerificationDocument = {
id: 5,
contentType: 'image/png',
fileSizeBytes: 2048,
originalFileName: 'license.png',
url: 'ignored-embedded-url',
};
describe('<DocumentViewer/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
afterEach(() => mockUseDocUrl.mockReset());
it('renders the loaded image from the on-demand signed url (never the embedded url)', () => {
mockUseDocUrl.mockReturnValue({ data: { url: 'https://signed/fresh.png', expiresInSeconds: 60 }, isLoading: false, isFetching: false, isError: false, refetch: jest.fn() });
const { container } = wrap(<DocumentViewer document={DOC} />);
const img = container.querySelector('img') as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.src).toContain('signed/fresh.png');
expect(img.src).not.toContain('ignored-embedded-url');
});
it('offers a re-request affordance on error and calls refetch', () => {
const refetch = jest.fn();
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch });
wrap(<DocumentViewer document={DOC} />);
expect(screen.getByText('doc_error')).toBeInTheDocument();
// Two re-request buttons (header + error panel); click the first.
fireEvent.click(screen.getAllByText('doc_reload')[0]);
expect(refetch).toHaveBeenCalled();
});
it('shows a skeleton while the signed url is loading', () => {
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() });
const { container } = wrap(<DocumentViewer document={DOC} />);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
});
});
@@ -0,0 +1,86 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import type { VerificationDocument } from '@/services/verification/types';
import { useVerificationDocumentUrl } from '@/services/verification';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
export interface DocumentViewerProps {
document: VerificationDocument;
}
/** Human-readable file size. */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* A verification-document viewer that fetches its **signed URL on demand** (never the embedded one — those
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link,
* **error / expired → re-request** (the URL is short-lived, so a manual re-request re-signs it), and the
* loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII → only
* the signed URL is ever surfaced, never a public asset (phase §5).
* @component DocumentViewer
*/
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) => {
const t = useTranslations('admin');
const signed = useVerificationDocumentUrl(document.id);
const isImage = document.contentType.startsWith('image/');
return (
<Box
data-document-id={document.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 1 }}>
<AppIcon icon="document" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
{t('doc_file_meta', { name: document.originalFileName ?? `#${document.id}`, size: formatSize(document.fileSizeBytes) })}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="refresh"
onClick={() => signed.refetch()}
disabled={signed.isFetching}
sx={{ m: 0, minWidth: 0 }}
>
{t('doc_reload')}
</AppButton>
</Stack>
{signed.isLoading || signed.isFetching ? (
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
) : signed.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{t('doc_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()} sx={{ m: 0 }}>
{t('doc_reload')}
</AppButton>
</Stack>
) : signed.data?.url ? (
isImage ? (
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
<Box
component="img"
src={signed.data.url}
alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 1, display: 'block' }}
/>
) : (
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab sx={{ m: 0 }}>
{t('doc_open_new')}
</AppButton>
)
) : null}
</Box>
);
};
export default DocumentViewer;
@@ -0,0 +1,49 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
import PartnerSettlementRow from './PartnerSettlementRow';
import type { CenterInvoice } from '@/services/partnerCenter/types';
// Reconciles: commission 750000 + bnpl 60000 + vat 75000 = 885000 total (VAT on commission only).
const INVOICE: CenterInvoice = {
id: 7,
bookingId: 5003,
invoiceNumber: 'INV-1405-1007',
grossIrr: '5000000',
platformCommissionIrr: '750000',
bnplCommissionIrr: '60000',
vatRate: 0.1,
vatIrr: '75000',
totalIrr: '885000',
moadianReferenceNumber: '1234567890123456789012',
moadianStatus: 'registered',
pdfUrl: 'https://mock.local/7.pdf',
issuedAt: '2026-01-01T00:00:00Z',
};
describe('<PartnerSettlementRow/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders a reconciling commission breakdown without a console error', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
wrap(<PartnerSettlementRow invoice={INVOICE} />);
expect(screen.getByText('invoice_number')).toBeInTheDocument();
// PriceBreakdown dev-guard would console.error if commission+bnpl+vat !== total.
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
errorSpy.mockRestore();
});
it('exposes the مودیان reference', () => {
wrap(<PartnerSettlementRow invoice={INVOICE} />);
expect(screen.getByText(/1234567890123456789012/)).toBeInTheDocument();
});
it('fires onDownloadPdf', () => {
const onDownloadPdf = jest.fn();
wrap(<PartnerSettlementRow invoice={INVOICE} onDownloadPdf={onDownloadPdf} />);
fireEvent.click(screen.getByText('invoice_download'));
expect(onDownloadPdf).toHaveBeenCalledWith(INVOICE);
});
});
@@ -0,0 +1,91 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import type { CenterInvoice } from '@/services/partnerCenter/types';
import AppButton from '../common/AppButton';
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
export interface PartnerSettlementRowProps {
invoice: CenterInvoice;
onDownloadPdf?: (invoice: CenterInvoice) => void;
downloadError?: boolean;
onRetryDownload?: (invoice: CenterInvoice) => void;
}
/**
* One per-booking commission-invoice row in a merchant-of-record center's settlement view. The reconciling
* breakdown is **platform commission + BNPL commission + VAT = total** (VAT on the commission line only,
* never the gross service fee — `grossIrr` is shown as context, not summed). Money is served IRR
* digit-strings formatted to Toman via the shared util. The سامانه مودیان reference + a signed-URL PDF
* download round it out. Enum/label copy comes from the `partner` namespace.
* @component PartnerSettlementRow
*/
const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ invoice, onDownloadPdf, downloadError, onRetryDownload }) => {
const t = useTranslations('partner');
const tc = useTranslations('common');
const locale = useLocale();
const rows: PriceBreakdownRow[] = [
{ key: 'commission', label: t('invoice_row_commission'), amountIrr: invoice.platformCommissionIrr },
];
if (invoice.bnplCommissionIrr) {
rows.push({ key: 'bnpl_commission', label: t('invoice_row_bnpl_commission'), amountIrr: invoice.bnplCommissionIrr });
}
rows.push({ key: 'vat', label: t('invoice_row_vat'), amountIrr: invoice.vatIrr });
return (
<Paper
elevation={0}
data-invoice-id={invoice.id}
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
>
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('invoice_number', { number: invoice.invoiceNumber })}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('settlement_col_booking')} #{invoice.bookingId} · {formatShamsiDate(invoice.issuedAt, locale)}
</Typography>
</Box>
<Stack sx={{ alignItems: 'flex-end' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('invoice_row_gross')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatIrrToToman(invoice.grossIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
</Stack>
<PriceBreakdown rows={rows} totalLabel={t('invoice_row_total')} totalAmountIrr={invoice.totalIrr} />
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', mt: 1.5, flexWrap: 'wrap' }}>
<Chip
size="small"
variant="outlined"
label={
invoice.moadianReferenceNumber
? `${t('settlement_moadian_ref')}: ${invoice.moadianReferenceNumber}`
: t('settlement_moadian_pending')
}
/>
{invoice.pdfUrl ? (
downloadError ? (
<AppButton variant="outlined" color="error" startIcon="refresh" onClick={() => onRetryDownload?.(invoice)} sx={{ m: 0 }}>
{t('invoice_pdf_error')}
</AppButton>
) : (
<AppButton variant="outlined" color="primary" startIcon="download" onClick={() => onDownloadPdf?.(invoice)} sx={{ m: 0 }}>
{t('invoice_download')}
</AppButton>
)
) : null}
</Stack>
</Paper>
);
};
export default PartnerSettlementRow;
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string, v?: Record<string, unknown>) => (v ? `${k}` : k), useLocale: () => 'en' }));
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
const mockUsePreview = jest.fn();
const mutation = () => ({ mutate: jest.fn(), isPending: false });
jest.mock('@/services/refunds', () => ({
useRefundPreview: (...a: unknown[]) => mockUsePreview(...a),
useInitiateRefund: () => mutation(),
useApproveRefund: () => mutation(),
useRejectRefund: () => mutation(),
}));
import RefundPanel from './RefundPanel';
// Reconciles: fee 1500000 + payout 8500000 = 10000000 amount.
const PREVIEW = {
bookingId: 42,
refundPercentageApplied: 1,
amountIrr: '10000000',
platformFeeRefundedIrr: '1500000',
nursePayoutRefundedIrr: '8500000',
refundChannel: 'psp_card' as const,
expectedCustomerRefundEta: null,
willCreateClawback: false,
cancellationPolicyCode: null,
};
describe('<RefundPanel/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
afterEach(() => mockUsePreview.mockReset());
it('renders the server-computed reconciling decomposition', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockUsePreview.mockReturnValue({ data: PREVIEW, isLoading: false, isError: false });
wrap(<RefundPanel bookingId={42} ticketId={9} />);
expect(screen.getByText('refund_preview_title')).toBeInTheDocument();
expect(screen.getByText('refund_initiate')).toBeInTheDocument();
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
errorSpy.mockRestore();
});
it('shows the clawback notice when the nurse was already paid', () => {
mockUsePreview.mockReturnValue({ data: { ...PREVIEW, willCreateClawback: true }, isLoading: false, isError: false });
wrap(<RefundPanel bookingId={42} ticketId={9} />);
expect(screen.getByText('refund_clawback_notice')).toBeInTheDocument();
});
it('shows a loading skeleton while the preview loads', () => {
mockUsePreview.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const { container } = wrap(<RefundPanel bookingId={42} ticketId={9} />);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
});
});
+213
View File
@@ -0,0 +1,213 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Alert, Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
import { formatShamsiDate } from '@/utils';
import type { AdminRefundResult, RefundChannel } from '@/services/refunds/types';
import { useApproveRefund, useInitiateRefund, useRefundPreview, useRejectRefund } from '@/services/refunds';
import AppButton from '../common/AppButton';
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
import ConfirmDialog from './ConfirmDialog';
export interface RefundPanelProps {
bookingId: number;
ticketId: number;
onDone?: (result: AdminRefundResult) => void;
}
const CHANNELS: readonly RefundChannel[] = ['psp_card', 'bnpl_revert', 'manual'];
/**
* The admin refund tool — always opened **from a ticket** (never a standalone form; every initiate carries
* the `ticketId`, phase §5). Renders the **server-computed** tiered percentage + fee/payout decomposition
* (via the shared PriceBreakdown; the client never recomputes the split), a channel selector, the BNPL ETA
* banner, and the read-only clawback notice when the nurse was already paid. Drives
* initiate → (provider-revert failure) retry / reject. Money stays IRR digit-strings end to end.
* @component RefundPanel
*/
const RefundPanel: FunctionComponent<RefundPanelProps> = ({ bookingId, ticketId, onDone }) => {
const t = useTranslations('admin');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const preview = useRefundPreview(bookingId, ticketId);
const initiate = useInitiateRefund();
const approve = useApproveRefund();
const reject = useRejectRefund();
const [channel, setChannel] = useState<RefundChannel | ''>('');
const [notes, setNotes] = useState('');
const [result, setResult] = useState<AdminRefundResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const p = preview.data;
const effectiveChannel = (channel || p?.refundChannel) as RefundChannel | undefined;
const busy = initiate.isPending || approve.isPending || reject.isPending;
const doInitiate = () => {
if (!p) return;
initiate.mutate(
{ bookingId, ticketId, refundChannel: effectiveChannel, reasonCategory: 'customer_request', reasonNotes: notes.trim() || undefined },
{
onSuccess: (r) => {
setResult(r);
setConfirmOpen(false);
if (r.status === 'succeeded' || r.status === 'processing') {
enqueueSnackbar(t('refund_done'), { variant: 'success' });
onDone?.(r);
}
},
onError: () => setConfirmOpen(false),
},
);
};
const doRetry = () => {
if (!result) return;
approve.mutate(result.refundId, {
onSuccess: (r) => {
setResult(r);
if (r.status === 'succeeded' || r.status === 'processing') {
enqueueSnackbar(t('refund_done'), { variant: 'success' });
onDone?.(r);
}
},
});
};
const doReject = (reason?: string) => {
if (!result) return;
reject.mutate(
{ refundId: result.refundId, reason: reason ?? '' },
{
onSuccess: () => {
setRejectOpen(false);
setResult({ ...result, status: 'rejected' });
},
},
);
};
if (preview.isLoading) return <Skeleton variant="rounded" height={260} />;
if (preview.isError || !p) {
return (
<Alert severity="error" variant="outlined">
{t('error_generic')}
</Alert>
);
}
const rows: PriceBreakdownRow[] = [
{ key: 'fee', label: t('refund_row_fee'), amountIrr: p.platformFeeRefundedIrr },
{ key: 'payout', label: t('refund_row_payout'), amountIrr: p.nursePayoutRefundedIrr },
];
const activeResult = result;
const failed = activeResult?.status === 'failed';
const finalDone = activeResult && (activeResult.status === 'succeeded' || activeResult.status === 'processing' || activeResult.status === 'rejected');
const pct = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { style: 'percent', maximumFractionDigits: 0 }).format(p.refundPercentageApplied);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t('refund_preview_title')}
</Typography>
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: bookingId })} />
<Chip size="small" label={`${t('refund_percentage')}: ${pct}`} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 700 }} />
</Stack>
<PriceBreakdown rows={rows} totalLabel={t('refund_row_total')} totalAmountIrr={p.amountIrr} />
{!activeResult ? (
<>
<TextField
select
size="small"
label={t('refund_channel')}
helperText={t('refund_channel_hint')}
value={channel || p.refundChannel}
onChange={(e) => setChannel(e.target.value as RefundChannel)}
sx={{ maxWidth: 260 }}
>
{CHANNELS.map((c) => (
<MenuItem key={c} value={c}>
{t(`channel_${c}`)}
</MenuItem>
))}
</TextField>
{p.expectedCustomerRefundEta ? (
<Alert severity="info" variant="outlined" icon={false}>
{t('refund_eta', { date: formatShamsiDate(p.expectedCustomerRefundEta, locale) })}
{effectiveChannel === 'bnpl_revert' ? `${t('refund_eta_bnpl')}` : ''}
</Alert>
) : null}
{p.willCreateClawback ? (
<Alert severity="warning" variant="outlined">
{t('refund_clawback_notice')}
</Alert>
) : null}
<TextField
size="small"
label={t('refund_notes_ph')}
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
minRows={2}
/>
<AppButton variant="contained" color="primary" startIcon="refunds" onClick={() => setConfirmOpen(true)} disabled={busy} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('refund_initiate')}
</AppButton>
</>
) : failed ? (
<>
<Alert severity="error" variant="outlined">
{t('refund_provider_failed')}
</Alert>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton variant="contained" color="primary" startIcon="refresh" onClick={doRetry} disabled={busy} sx={{ m: 0 }}>
{t('refund_approve')}
</AppButton>
<AppButton variant="outlined" color="error" onClick={() => setRejectOpen(true)} disabled={busy} sx={{ m: 0 }}>
{t('refund_reject')}
</AppButton>
</Stack>
</>
) : finalDone ? (
<Alert severity={activeResult?.status === 'rejected' ? 'warning' : 'success'} variant="outlined">
{t(`rstatus_${activeResult!.status}`)}
</Alert>
) : null}
<ConfirmDialog
open={confirmOpen}
title={t('refund_initiate')}
body={t('refund_confirm', { id: bookingId })}
confirmLabel={t('refund_initiate')}
cancelLabel={t('cancel')}
onConfirm={doInitiate}
onClose={() => setConfirmOpen(false)}
loading={initiate.isPending}
/>
<ConfirmDialog
open={rejectOpen}
title={t('refund_reject')}
confirmLabel={t('refund_reject')}
cancelLabel={t('cancel')}
confirmColor="error"
onConfirm={doReject}
onClose={() => setRejectOpen(false)}
loading={reject.isPending}
requireReason
reasonLabel={t('reason_label')}
/>
</Box>
);
};
export default RefundPanel;
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
import SupportAlertCard from './SupportAlertCard';
import type { SupportAlert } from '@/services/admin/types';
const OPEN_ALERT: SupportAlert = {
id: 42,
type: 'low_rating',
severity: 'high',
status: 'open',
entityType: 'Review',
entityId: '44',
bookingId: 5001,
reviewId: 44,
ownerUserId: null,
resolutionNote: null,
resolvedAt: null,
createdAt: '2026-01-01T00:00:00Z',
};
describe('<SupportAlertCard/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
it('renders the alert type and status labels', () => {
wrap(<SupportAlertCard alert={OPEN_ALERT} />);
expect(screen.getByText('atype_low_rating')).toBeInTheDocument();
expect(screen.getByText('astatus_open')).toBeInTheDocument();
});
it('hides actions unless canAct', () => {
wrap(<SupportAlertCard alert={OPEN_ALERT} />);
expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument();
});
it('fires assign/resolve callbacks when actionable', () => {
const onAssignSelf = jest.fn();
const onResolve = jest.fn();
wrap(<SupportAlertCard alert={OPEN_ALERT} canAct onAssignSelf={onAssignSelf} onResolve={onResolve} />);
fireEvent.click(screen.getByText('alert_assign_me'));
fireEvent.click(screen.getByText('alert_resolve'));
expect(onAssignSelf).toHaveBeenCalledWith(OPEN_ALERT);
expect(onResolve).toHaveBeenCalledWith(OPEN_ALERT);
});
it('does not offer actions on a resolved alert', () => {
wrap(<SupportAlertCard alert={{ ...OPEN_ALERT, status: 'resolved', resolutionNote: 'done' }} canAct />);
expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument();
expect(screen.getByText('done')).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Chip, Paper, Stack, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
import type { SupportAlert, SupportAlertSeverity } from '@/services/admin/types';
import AppButton from '../common/AppButton';
import StatusChip, { type StatusKind } from '../StatusChip';
export interface SupportAlertCardProps {
alert: SupportAlert;
/** Whether the current admin may act (assign/resolve). Server enforces; this only hides controls. */
canAct?: boolean;
onAssignSelf?: (alert: SupportAlert) => void;
onResolve?: (alert: SupportAlert) => void;
}
/** Alert status → semantic chip kind. */
const STATUS_KIND: Record<SupportAlert['status'], StatusKind> = {
open: 'pending',
assigned: 'info',
resolved: 'verified',
};
/** Severity → the inline-start accent token. */
const SEVERITY_ACCENT: Record<SupportAlertSeverity, string> = {
high: 'var(--bal-error)',
medium: 'var(--bal-warning)',
low: 'var(--bal-divider)',
};
/**
* A single internal support-alert card for the triage board. Shows the alert type, a severity accent, the
* linked entity reference, the owner, the raised time (Shamsi), and — for a non-resolved alert — assign/
* resolve actions (only when `canAct`). **Internal-only**: this card is rendered exclusively inside admin
* routes and its data never reaches a non-admin surface (phase §5). Enum labels come from the `admin`
* namespace keyed off the stable code.
* @component SupportAlertCard
*/
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
const t = useTranslations('admin');
const locale = useLocale();
const entityLabel = alert.bookingId != null
? t('alert_link_booking', { id: alert.bookingId })
: alert.reviewId != null
? t('alert_link_review', { id: alert.reviewId })
: t('alert_link_entity', { type: alert.entityType, id: alert.entityId });
return (
<Paper
elevation={0}
data-alert-id={alert.id}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '4px solid',
borderInlineStartColor: SEVERITY_ACCENT[alert.severity],
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t(`atype_${alert.type}`)}
</Typography>
<StatusChip status={STATUS_KIND[alert.status]} label={t(`astatus_${alert.status}`)} />
<Chip size="small" variant="outlined" label={t(`sev_${alert.severity}`)} />
</Stack>
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap', color: 'text.secondary' }}>
<Typography variant="body2">{entityLabel}</Typography>
<Typography variant="body2">
{alert.ownerUserId != null ? `#${alert.ownerUserId}` : t('alert_unassigned')}
</Typography>
<Typography variant="body2">{formatShamsiDateTime(alert.createdAt, locale)}</Typography>
</Stack>
{alert.status === 'resolved' && alert.resolutionNote ? (
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: 'text.secondary' }}>
{alert.resolutionNote}
</Typography>
) : null}
{canAct && alert.status !== 'resolved' ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
{alert.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="assign" onClick={() => onAssignSelf?.(alert)} sx={{ m: 0 }}>
{t('alert_assign_me')}
</AppButton>
) : null}
<AppButton variant="contained" color="primary" startIcon="verified" onClick={() => onResolve?.(alert)} sx={{ m: 0 }}>
{t('alert_resolve')}
</AppButton>
</Stack>
) : null}
</Paper>
);
};
export default SupportAlertCard;
+32
View File
@@ -0,0 +1,32 @@
/**
* Admin/backoffice + partner shared composites (import from `@/components/admin`). Generic worklist
* primitives (page header / empty / error / pager / confirm dialog / data table) + the domain rows
* (config / audit / support-alert / partner settlement). The extension-typed composites (document viewer,
* refund panel, payout batch rows, moderation card, admin message bubble) are added alongside as they land.
*/
export { default as AdminPageHeader } from './AdminPageHeader';
export type { AdminPageHeaderProps } from './AdminPageHeader';
export { default as AdminEmptyState } from './AdminEmptyState';
export type { AdminEmptyStateProps } from './AdminEmptyState';
export { default as AdminErrorState } from './AdminErrorState';
export type { AdminErrorStateProps } from './AdminErrorState';
export { default as AdminPager } from './AdminPager';
export type { AdminPagerProps } from './AdminPager';
export { default as ConfirmDialog } from './ConfirmDialog';
export type { ConfirmDialogProps } from './ConfirmDialog';
export { default as AdminDataTable } from './AdminDataTable';
export type { AdminDataTableProps, AdminTableColumn } from './AdminDataTable';
export { default as ConfigRow } from './ConfigRow';
export type { ConfigRowProps } from './ConfigRow';
export { default as AuditLogRow } from './AuditLogRow';
export type { AuditLogRowProps } from './AuditLogRow';
export { default as SupportAlertCard } from './SupportAlertCard';
export type { SupportAlertCardProps } from './SupportAlertCard';
export { default as PartnerSettlementRow } from './PartnerSettlementRow';
export type { PartnerSettlementRowProps } from './PartnerSettlementRow';
export { default as DocumentViewer } from './DocumentViewer';
export type { DocumentViewerProps } from './DocumentViewer';
export { default as RefundPanel } from './RefundPanel';
export type { RefundPanelProps } from './RefundPanel';
export { default as AdminMessageBubble } from './AdminMessageBubble';
export type { AdminMessageBubbleProps } from './AdminMessageBubble';
@@ -83,6 +83,19 @@ import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined';
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
import SupportIcon from '@mui/icons-material/SupportAgentOutlined';
import SendIcon from '@mui/icons-material/SendOutlined';
// Admin backoffice & partner consoles (f15/b15): config, holidays, audit, alerts, moderation, partners, refunds
import ConfigIcon from '@mui/icons-material/TuneOutlined';
import CalendarIcon from '@mui/icons-material/CalendarMonthOutlined';
import AuditIcon from '@mui/icons-material/FactCheckOutlined';
import AlertsIcon from '@mui/icons-material/NotificationImportantOutlined';
import ModerationIcon from '@mui/icons-material/GavelOutlined';
import PartnersIcon from '@mui/icons-material/ApartmentOutlined';
import RolesIcon from '@mui/icons-material/ManageAccountsOutlined';
import RefundsIcon from '@mui/icons-material/CurrencyExchangeOutlined';
import DownloadIcon from '@mui/icons-material/FileDownloadOutlined';
import ExpandIcon from '@mui/icons-material/ExpandMoreOutlined';
import ExternalIcon from '@mui/icons-material/OpenInNewOutlined';
import AssignIcon from '@mui/icons-material/AssignmentIndOutlined';
/**
* List of all available Icon names
@@ -172,4 +185,16 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
family: FamilyIcon,
support: SupportIcon,
send: SendIcon,
config: ConfigIcon,
calendar: CalendarIcon,
audit: AuditIcon,
alerts: AlertsIcon,
moderation: ModerationIcon,
partners: PartnersIcon,
roles: RolesIcon,
refunds: RefundsIcon,
download: DownloadIcon,
expand: ExpandIcon,
external: ExternalIcon,
assign: AssignIcon,
};