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,120 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
import { PARTNER_PAGE_SIZE } from '../constants';
import type {
CenterInvoice,
PartnerCenter,
PartnerCenterApi,
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
import { deriveCenterState } from '../types';
const API = '/api/v1';
/** Wire `PartnerCenterDto` (b15) — `onboardingState` is derived client-side from `isActive`/`verifiedAt`. */
interface CenterWire {
id: number;
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId: number | null;
technicalDirectorLicenseNo: string | null;
enamadCode: string | null;
settlementIbanMasked: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId: number | null;
isActive: boolean;
verifiedAt: string | null;
sponsoredNurseCount: number;
createdAt: string;
}
function mapCenter(w: CenterWire): PartnerCenter {
return { ...w, onboardingState: deriveCenterState(w.isActive, w.verifiedAt) };
}
/**
* Real HTTP implementation of the `PartnerCenterApi` seam. **Not primary this phase** (`USE_PARTNER_MOCK =
* true`). Admin CRUD/verify/sponsor map the live b15 routes; the activate/suspend toggle and the portal's
* split reads (my-center / nurses / bookings / settlement) are proposed routes (REQ-032/033) kept
* real-shaped so the seam flips in one file once they land.
*/
export const partnerCenterClientApi: PartnerCenterApi = {
listCenters: async (filters: PartnerCenterFilters, params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
if (filters.isMerchantOfRecord != null) q.set('isMerchantOfRecord', String(filters.isMerchantOfRecord));
if (filters.isActive != null) q.set('isActive', String(filters.isActive));
const wire = unwrap(await clientFetch<ApiEnvelope<Paginated<CenterWire>>>(`${API}/admin/partner-centers?${q}`));
return { ...wire, items: wire.items.map(mapCenter) };
},
getCenter: async (id) => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`))),
createCenter: async (input: PartnerCenterInput) =>
mapCenter(
unwrap(
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
),
updateCenter: async (id, input: PartnerCenterInput) =>
mapCenter(
unwrap(
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
}),
),
),
verifyCenter: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/verify`, { method: 'POST' });
},
// REQ-032 — no activate/suspend route in the b15 contract yet; proposed shape.
setCenterActive: async (id, isActive) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/set-active`, {
method: 'POST',
body: JSON.stringify({ isActive }),
});
},
assignNurse: async (id, nurseProfileId, unlink) => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/sponsor-nurse`, {
method: 'POST',
body: JSON.stringify({ nurseProfileId, unlink }),
});
},
// REQ-032 — admin roster read (the b15 dashboard is portal-auth); proposed shape.
getCenterSponsoredNurses: async (id) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/admin/partner-centers/${id}/nurses`)),
// ── portal (center-scoped; REQ-032/033 — proposed split reads over `GET /centers/{id}/dashboard`) ──
getMyCenter: async () => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/centers/me`))),
listMySponsoredNurses: async () =>
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/centers/me/nurses`)),
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
},
listMySettlement: async (params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
return unwrap(await clientFetch<ApiEnvelope<Paginated<CenterInvoice>>>(`${API}/centers/me/settlement?${q}`));
},
};
@@ -0,0 +1,10 @@
import { USE_PARTNER_MOCK } from '../constants';
import type { PartnerCenterApi } from '../types';
import { partnerCenterClientApi } from './clientApi';
import { partnerCenterMockApi } from './mockApi';
/**
* The selected `PartnerCenterApi` implementation — the single seam the hooks import. Mock-primary this
* phase (REQ-032/033); the swap to the real client is this one line.
*/
export const partnerCenterApi: PartnerCenterApi = USE_PARTNER_MOCK ? partnerCenterMockApi : partnerCenterClientApi;
@@ -0,0 +1,250 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import { MOCK_MY_CENTER_ID } from '../constants';
import type {
CenterInvoice,
PartnerCenter,
PartnerCenterApi,
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
import { deriveCenterState } from '../types';
/**
* In-memory `PartnerCenterApi` — **the primary implementation this phase** (REQ-032/033). Fixtures:
* - center **#1 = merchant-of-record** (settlement/invoice view renders) and **#2 = non-MoR** (the
* "settlement runs through Balinyaar" state) plus a **draft** center #3 (unverified banner);
* - sponsored nurses (verified + unverified) and sponsored bookings;
* - commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the
* commission line only), a fake 22-digit مودیان reference, and a stub PDF url;
* - `settlementIbanMasked` is **last-4 only** — the full IBAN never leaves the mock.
* Admin mutations mutate the in-memory arrays; "my center" resolves to `MOCK_MY_CENTER_ID`.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString();
const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10);
const LATENCY_MS = 220;
const delay = <T>(v: T): Promise<T> => new Promise((r) => setTimeout(() => r(v), LATENCY_MS));
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? (all.length || 1));
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
/** Mask an IBAN to last-4 (`"••••0001"`) — the mock never surfaces the full value. */
function maskIban(full: string): string {
return `••••${full.slice(-4)}`;
}
const CENTERS: PartnerCenter[] = [
{
id: 1,
name: 'مرکز پرستاری آسان‌گستر',
legalEntityType: 'llc',
mohEstablishmentPermitNo: 'MOH-12345',
technicalDirectorNurseUserId: 7015,
technicalDirectorLicenseNo: 'INO-88231',
enamadCode: 'EN-999',
settlementIbanMasked: '••••0001',
isMerchantOfRecord: true,
commissionRate: 0.05,
adminUserId: 8,
isActive: true,
verifiedAt: isoDaysAgo(40),
sponsoredNurseCount: 3,
onboardingState: 'verified',
createdAt: isoDaysAgo(120),
},
{
id: 2,
name: 'خانه سلامت مهرآوران',
legalEntityType: 'cooperative',
mohEstablishmentPermitNo: 'MOH-55621',
technicalDirectorNurseUserId: null,
technicalDirectorLicenseNo: 'INO-44120',
enamadCode: 'EN-514',
settlementIbanMasked: '••••7788',
isMerchantOfRecord: false,
commissionRate: 0.05,
adminUserId: 9,
isActive: true,
verifiedAt: isoDaysAgo(15),
sponsoredNurseCount: 1,
onboardingState: 'verified',
createdAt: isoDaysAgo(60),
},
{
id: 3,
name: 'مرکز نمونه (پیش‌نویس)',
legalEntityType: 'llc',
mohEstablishmentPermitNo: 'MOH-00099',
technicalDirectorNurseUserId: null,
technicalDirectorLicenseNo: null,
enamadCode: null,
settlementIbanMasked: null,
isMerchantOfRecord: false,
commissionRate: 0.05,
adminUserId: 10,
isActive: false,
verifiedAt: null,
sponsoredNurseCount: 0,
onboardingState: 'pending_verification',
createdAt: isoDaysAgo(5),
},
];
const NURSES: Record<number, SponsoredNurse[]> = {
1: [
{ nurseProfileId: 15, name: 'زهرا موسوی', isVerified: true },
{ nurseProfileId: 16, name: 'مریم رضایی', isVerified: true },
{ nurseProfileId: 17, name: 'سارا کاظمی', isVerified: false },
],
2: [{ nurseProfileId: 22, name: 'نگار احمدی', isVerified: true }],
3: [],
};
const BOOKINGS: Record<number, SponsoredBooking[]> = {
1: [
{ bookingId: 5001, patientName: 'حاج‌آقا موسوی', scheduledDate: dateDaysAgo(1), status: 'completed' },
{ bookingId: 5002, patientName: 'خانم احمدی', scheduledDate: dateDaysAgo(3), status: 'in_progress' },
{ bookingId: 5003, patientName: 'آقای کریمی', scheduledDate: dateDaysAgo(9), status: 'completed' },
],
2: [{ bookingId: 5101, patientName: 'خانم صادقی', scheduledDate: dateDaysAgo(2), status: 'confirmed' }],
3: [],
};
/** Build a reconciling commission invoice (VAT on the commission line only; total = comm + bnpl + vat). */
function makeInvoice(id: number, bookingId: number, grossIrr: bigint, commissionIrr: bigint, bnplIrr: bigint, vatRate: number, days: number): CenterInvoice {
const vatIrr = (commissionIrr * BigInt(Math.round(vatRate * 100))) / BigInt(100);
const total = commissionIrr + bnplIrr + vatIrr;
return {
id,
bookingId,
invoiceNumber: `INV-1405-${1000 + id}`,
grossIrr: String(grossIrr),
platformCommissionIrr: String(commissionIrr),
bnplCommissionIrr: bnplIrr > BigInt(0) ? String(bnplIrr) : null,
vatRate,
vatIrr: String(vatIrr),
totalIrr: String(total),
moadianReferenceNumber: id % 2 === 0 ? '1234567890123456789012' : null,
moadianStatus: id % 2 === 0 ? 'registered' : 'pending',
pdfUrl: `https://mock.balinyaar.local/invoices/${id}.pdf`,
issuedAt: isoDaysAgo(days),
};
}
const INVOICES: CenterInvoice[] = [
makeInvoice(1, 5001, BigInt(5_000_000), BigInt(750_000), BigInt(0), 0.1, 2),
makeInvoice(2, 5003, BigInt(5_000_000), BigInt(750_000), BigInt(60_000), 0.1, 9),
];
function centerById(id: number): PartnerCenter {
const c = CENTERS.find((x) => x.id === id);
if (!c) throw new Error(`Mock center ${id} not found`);
return c;
}
export const partnerCenterMockApi: PartnerCenterApi = {
listCenters: async (filters: PartnerCenterFilters, params) => {
let items = [...CENTERS];
if (filters.isMerchantOfRecord != null) items = items.filter((c) => c.isMerchantOfRecord === filters.isMerchantOfRecord);
if (filters.isActive != null) items = items.filter((c) => c.isActive === filters.isActive);
return delay(paginate(items, params));
},
getCenter: async (id) => delay(centerById(id)),
createCenter: async (input: PartnerCenterInput) => {
const id = Math.max(0, ...CENTERS.map((c) => c.id)) + 1;
const center: PartnerCenter = {
id,
name: input.name,
legalEntityType: input.legalEntityType,
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
enamadCode: input.enamadCode ?? null,
settlementIbanMasked: input.settlementIban ? maskIban(input.settlementIban) : null,
isMerchantOfRecord: input.isMerchantOfRecord,
commissionRate: input.commissionRate,
adminUserId: input.adminUserId ?? null,
isActive: false,
verifiedAt: null,
sponsoredNurseCount: 0,
onboardingState: 'pending_verification',
createdAt: new Date().toISOString(),
};
CENTERS.push(center);
NURSES[id] = [];
BOOKINGS[id] = [];
return delay(center);
},
updateCenter: async (id, input: PartnerCenterInput) => {
const c = centerById(id);
Object.assign(c, {
name: input.name,
legalEntityType: input.legalEntityType,
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
enamadCode: input.enamadCode ?? null,
isMerchantOfRecord: input.isMerchantOfRecord,
commissionRate: input.commissionRate,
adminUserId: input.adminUserId ?? null,
});
// write-then-masked: a supplied full IBAN is stored masked; never echoed back in plaintext
if (input.settlementIban) c.settlementIbanMasked = maskIban(input.settlementIban);
return delay(c);
},
verifyCenter: async (id) => {
const c = centerById(id);
c.verifiedAt = new Date().toISOString();
c.isActive = true;
c.onboardingState = 'verified';
return delay(undefined);
},
setCenterActive: async (id, isActive) => {
const c = centerById(id);
c.isActive = isActive;
c.onboardingState = deriveCenterState(c.isActive, c.verifiedAt);
return delay(undefined);
},
assignNurse: async (id, nurseProfileId, unlink) => {
const roster = (NURSES[id] ??= []);
if (unlink) {
NURSES[id] = roster.filter((n) => n.nurseProfileId !== nurseProfileId);
} else if (!roster.some((n) => n.nurseProfileId === nurseProfileId)) {
roster.push({ nurseProfileId, name: `پرستار #${nurseProfileId}`, isVerified: false });
}
centerById(id).sponsoredNurseCount = (NURSES[id] ?? []).length;
return delay(undefined);
},
getCenterSponsoredNurses: async (id) => delay([...(NURSES[id] ?? [])]),
// ── portal (my center) ──
getMyCenter: async () => delay(centerById(MOCK_MY_CENTER_ID)),
listMySponsoredNurses: async () => delay([...(NURSES[MOCK_MY_CENTER_ID] ?? [])]),
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
let items = [...(BOOKINGS[MOCK_MY_CENTER_ID] ?? [])];
if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params));
},
listMySettlement: async (params) => {
const center = centerById(MOCK_MY_CENTER_ID);
// Non-MoR centers issue no commission invoices here — the portal renders the "via Balinyaar" state.
const items = center.isMerchantOfRecord ? [...INVOICES] : [];
return delay(paginate(items, params));
},
};
@@ -0,0 +1,19 @@
/**
* When true, the partner-center domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PartnerCenterApi` seam. **Mock is primary this phase:** the b15 contract exposes admin CRUD/verify/
* sponsor + a single `GET /centers/{id}/dashboard` portal endpoint, but the portal's split reads
* (my-center / sponsored-nurses / sponsored-bookings / settlement invoices), the activate/suspend toggle,
* and the write-then-masked IBAN flow are gaps (REQ-032/033). The mock returns **both** a merchant-of-record
* center (settlement view renders) and a non-MoR center (the "settlement via Balinyaar" state), verified +
* unverified nurses, sponsored bookings, and commission invoices with a fake مودیان reference + stub PDF.
*/
export const USE_PARTNER_MOCK = true;
/** Which mock center the portal ("my center") resolves to — flip to demo the MoR vs non-MoR states. */
export const MOCK_MY_CENTER_ID = 1;
export const PARTNER_PAGE_SIZE = 20;
export const PARTNER_LIST_STALE_TIME = 60 * 1000;
export const PARTNER_DETAIL_STALE_TIME = 30 * 1000;
export const PARTNER_GC_TIME = 5 * 60 * 1000;
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Set/clear a nurse's sponsorship link to a center. Invalidate the roster + detail. */
export function useAssignNurseToPartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, { nurseProfileId: number; unlink: boolean }>({
mutationFn: ({ nurseProfileId, unlink }) => partnerCenterApi.assignNurse(id, nurseProfileId, unlink),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.sponsoredNurses(id) });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
},
});
}
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** The sponsored-nurse roster for one center (admin detail). */
export function useCenterSponsoredNurses(id: number | null) {
return useQuery({
queryKey: centerKeys.sponsoredNurses(id ?? -1),
queryFn: () => partnerCenterApi.getCenterSponsoredNurses(id!),
enabled: id != null && id > 0,
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import type { PartnerCenter, PartnerCenterInput } from '../types';
/** Create a partner center (inactive until verified). Invalidate the center lists. */
export function useCreatePartnerCenter() {
const queryClient = useQueryClient();
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
mutationFn: (input) => partnerCenterApi.createCenter(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
},
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/**
* The signed-in center admin's **own** center (portal scope; server-resolved — never a raw id). Also the
* de-facto access gate for the `/partner` shell: a resolved center means in-scope; a 403/404 means the
* caller has no center (access-denied state).
*/
export function useMyPartnerCenter() {
return useQuery({
queryKey: centerKeys.myCenter(),
queryFn: () => partnerCenterApi.getMyCenter(),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
retry: false,
});
}
@@ -0,0 +1,19 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
/**
* The center's per-booking commission invoices (portal settlement view). Only meaningful for a
* merchant-of-record center — a non-MoR center returns an empty page and the portal shows the
* "settlement runs through Balinyaar" state.
*/
export function useMySettlement(page = 1) {
return useQuery({
queryKey: centerKeys.mySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listMySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
import type { SponsoredBookingFilters } from '../types';
/** The bookings the signed-in center legally covers (portal; read-only summaries). Filter+page key cache. */
export function useMySponsoredBookings(filters: SponsoredBookingFilters, page = 1) {
return useQuery({
queryKey: centerKeys.mySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listMySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_LIST_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** The nurses the signed-in center sponsors (portal). */
export function useMySponsoredNurses() {
return useQuery({
queryKey: centerKeys.mySponsoredNurses(),
queryFn: () => partnerCenterApi.listMySponsoredNurses(),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/** Admin partner-center detail (IBAN masked last-4). */
export function usePartnerCenter(id: number | null) {
return useQuery({
queryKey: centerKeys.detail(id ?? -1),
queryFn: () => partnerCenterApi.getCenter(id!),
enabled: id != null && id > 0,
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -0,0 +1,16 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
import type { PartnerCenterFilters } from '../types';
/** Admin list of partner centers (no IBAN, sponsored-nurse counts). Filters + page key the cache. */
export function usePartnerCenters(filters: PartnerCenterFilters, page = 1) {
return useQuery({
queryKey: centerKeys.list(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
queryFn: () => partnerCenterApi.listCenters(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
staleTime: PARTNER_LIST_STALE_TIME,
gcTime: PARTNER_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Activate / suspend a center (REQ-032). Invalidate list + detail. */
export function useSetPartnerCenterActive(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, boolean>({
mutationFn: (isActive) => partnerCenterApi.setCenterActive(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import type { PartnerCenter, PartnerCenterInput } from '../types';
/** Update a partner center (replace semantics; IBAN write-then-masked). Invalidate list + detail. */
export function useUpdatePartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
mutationFn: (input) => partnerCenterApi.updateCenter(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
/** Record licensing approval and activate a center. Invalidate list + detail. */
export function useVerifyPartnerCenter(id: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => partnerCenterApi.verifyCenter(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
},
});
}
@@ -0,0 +1,16 @@
/**
* Partner-center domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { usePartnerCenters } from './hooks/usePartnerCenters';
export { usePartnerCenter } from './hooks/usePartnerCenter';
export { useCenterSponsoredNurses } from './hooks/useCenterSponsoredNurses';
export { useCreatePartnerCenter } from './hooks/useCreatePartnerCenter';
export { useUpdatePartnerCenter } from './hooks/useUpdatePartnerCenter';
export { useVerifyPartnerCenter } from './hooks/useVerifyPartnerCenter';
export { useSetPartnerCenterActive } from './hooks/useSetPartnerCenterActive';
export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCenter';
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySettlement } from './hooks/useMySettlement';
+25
View File
@@ -0,0 +1,25 @@
import type { PageParams } from '@/lib/api/types';
import type { PartnerCenterFilters, SponsoredBookingFilters } from './types';
/**
* React Query key factory for the partner-center domain. Admin lists key on filters+page; the portal keys
* are scoped to "my center" (the server resolves the caller's own center — never a raw id). Mutations
* invalidate the affected sub-tree.
*/
export const centerKeys = {
all: ['partnerCenter'] as const,
lists: () => [...centerKeys.all, 'list'] as const,
list: (filters: PartnerCenterFilters, params: PageParams) => [...centerKeys.lists(), filters, params] as const,
details: () => [...centerKeys.all, 'detail'] as const,
detail: (id: number) => [...centerKeys.details(), id] as const,
sponsoredNurses: (id: number) => [...centerKeys.detail(id), 'sponsoredNurses'] as const,
// portal (my center)
myCenter: () => [...centerKeys.all, 'me'] as const,
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
};
+138
View File
@@ -0,0 +1,138 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Partner-center domain — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد اعتماد
* الکترونیکی) that may be the **merchant-of-record / invoice issuer**, and the two audiences that read
* them: Balinyaar **admins** (list/create/verify/activate/sponsor) and a **center admin** in the separate
* partner-portal scope (their own center only). Shapes derive from the b15 contract
* (`dev/contracts/domains/messaging-notifications-admin.md`) and the b11 invoice shape.
*
* Load-bearing rules (phase §5):
* - **`settlementIban` is never returned in plaintext** — only a masked last-4 (`"••••0001"`). On create
* it is **write-then-masked** (submit the full IBAN, only last-4 shows afterwards).
* - **Merchant-of-record drives the settlement view** — the invoice/settlement surface renders only when
* `isMerchantOfRecord === true`.
* - **VAT is on the commission line only**, config-driven — never hardcode 10%.
* - **Tenancy** — a center admin sees only their own center (server-enforced); never fetch a raw id they
* don't own.
*/
/** A center's onboarding/verification lifecycle (derived from `isActive`/`verifiedAt`; the mock sets it). */
export type CenterOnboardingState = 'draft' | 'pending_verification' | 'verified' | 'suspended';
/** `PartnerCenter` detail (admin + portal). `settlementIbanMasked` is last-4 only, never the full IBAN. */
export interface PartnerCenter {
id: number;
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId: number | null;
technicalDirectorLicenseNo: string | null;
enamadCode: string | null;
settlementIbanMasked: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId: number | null;
isActive: boolean;
verifiedAt: string | null;
sponsoredNurseCount: number;
onboardingState: CenterOnboardingState;
createdAt: string;
}
/**
* Create/update body. `settlementIban` is the **full** IBAN, write-only — the server stores it masked and
* only ever returns the last-4. Required when `isMerchantOfRecord`. `commissionRate ∈ [0, 1)`.
*/
export interface PartnerCenterInput {
name: string;
legalEntityType: string;
mohEstablishmentPermitNo: string;
technicalDirectorNurseUserId?: number | null;
technicalDirectorLicenseNo?: string | null;
enamadCode?: string | null;
settlementIban?: string | null;
isMerchantOfRecord: boolean;
commissionRate: number;
adminUserId?: number | null;
}
/** A nurse sponsored by a center (roster + portal list). */
export interface SponsoredNurse {
nurseProfileId: number;
name: string;
isVerified: boolean;
}
/** A booking the center legally covers (portal list; read-only summary, no extra PII). */
export interface SponsoredBooking {
bookingId: number;
patientName: string;
scheduledDate: string;
status: string;
}
/** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
/**
* A per-booking commission invoice (only meaningful when the center is merchant-of-record). The
* reconciling breakdown is **platform commission + BNPL commission + VAT = total**; `grossIrr` is shown
* as context, not part of the total (VAT is on the commission line, never the gross service fee). Money is
* IRR digit-strings.
*/
export interface CenterInvoice {
id: number;
bookingId: number;
invoiceNumber: string;
grossIrr: string;
platformCommissionIrr: string;
bnplCommissionIrr: string | null;
vatRate: number;
vatIrr: string;
/** commission + bnpl commission + vat (REQ-033 — the wire lacks a total; summed from served legs). */
totalIrr: string;
moadianReferenceNumber: string | null;
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
}
/** Admin list filters. */
export interface PartnerCenterFilters {
isMerchantOfRecord?: boolean;
isActive?: boolean;
}
/** Bookings list filter (portal). */
export interface SponsoredBookingFilters {
status?: string;
}
/**
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
* and the in-memory mock both implement it (selection by `USE_PARTNER_MOCK`).
*/
export interface PartnerCenterApi {
// admin-side
listCenters(filters: PartnerCenterFilters, params: PageParams): Promise<Paginated<PartnerCenter>>;
getCenter(id: number): Promise<PartnerCenter>;
createCenter(input: PartnerCenterInput): Promise<PartnerCenter>;
updateCenter(id: number, input: PartnerCenterInput): Promise<PartnerCenter>;
verifyCenter(id: number): Promise<void>;
setCenterActive(id: number, isActive: boolean): Promise<void>;
assignNurse(id: number, nurseProfileId: number, unlink: boolean): Promise<void>;
getCenterSponsoredNurses(id: number): Promise<SponsoredNurse[]>;
// portal (center-scoped)
getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
}
/** Derive the onboarding state from a center's `isActive`/`verifiedAt` (the real-path fallback). */
export function deriveCenterState(isActive: boolean, verifiedAt: string | null): CenterOnboardingState {
if (verifiedAt && isActive) return 'verified';
if (verifiedAt && !isActive) return 'suspended';
return 'pending_verification';
}