ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
@@ -8,6 +8,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -111,6 +112,9 @@ export const partnerCenterClientApi: PartnerCenterApi = {
if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
},
// REQ-064 — no single-booking read in the b15 contract yet; proposed shape (sibling of the list route).
getMySponsoredBookingDetail: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredBookingDetail>>(`${API}/centers/me/bookings/${bookingId}`)),
listMySettlement: async (params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
@@ -7,6 +7,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -119,6 +120,44 @@ const BOOKINGS: Record<number, SponsoredBooking[]> = {
3: [],
};
/** The normal-path lifecycle ladder a booking climbs before any dispute/cancellation branch. */
const BOOKING_STATUS_LADDER = ['confirmed', 'in_progress', 'completed', 'closed'] as const;
/**
* A synthetic 2-4-step status timeline consistent with the booking's current `status` (REQ-064 — the wire
* has no timeline endpoint yet). Earlier steps get earlier (larger days-ago) timestamps than later ones.
*/
function buildBookingTimeline(status: string): { status: string; occurredAt: string }[] {
if (status === 'pending_payment') return [{ status: 'pending_payment', occurredAt: isoDaysAgo(1) }];
if (status === 'cancelled') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(4) },
{ status: 'cancelled', occurredAt: isoDaysAgo(3) },
];
}
if (status === 'disputed') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(6) },
{ status: 'in_progress', occurredAt: isoDaysAgo(4) },
{ status: 'completed', occurredAt: isoDaysAgo(3) },
{ status: 'disputed', occurredAt: isoDaysAgo(1) },
];
}
const stepIndex = BOOKING_STATUS_LADDER.indexOf(status as (typeof BOOKING_STATUS_LADDER)[number]);
if (stepIndex === -1) return [{ status, occurredAt: isoDaysAgo(1) }];
return BOOKING_STATUS_LADDER.slice(0, stepIndex + 1).map((s, i) => ({
status: s,
occurredAt: isoDaysAgo(stepIndex - i + 1),
}));
}
function findSponsoredBooking(bookingId: number): SponsoredBooking {
const all = Object.values(BOOKINGS).flat();
const booking = all.find((b) => b.bookingId === bookingId);
if (!booking) throw new Error(`Mock sponsored booking ${bookingId} not found`);
return booking;
}
/** 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);
@@ -241,6 +280,10 @@ export const partnerCenterMockApi: PartnerCenterApi = {
if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params));
},
getMySponsoredBookingDetail: async (bookingId: number): Promise<SponsoredBookingDetail> => {
const booking = findSponsoredBooking(bookingId);
return delay({ ...booking, timeline: buildBookingTimeline(booking.status) });
},
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.
@@ -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 scoped, read-only booking detail behind a sponsored-bookings-list row (REQ-064) — dates + a status
* timeline only, no clinical content. `bookingId` is `undefined` while the route param hasn't resolved yet
* (mirrors the disabled-until-ready pattern used by the nurse payout detail hook).
*/
export function useMySponsoredBookingDetail(bookingId: number | undefined) {
return useQuery({
queryKey: centerKeys.mySponsoredBookingDetail(bookingId ?? -1),
queryFn: () => partnerCenterApi.getMySponsoredBookingDetail(bookingId as number),
enabled: bookingId != null && Number.isFinite(bookingId),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -13,4 +13,5 @@ export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCe
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySponsoredBookingDetail } from './hooks/useMySponsoredBookingDetail';
export { useMySettlement } from './hooks/useMySettlement';
@@ -21,5 +21,6 @@ export const centerKeys = {
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySponsoredBookingDetail: (bookingId: number) => [...centerKeys.myCenter(), 'bookings', bookingId] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
};
+19 -3
View File
@@ -72,6 +72,16 @@ export interface SponsoredBooking {
status: string;
}
/**
* The portal's scoped, read-only booking detail (REQ-064) — `SponsoredBooking` plus a server-truth status
* timeline. Deliberately bounded to dates/status/patient display name: no clinical content, no address, no
* money — the portal never sees more than a center legally needs to confirm a booking happened.
*/
export interface SponsoredBookingDetail extends SponsoredBooking {
/** Server-truth status timeline for this booking — dates only, no clinical content (portal scope). */
timeline: { status: string; occurredAt: string }[];
}
/** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
@@ -104,10 +114,14 @@ export interface PartnerCenterFilters {
isActive?: boolean;
}
/** Bookings list filter (portal). */
export interface SponsoredBookingFilters {
/**
* Bookings list filter (portal). A `type` (not `interface`) so it satisfies `useAdminListState`'s
* `Record<string, unknown>` generic constraint — a plain interface has no implicit index signature and
* TS rejects it as a type argument there, even though it's structurally identical.
*/
export type SponsoredBookingFilters = {
status?: string;
}
};
/**
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
@@ -127,6 +141,8 @@ export interface PartnerCenterApi {
getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
/** REQ-064 — the scoped read-only detail behind a bookings-list row (dates + status timeline only). */
getMySponsoredBookingDetail(bookingId: number): Promise<SponsoredBookingDetail>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
}