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