frontend phase 15
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
AdminEmptyState,
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
AdminPager,
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { adminPartnerCenterPath } from '@/constants';
|
||||
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
|
||||
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
|
||||
|
||||
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
|
||||
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||
verified: 'verified',
|
||||
pending_verification: 'pending',
|
||||
suspended: 'rejected',
|
||||
draft: 'neutral',
|
||||
};
|
||||
|
||||
/**
|
||||
* Partner-center admin list (f15) — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد
|
||||
* اعتماد الکترونیکی) that may be the merchant-of-record. Each row shows whether it issues invoices, its
|
||||
* sponsored-nurse count, and its onboarding state; a row opens the center detail. Admins with
|
||||
* `canManagePartners` may create a new center (inactive until verified). The full IBAN is write-then-masked —
|
||||
* it is only ever entered here, never displayed (the list carries no IBAN at all).
|
||||
*/
|
||||
export default function AdminPartnersPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const [page, setPage] = useState(1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const centers = usePartnerCenters({}, page);
|
||||
const items = centers.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||
|
||||
const columns: AdminTableColumn<PartnerCenter>[] = [
|
||||
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
|
||||
{ key: 'mor', header: t('partner_col_mor'), render: (c) => t(c.isMerchantOfRecord ? 'yes' : 'no') },
|
||||
{ key: 'nurses', header: t('partner_col_nurses'), render: (c) => c.sponsoredNurseCount },
|
||||
{
|
||||
key: 'state',
|
||||
header: t('partner_col_state'),
|
||||
render: (c) => <StatusChip status={CENTER_STATE_KIND[c.onboardingState]} label={t(`center_state_${c.onboardingState}`)} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('partner_title')}
|
||||
subtitle={t('partner_subtitle')}
|
||||
actions={
|
||||
caps.canManagePartners ? (
|
||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)} sx={{ m: 0 }}>
|
||||
{t('partner_create')}
|
||||
</AppButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{centers.isLoading ? (
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
) : centers.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => centers.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="partners" title={t('partner_empty')} />
|
||||
) : (
|
||||
<AdminDataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
getRowKey={(c) => c.id}
|
||||
ariaLabel={t('partner_title')}
|
||||
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
/>
|
||||
|
||||
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
|
||||
interface CenterFormState {
|
||||
name: string;
|
||||
legalEntityType: string;
|
||||
mohEstablishmentPermitNo: string;
|
||||
technicalDirectorLicenseNo: string;
|
||||
enamadCode: string;
|
||||
settlementIban: string;
|
||||
isMerchantOfRecord: boolean;
|
||||
commissionRate: string;
|
||||
adminUserId: string;
|
||||
}
|
||||
|
||||
function initialForm(center: PartnerCenter | null): CenterFormState {
|
||||
return {
|
||||
name: center?.name ?? '',
|
||||
legalEntityType: center?.legalEntityType ?? '',
|
||||
mohEstablishmentPermitNo: center?.mohEstablishmentPermitNo ?? '',
|
||||
technicalDirectorLicenseNo: center?.technicalDirectorLicenseNo ?? '',
|
||||
enamadCode: center?.enamadCode ?? '',
|
||||
// Write-then-masked: always blank on open. On edit, a blank IBAN keeps the existing masked value.
|
||||
settlementIban: '',
|
||||
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
|
||||
commissionRate: center != null ? String(center.commissionRate) : '',
|
||||
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a partner center — shared by the list (create, `center=null`) and the detail
|
||||
* (edit, `center` prefilled). `settlementIban` is write-then-masked: the field is always blank on open and a
|
||||
* blank submit on edit keeps the stored masked value. Validates name + permit non-empty, `commissionRate ∈
|
||||
* [0, 1)`, and (create only) an IBAN when the center is merchant-of-record.
|
||||
*/
|
||||
export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCenter | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = center != null;
|
||||
const create = useCreatePartnerCenter();
|
||||
const update = useUpdatePartnerCenter(center?.id ?? 0);
|
||||
const mutation = isEdit ? update : create;
|
||||
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
|
||||
|
||||
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const commission = Number(form.commissionRate);
|
||||
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
|
||||
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
|
||||
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
|
||||
const valid =
|
||||
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
|
||||
|
||||
const onSave = () => {
|
||||
if (!valid) return;
|
||||
const input: PartnerCenterInput = {
|
||||
name: form.name.trim(),
|
||||
legalEntityType: form.legalEntityType.trim(),
|
||||
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
|
||||
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
|
||||
enamadCode: form.enamadCode.trim() || null,
|
||||
settlementIban: form.settlementIban.trim() || null,
|
||||
isMerchantOfRecord: form.isMerchantOfRecord,
|
||||
commissionRate: commission,
|
||||
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
|
||||
};
|
||||
mutation.mutate(input, {
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('partner_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
|
||||
<TextField
|
||||
label={t('partner_legal_type')}
|
||||
value={form.legalEntityType}
|
||||
onChange={(e) => set('legalEntityType', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t('partner_permit')}
|
||||
value={form.mohEstablishmentPermitNo}
|
||||
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label={t('partner_tech_director_license')}
|
||||
value={form.technicalDirectorLicenseNo}
|
||||
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
|
||||
/>
|
||||
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
|
||||
<TextField
|
||||
label={t('partner_iban')}
|
||||
value={form.settlementIban}
|
||||
onChange={(e) => set('settlementIban', e.target.value)}
|
||||
helperText={t('partner_iban_write_hint')}
|
||||
placeholder={center?.settlementIbanMasked ?? undefined}
|
||||
slotProps={{ htmlInput: { dir: 'ltr' } }}
|
||||
/>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
|
||||
label={t('partner_is_mor')}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{t('partner_is_mor_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
type="number"
|
||||
label={t('partner_commission')}
|
||||
value={form.commissionRate}
|
||||
onChange={(e) => set('commissionRate', e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
label={t('partner_admin_user')}
|
||||
value={form.adminUserId}
|
||||
onChange={(e) => set('adminUserId', e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending} sx={{ m: 0 }}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending} sx={{ m: 0 }}>
|
||||
{mutation.isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user