frontend phase 12

This commit is contained in:
hamid
2026-07-10 14:48:15 +03:30
parent 67c028562e
commit 6186f54294
34 changed files with 2363 additions and 1 deletions
@@ -0,0 +1,93 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { PAYOUTS_PAGE_SIZE } from '../constants';
import type {
EarningsListParams,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutStatus,
PayoutsApi,
} from '../types';
import type { PageParams } from '@/lib/api/types';
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
/**
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
* **no** `failureReason` — that field lives on the admin-only `PayoutDto`, so it maps to `null` until
* REQ-025 adds it to the nurse read (a `failed` payout still renders; its reason is just absent).
*/
interface NursePayoutHistoryWire {
id: number;
batchId: number;
status: PayoutStatus;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
netAmountIrr: string;
maskedIban: string;
transferReference: string | null;
paidAt: string | null;
periodStart: string;
periodEnd: string;
}
function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
return {
id: wire.id,
batchId: wire.batchId,
status: wire.status,
grossEarningsIrr: wire.grossEarningsIrr,
clawbackAppliedIrr: wire.clawbackAppliedIrr,
netAmountIrr: wire.netAmountIrr,
maskedIban: wire.maskedIban,
transferReference: wire.transferReference,
paidAt: wire.paidAt,
periodStart: wire.periodStart,
periodEnd: wire.periodEnd,
// REQ-025: the nurse history DTO carries no failure reason yet (it is on the admin PayoutDto).
failureReason: null,
};
}
/**
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
* route; the other three target contract gaps the frontend filed (**REQ-025**), which is why the domain
* stays mock-primary (see `constants.ts`):
* - `getNurseEarningsBalance` → the four-bucket summary + signed net balance (no nurse endpoint today).
* - `getNurseEarnings` → the per-booking earnings list + money-state (no nurse endpoint today).
* - `getNursePayoutDetail` → a nurse-scoped payout detail with batch context + booking links (the b13
* `batches/{id}` detail is **admin-only**).
*
* NOT the primary implementation this phase (`USE_PAYOUTS_MOCK = true`). Routes are `snake_case`; ids come
* from the route; `clientFetch` returns the raw envelope so we `unwrap()`.
*/
export const payoutsClientApi: PayoutsApi = {
getNurseEarningsBalance: async () =>
unwrap(await clientFetch<ApiEnvelope<NurseEarningsSummary>>(`${NURSE_PAYOUTS}/earnings_balance`)),
getNurseEarnings: async (params: EarningsListParams): Promise<Paginated<NurseEarningsItem>> => {
const query = new URLSearchParams();
if (params.state) query.set('state', params.state);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<NurseEarningsItem>>>(`${NURSE_PAYOUTS}/earnings?${query.toString()}`),
);
},
getNursePayoutHistory: async (params: PageParams): Promise<Paginated<NursePayoutHistoryItem>> => {
const query = new URLSearchParams();
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<NursePayoutHistoryWire>>>(`${NURSE_PAYOUTS}/history?${query.toString()}`),
);
return { ...page, items: page.items.map(toHistoryItem) };
},
getNursePayoutDetail: async (payoutId: number) =>
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_PAYOUTS_MOCK } from '../constants';
import type { PayoutsApi } from '../types';
import { payoutsClientApi } from './clientApi';
import { payoutsMockApi } from './mockApi';
/**
* The selected `PayoutsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_PAYOUTS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-025 gaps).
*/
export const payoutsApi: PayoutsApi = USE_PAYOUTS_MOCK ? payoutsMockApi : payoutsClientApi;
+339
View File
@@ -0,0 +1,339 @@
import type { Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { MOCK_SCENARIO } from '../constants';
import type {
EarningsListParams,
EarningsState,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutsApi,
} from '../types';
/**
* In-memory `PayoutsApi` — **the primary implementation this phase** (b13 serves only the nurse history
* endpoint; the summary, earnings list, and nurse payout detail are REQ-025 gaps — see `constants.ts`).
*
* The fixtures are engineered to exercise **every** UI state and to be **money-correct**:
* - all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`);
* - `gross = commission + payout` on every earnings row (the sacred three-amount invariant);
* - a **negative net balance** ("owed back") under the `clawback_heavy` scenario;
* - a `failed` payout (with a `failureReason`) plus `paid` and `submitted` payouts;
* - payout-detail booking links whose `payoutAmountIrr` sum to the payout's `grossEarningsIrr`, and
* `gross clawback = net = amount` — the reconciliation the nurse checks.
*
* Booking ids align with the f8 bookings mock seeds (50015004) so an earnings row's "view booking"
* deep-link lands on a real mock detail screen. Timestamps are computed **relative to now** so the pending
* dispute-window countdown always ticks; all money stays an IRR digit-string end-to-end.
*/
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/** An ISO instant `hours` in the future (+) / past () from now — for dispute-window / paid-at fixtures. */
function isoFromNowHours(hours: number): string {
return new Date(Date.now() + hours * HOUR_MS).toISOString();
}
/** An ISO `YYYY-MM-DD` date `days` ago (batch window / scheduled-date fixtures). */
function isoDateDaysAgo(days: number): string {
return new Date(Date.now() - days * DAY_MS).toISOString().slice(0, 10);
}
const MASKED_IBAN = 'IR••••••••••••••••••4821';
// ── Earnings items (one per state; ids match the f8 bookings mock seeds) ──────────────────────────────
const EARNINGS: NurseEarningsItem[] = [
{
// pending — in escrow, dispute window still open (~40h left, always ticking)
bookingId: 5001,
patientName: 'حاج‌آقا موسوی',
scheduledDate: isoDateDaysAgo(1),
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state: 'pending',
disputeWindowEndsAt: isoFromNowHours(40),
payoutEligibleAt: null,
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// eligible — cleared (dispute window closed), awaiting the next weekly batch
bookingId: 5002,
patientName: 'خانم احمدی',
scheduledDate: isoDateDaysAgo(4),
grossPriceIrr: '4000000',
balinyaarCommissionIrr: '600000',
nursePayoutAmount: '3400000',
state: 'eligible',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-12),
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// paid — transferred, links to its payout detail
bookingId: 5003,
patientName: 'آقای کریمی',
scheduledDate: isoDateDaysAgo(9),
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state: 'paid',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-96),
paidAt: isoFromNowHours(-72),
transferReference: 'PAYA-14050412-9001',
nursePayoutId: 9001,
batchId: 7001,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// clawback_applied — booking refunded after payout: original 1,700,000 clawback 1,700,000 = net 0
bookingId: 5004,
patientName: 'خانم صادقی',
scheduledDate: isoDateDaysAgo(14),
grossPriceIrr: '2000000',
balinyaarCommissionIrr: '300000',
nursePayoutAmount: '1700000',
state: 'clawback_applied',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-240),
paidAt: isoFromNowHours(-216),
transferReference: 'SATNA-14050405-9002',
nursePayoutId: 9002,
batchId: 7000,
clawbackAppliedIrr: '1700000',
netAmountIrr: '0',
},
];
// ── Payout history (newest first; exercises all four PayoutStatus values) ─────────────────────────────
const HISTORY: NursePayoutHistoryItem[] = [
{
id: 9004,
batchId: 7003,
status: 'submitted',
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
periodStart: isoDateDaysAgo(7),
periodEnd: isoDateDaysAgo(1),
failureReason: null,
},
{
id: 9003,
batchId: 7002,
status: 'failed',
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
failureReason: 'invalid_sheba',
},
{
id: 9001,
batchId: 7001,
status: 'paid',
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'PAYA-14050412-9001',
paidAt: isoFromNowHours(-72),
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
failureReason: null,
},
{
// demonstrates clawback netting in a paid payout: 5,000,000 gross 750,000 clawback = 4,250,000 net
id: 9002,
batchId: 7000,
status: 'paid',
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '750000',
netAmountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'SATNA-14050405-9002',
paidAt: isoFromNowHours(-240),
periodStart: isoDateDaysAgo(21),
periodEnd: isoDateDaysAgo(15),
failureReason: null,
},
];
// ── Payout details (batch context + booking links; every one reconciles) ──────────────────────────────
const DETAILS: Record<number, NursePayoutDetail> = {
9001: {
id: 9001,
batchId: 7001,
status: 'paid',
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
amountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'PAYA-14050412-9001',
paidAt: isoFromNowHours(-72),
failureReason: null,
batch: {
id: 7001,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(7),
status: 'completed',
totalAmount: '4250000',
payoutCount: 1,
processedAt: isoFromNowHours(-72),
},
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
},
9002: {
// Σ booking links (2,750,000 + 2,250,000) = 5,000,000 gross 750,000 clawback = 4,250,000 net/amount
id: 9002,
batchId: 7000,
status: 'paid',
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '750000',
netAmountIrr: '4250000',
amountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'SATNA-14050405-9002',
paidAt: isoFromNowHours(-240),
failureReason: null,
batch: {
id: 7000,
periodStart: isoDateDaysAgo(21),
periodEnd: isoDateDaysAgo(15),
processingDate: isoDateDaysAgo(14),
status: 'completed',
totalAmount: '4250000',
payoutCount: 1,
processedAt: isoFromNowHours(-240),
},
bookings: [
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
],
},
9003: {
id: 9003,
batchId: 7002,
status: 'failed',
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
amountIrr: '3400000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
failureReason: 'invalid_sheba',
batch: {
id: 7002,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(7),
status: 'partially_failed',
totalAmount: '3400000',
payoutCount: 1,
processedAt: isoFromNowHours(-168),
},
bookings: [{ bookingId: 5005, sessionId: 1, payoutAmountIrr: '3400000' }],
},
9004: {
id: 9004,
batchId: 7003,
status: 'submitted',
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
amountIrr: '2000000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
failureReason: null,
batch: {
id: 7003,
periodStart: isoDateDaysAgo(7),
periodEnd: isoDateDaysAgo(1),
processingDate: isoDateDaysAgo(0),
status: 'processing',
totalAmount: '2000000',
payoutCount: 1,
processedAt: null,
},
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '2000000' }],
},
};
/**
* The ledger-derived summary. `paid` is a **lifetime** total; the **net payable balance** is
* `pending + eligible clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
* BigInt and **not clamped** — under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
* the net balance (it already left the ledger).
*/
function buildSummary(): NurseEarningsSummary {
const pending = BigInt(4_250_000);
const eligible = BigInt(3_400_000);
const paid = BigInt(8_500_000); // 9001 (4,250,000) + 9002 net (4,250,000)
const clawbackOutstanding = MOCK_SCENARIO === 'clawback_heavy' ? BigInt(12_000_000) : BigInt(1_700_000);
const net = pending + eligible - clawbackOutstanding;
return {
pendingTotalIrr: String(pending),
eligibleTotalIrr: String(eligible),
paidTotalIrr: String(paid),
clawbackOutstandingIrr: String(clawbackOutstanding),
netPayableBalanceIrr: String(net),
};
}
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);
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
/** Small artificial latency so loading skeletons are observable in dev. */
const LATENCY_MS = 250;
function delay<T>(value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS));
}
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
export const payoutsMockApi: PayoutsApi = {
getNurseEarningsBalance: async () => delay(buildSummary()),
getNurseEarnings: async (params: EarningsListParams) => {
const filtered = params.state ? EARNINGS.filter((e) => e.state === params.state) : [...EARNINGS];
filtered.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.bookingId - a.bookingId);
return delay(paginate(filtered, params));
},
getNursePayoutHistory: async (params: PageParams) => delay(paginate([...HISTORY], params)),
getNursePayoutDetail: async (payoutId: number) => {
const detail = DETAILS[payoutId];
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
return delay(detail);
},
};