ui phase 7

This commit is contained in:
hamid
2026-07-19 11:56:59 +03:30
parent a438edeeaa
commit edc38543fd
39 changed files with 1538 additions and 208 deletions
@@ -132,6 +132,11 @@ function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestLi
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
paymentDeadlineAt: dto.paymentDeadlineAt,
customerNotes: role === 'nurse' ? dto.customerNotes : null,
// ui-phase-7 (REQ-050): the nurse inbox is decision-first — service + price on the row itself. The
// real list DTO doesn't carry these yet; only the mock stamps them (the detail DTO always has them).
variantLabel: dto.variantLabel,
variantPrice: dto.variantPrice,
variantPriceUnit: dto.variantPriceUnit,
};
}
@@ -0,0 +1,18 @@
const MINUTES_PER_HOUR = 60;
/**
* Humanized minutes-remaining copy above `CountdownTimer`'s coarse threshold («حدود ۳ ساعت» / «حدود ۲۵
* دقیقه»). Shared by the customer C5 response countdown and the nurse inbox's urgency-tinted countdown
* pill (ui-phase-7) — both count down the same `nurseResponseDeadlineAt`, so the humanized framing must
* read identically in both places. `t` is the `booking` namespace translator (`countdown_about_hours`/
* `countdown_about_minutes`).
*/
export function coarseResponseLabel(
minutes: number,
t: (key: string, values?: Record<string, number>) => string,
): string {
if (minutes >= MINUTES_PER_HOUR) {
return t('countdown_about_hours', { hours: Math.round(minutes / MINUTES_PER_HOUR) });
}
return t('countdown_about_minutes', { minutes });
}
@@ -13,15 +13,21 @@ import type { BookingRequestStatus } from '../types';
/**
* The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly
* polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this
* list so an actioned request leaves the pending inbox immediately.
* list so an actioned request leaves the pending inbox immediately. `options.enabled` (default `true`)
* lets a multi-tab inbox (ui-phase-7) mount every status query up front without polling tabs the nurse
* isn't currently viewing.
*/
export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) {
export function useNurseRequestInbox(
status: BookingRequestStatus | undefined = 'pending_nurse_response',
page = 1,
options?: { enabled?: boolean },
) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.nurseInbox(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
enabled: isAuthenticated && (options?.enabled ?? true),
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: BOOKING_REQUEST_POLL_MS,
@@ -129,6 +129,14 @@ export interface BookingRequestListItem {
paymentDeadlineAt: string | null;
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
customerNotes: string | null;
/**
* Client-augmented (ui-phase-7, REQ-050) — the list DTO carries no variant fields today. `undefined` on
* the real path until the field lands; the nurse-inbox card renders the decision-first headline when
* present and degrades to the patient-name headline otherwise (mock-tolerant, never fetched per-row).
*/
variantLabel?: string | null;
variantPrice?: string | null;
variantPriceUnit?: PriceUnit | null;
}
/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */
+24 -2
View File
@@ -21,6 +21,7 @@ import type {
TodaySessionsParams,
VisitVerificationDto,
} from '../types';
import { isBookingConfirmedOrBeyond } from '../types';
const MOCK_LATENCY_MS = 350;
@@ -37,6 +38,16 @@ function isoDate(daysFromToday: number): string {
return d.toISOString().slice(0, 10);
}
/** Best-effort read of the frozen variant display name from a booking's variant snapshot (mock-only). */
function variantDisplayName(snapshotJson: string): string | null {
try {
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
return parsed?.displayName ?? null;
} catch {
return null;
}
}
/** Haversine distance in metres — the mock stand-in for the server's address-match math. */
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6_371_000;
@@ -80,6 +91,10 @@ function seed(): void {
district: 'سعادت‌آباد',
line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766',
// Matches the EVV mock's reference point (MOCK_EVV_REFERENCE_LAT/LNG) so the address-card map link and
// the in-range check-in demo point at the same spot (ui-phase-7 — REQ-051's map deep-link).
latitude: MOCK_EVV_REFERENCE_LAT,
longitude: MOCK_EVV_REFERENCE_LNG,
});
const addr5002 = JSON.stringify({
title: 'آپارتمان',
@@ -352,10 +367,15 @@ function findSession(sessionId: number): { booking: BookingDetailDto; session: B
throw new ApiError(404, 'Session not found', 'not_found');
}
/** The nurse view omits the full address snapshot (two-stage disclosure — coarse context only). */
/**
* The nurse view gets the full address snapshot only once the booking is `confirmed`+ (ui-phase-7,
* REQ-051 — a deliberate b9 contract change the mock simulates ahead of the real endpoint; the real
* `clientApi` still masks it until the backend delivers the request, so the nurse UI stays mock-tolerant).
* Pre-confirmation there is no address to leak anyway (two-stage disclosure — coarse context only).
*/
function forViewer(b: BookingDetailDto, viewerRole: BookingViewerRole | undefined): BookingDetailDto {
const clone = cloneBooking(b);
if (viewerRole === 'nurse') clone.addressSnapshotJson = null;
if (viewerRole === 'nurse' && !isBookingConfirmedOrBeyond(b.status)) clone.addressSnapshotJson = null;
return clone;
}
@@ -430,6 +450,8 @@ export const bookingsMockApi: BookingsApi = {
scheduledTimeEnd: session.scheduledTimeEnd,
status: session.status,
evvStatus: session.evvStatus,
// ui-phase-7 (REQ-052): the real `booking_sessions/today` row carries no service field yet.
variantLabel: variantDisplayName(booking.variantSnapshotJson),
});
}
}
@@ -27,6 +27,13 @@ export const BOOKING_LIST_STALE_TIME = 30 * 1000;
/** A nurse's "today" feed changes as they clock in/out — kept fresh, invalidated on every EVV mutation. */
export const TODAY_SESSIONS_STALE_TIME = 15 * 1000;
/**
* A same-day schedule change (a new request converts, an admin reschedules) otherwise never appears on
* the day surface without a manual re-navigation — the EVV-mutation invalidation only covers the nurse's
* own check-in/out. A modest poll closes that gap without hammering the endpoint (ui-phase-7 §3.2).
*/
export const TODAY_SESSIONS_REFETCH_MS = 60 * 1000;
/** Per-session EVV detail is immutable once completed; a short window covers the checked-in interval. */
export const SESSION_EVV_STALE_TIME = 15 * 1000;
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { bookingsApi } from '../apis';
import { bookingKeys } from '../keys';
import { BOOKINGS_PAGE_SIZE, TODAY_SESSIONS_STALE_TIME } from '../constants';
import { BOOKINGS_PAGE_SIZE, TODAY_SESSIONS_REFETCH_MS, TODAY_SESSIONS_STALE_TIME } from '../constants';
import type { TodaySessionsParams } from '../types';
/**
@@ -19,5 +19,6 @@ export function useTodaySessions(options?: { date?: string; page?: number; pageS
queryKey: bookingKeys.today(params),
queryFn: () => bookingsApi.listTodaySessions(params),
staleTime: TODAY_SESSIONS_STALE_TIME,
refetchInterval: TODAY_SESSIONS_REFETCH_MS,
});
}
+7
View File
@@ -180,6 +180,13 @@ export interface BookingSessionListItemDto {
scheduledTimeEnd: string;
status: BookingSessionStatus;
evvStatus: VisitVerificationStatus;
/**
* Client-augmented (ui-phase-7, REQ-052) — `BookingSessionListItemDto` carries no service/variant field
* today. `undefined` on the real path until the field lands; the day surface renders it when present and
* degrades to patient name + visit index otherwise (mock-tolerant, never fetched per-row — that would be
* an N+1 against the booking detail).
*/
variantLabel?: string | null;
}
/**
@@ -296,6 +296,11 @@ const DETAILS: Record<number, NursePayoutDetail> = {
* `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).
*
* `nextPayoutDate`/`nextPayoutEligibleAmountIrr` (ui-phase-7, REQ-053) stand in for the server-computed
* «برداشت بعدی» forecast the nurse read doesn't serve yet: the mock picks a plausible next-batch date
* (3 days out — real holiday shifting is backend truth, not modelled here) and the currently-`eligible`
* bucket as the amount that batch would pay.
*/
function buildSummary(): NurseEarningsSummary {
const pending = BigInt(4_250_000);
@@ -309,6 +314,8 @@ function buildSummary(): NurseEarningsSummary {
paidTotalIrr: String(paid),
clawbackOutstandingIrr: String(clawbackOutstanding),
netPayableBalanceIrr: String(net),
nextPayoutDate: new Date(Date.now() + 3 * DAY_MS).toISOString().slice(0, 10),
nextPayoutEligibleAmountIrr: String(eligible),
};
}
@@ -0,0 +1,13 @@
/**
* Bank-rail `failureReason` codes the UI has a mapped Persian/English label for (i18n keys
* `payouts.failure_code_{code}`). A `failed` payout must never show the raw vendor string as its
* headline — known codes get the mapped label; anything else falls back to a generic message with the
* raw code demoted to a secondary `dir="ltr"` caption (never hidden — it's still useful for support).
*/
const KNOWN_FAILURE_REASON_CODES = new Set(['invalid_sheba']);
/** The `payouts` namespace i18n key for a payout's `failureReason` — unknown/`null` codes fall back. */
export function failureReasonLabelKey(code: string | null): string {
if (code && KNOWN_FAILURE_REASON_CODES.has(code)) return `failure_code_${code}`;
return 'failure_code_unknown';
}
+9
View File
@@ -77,6 +77,15 @@ export interface NurseEarningsSummary {
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
*/
netPayableBalanceIrr: string;
/**
* Client-augmented (ui-phase-7, REQ-053) — the «برداشت بعدی» forecast: the next weekly batch date
* (holiday-shifted server-side) and the amount expected to be eligible in it. Both `undefined`/`null` on
* the real path until the earnings read serves them; the dashboard/earnings forecast line renders only
* when both are present — **never computed client-side** (holiday shifting + eligibility are backend
* truth).
*/
nextPayoutDate?: string | null;
nextPayoutEligibleAmountIrr?: string | null;
}
/** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */