ui phase 7
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user