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