frontend phase 15
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
|
||||
import { ADMIN_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminApi,
|
||||
AdminRole,
|
||||
AuditFilters,
|
||||
AuditLogEntry,
|
||||
ConfigChange,
|
||||
Holiday,
|
||||
HolidayFilters,
|
||||
HolidayInput,
|
||||
PlatformConfig,
|
||||
RoleGrant,
|
||||
SupportAlert,
|
||||
SupportAlertFilters,
|
||||
} from '../types';
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
/** Build a `page`/`page_size` query (snake_case per b1 api-conventions). */
|
||||
function pageQuery(params: PageParams, extra?: Record<string, string | undefined>): string {
|
||||
const q = new URLSearchParams();
|
||||
q.set('page', String(params.page ?? 1));
|
||||
q.set('page_size', String(params.pageSize ?? ADMIN_PAGE_SIZE));
|
||||
for (const [k, v] of Object.entries(extra ?? {})) if (v != null && v !== '') q.set(k, v);
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
/** Parse the b1 `changedFieldsJson` (`{ "Field": { "old": …, "new": … } }`) into a typed record. */
|
||||
function parseChangedFields(json: string | null): AuditLogEntry['changedFields'] {
|
||||
if (!json) return null;
|
||||
try {
|
||||
return JSON.parse(json) as AuditLogEntry['changedFields'];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse a config change's `changedFieldsJson` into the single value delta the drawer shows. */
|
||||
function parseValueDelta(json: string | null): { oldValue: string | null; newValue: string | null } {
|
||||
const parsed = parseChangedFields(json);
|
||||
const field = parsed && (parsed['Value'] ?? Object.values(parsed)[0]);
|
||||
const toStr = (v: unknown): string | null => (v == null ? null : String(v));
|
||||
return { oldValue: toStr(field?.old), newValue: toStr(field?.new) };
|
||||
}
|
||||
|
||||
interface ConfigChangeWire {
|
||||
id: number;
|
||||
action: ConfigChange['action'];
|
||||
changedFieldsJson: string | null;
|
||||
actorUserId: number | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
interface AuditWire {
|
||||
id: number;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
changedFieldsJson: string | null;
|
||||
actorUserId: number | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `AdminApi` seam (b1 config/holiday/audit/support-alert routes + the b15
|
||||
* RBAC routes). **Not primary this phase** (`USE_ADMIN_MOCK = true`) — the config audit columns and rich
|
||||
* audit filters aren't on the wire (REQ-029/030) and the RBAC routes don't exist yet (REQ-031). When each
|
||||
* upstream lands, flip the seam in `apis/index.ts`; the hooks/screens are unchanged.
|
||||
*/
|
||||
export const adminClientApi: AdminApi = {
|
||||
listConfigs: async (params) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<Paginated<PlatformConfig>>>(`${API}/platform_config/get_platform_configs?${pageQuery(params)}`)),
|
||||
|
||||
updateConfig: async (key, value) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/platform_config/update_platform_config`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
},
|
||||
|
||||
getConfigHistory: async (key, params) => {
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<ConfigChangeWire>>>(
|
||||
`${API}/platform_config/get_config_change_history?${pageQuery(params, { key })}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...wire,
|
||||
items: wire.items.map((w): ConfigChange => ({
|
||||
id: w.id,
|
||||
action: w.action,
|
||||
actorUserId: w.actorUserId,
|
||||
occurredAt: w.occurredAt,
|
||||
...parseValueDelta(w.changedFieldsJson),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
listHolidays: async (filters: HolidayFilters, params) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<Holiday>>>(
|
||||
`${API}/holidays/get_holidays?${pageQuery(params, { from: filters.from, to: filters.to })}`,
|
||||
),
|
||||
),
|
||||
|
||||
upsertHoliday: async (input: HolidayInput) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/holidays/upsert_holiday`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
|
||||
listAuditLogs: async (filters: AuditFilters, params) => {
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<AuditWire>>>(
|
||||
`${API}/audit/get_audit_trail?${pageQuery(params, { entity_type: filters.entityType, entity_id: filters.entityId })}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...wire,
|
||||
items: wire.items.map((w): AuditLogEntry => ({
|
||||
id: w.id,
|
||||
entityType: w.entityType,
|
||||
entityId: w.entityId,
|
||||
action: w.action,
|
||||
actorUserId: w.actorUserId,
|
||||
occurredAt: w.occurredAt,
|
||||
changedFields: parseChangedFields(w.changedFieldsJson),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
listSupportAlerts: async (filters: SupportAlertFilters, params) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<SupportAlert>>>(
|
||||
`${API}/support_alerts/get_support_alerts?${pageQuery(params, {
|
||||
type: filters.type,
|
||||
status: filters.status,
|
||||
owner_user_id: filters.ownerUserId != null ? String(filters.ownerUserId) : undefined,
|
||||
})}`,
|
||||
),
|
||||
),
|
||||
|
||||
assignSupportAlert: async (alertId, ownerUserId) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/assign_support_alert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ alertId, ownerUserId }),
|
||||
});
|
||||
},
|
||||
|
||||
resolveSupportAlert: async (alertId, note) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/resolve_support_alert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ alertId, note }),
|
||||
});
|
||||
},
|
||||
|
||||
// RBAC (REQ-031 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship.
|
||||
listRoles: async (userId?: number) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RoleGrant[]>>(
|
||||
`${API}/admin_roles/list_roles${userId != null ? `?user_id=${userId}` : ''}`,
|
||||
),
|
||||
),
|
||||
grantRole: async (userId, role: AdminRole) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/grant_role`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, role }),
|
||||
});
|
||||
},
|
||||
revokeRole: async (userId, role: AdminRole) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/revoke_role`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, role }),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_ADMIN_MOCK } from '../constants';
|
||||
import type { AdminApi } from '../types';
|
||||
import { adminClientApi } from './clientApi';
|
||||
import { adminMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `AdminApi` implementation — the single seam the hooks import. Mock-primary this phase
|
||||
* (REQ-029/030/031); flipping to the real client is this one line once the upstream endpoints/columns land.
|
||||
*/
|
||||
export const adminApi: AdminApi = USE_ADMIN_MOCK ? adminMockApi : adminClientApi;
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import type {
|
||||
AdminApi,
|
||||
AdminRole,
|
||||
AuditFilters,
|
||||
AuditLogEntry,
|
||||
ConfigChange,
|
||||
Holiday,
|
||||
HolidayFilters,
|
||||
HolidayInput,
|
||||
PlatformConfig,
|
||||
RoleGrant,
|
||||
SupportAlert,
|
||||
SupportAlertFilters,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* In-memory `AdminApi` — **the primary implementation this phase** (REQ-029/030/031: config audit
|
||||
* columns, rich audit filters, and the whole RBAC surface are gaps). The fixtures are engineered to
|
||||
* exercise every console state: one config **per `dataType`** (so all typed inputs + the 0–1 rate
|
||||
* validation are reachable), a config change-history trail, holidays with **bank-closed** days, a paged
|
||||
* audit log with `changedFields` diffs, a support-alert list spanning **every** alert type/status (so the
|
||||
* worklist filters are testable), and RBAC grants. Mutations mutate the in-memory arrays so a save shows
|
||||
* on the next read. Timestamps are relative to now so Shamsi rendering always reads sensibly.
|
||||
*/
|
||||
|
||||
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 dateDaysAhead = (d: number): string => new Date(Date.now() + d * DAY_MS).toISOString().slice(0, 10);
|
||||
|
||||
const LATENCY_MS = 200;
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── Config (one row per data_type) ─────────────────────────────────────────────────────────────────────
|
||||
const CONFIGS: PlatformConfig[] = [
|
||||
{ key: 'platform_fee_rate', value: '0.15', dataType: 'decimal', description: 'Platform commission fraction of gross.', updatedAt: isoDaysAgo(30), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'vat_rate', value: '0.10', dataType: 'decimal', description: 'VAT applied to the platform commission line only.', updatedAt: isoDaysAgo(90), updatedBy: 'finance@balinyaar' },
|
||||
{ key: 'dispute_window_hours', value: '72', dataType: 'int', description: 'Hours after check-out before a payout becomes eligible.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'nurse_payout_interval_days', value: '7', dataType: 'int', description: 'Payout batch cadence in days.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'evv_location_tolerance_meters', value: '150', dataType: 'int', description: 'Advisory EVV geofence radius in meters.', updatedAt: isoDaysAgo(45), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'payout_satna_threshold_irr', value: '150000000', dataType: 'int', description: 'Above this amount payouts use SATNA instead of PAYA.', updatedAt: isoDaysAgo(60), updatedBy: 'finance@balinyaar' },
|
||||
{ key: 'min_rating_for_support_alert', value: '2', dataType: 'int', description: 'Reviews at or below this rating raise a low-rating alert.', updatedAt: isoDaysAgo(200), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'bnpl_provider_enabled', value: 'true', dataType: 'bool', description: 'Whether the BNPL checkout branch is offered.', updatedAt: isoDaysAgo(15), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'cancellation_policy_tiers', value: '{"tier1":1,"tier2":0.5,"tier3":0}', dataType: 'json', description: 'Refund fraction per cancellation lead-time tier.', updatedAt: isoDaysAgo(75), updatedBy: 'admin@balinyaar' },
|
||||
{ key: 'support_contact_line', value: 'پشتیبانی بالینیار', dataType: 'string', description: 'Display name used in support messages.', updatedAt: isoDaysAgo(10), updatedBy: 'support@balinyaar' },
|
||||
];
|
||||
|
||||
const CONFIG_HISTORY: Record<string, ConfigChange[]> = {
|
||||
vat_rate: [
|
||||
{ id: 301, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(90), oldValue: '0.09', newValue: '0.10' },
|
||||
{ id: 300, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(365), oldValue: '0.08', newValue: '0.09' },
|
||||
],
|
||||
platform_fee_rate: [
|
||||
{ id: 310, action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), oldValue: '0.18', newValue: '0.15' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Holidays ───────────────────────────────────────────────────────────────────────────────────────────
|
||||
const HOLIDAYS: Holiday[] = [
|
||||
{ id: 501, holidayDate: dateDaysAhead(3), nameFa: 'عید فطر', type: 'religious', isBankClosed: true },
|
||||
{ id: 502, holidayDate: dateDaysAgo(2), nameFa: 'رحلت امام', type: 'religious', isBankClosed: true },
|
||||
{ id: 503, holidayDate: dateDaysAgo(20), nameFa: 'روز جمهوری اسلامی', type: 'national', isBankClosed: true },
|
||||
{ id: 504, holidayDate: dateDaysAhead(30), nameFa: 'تعطیلی اداری', type: 'official', isBankClosed: false },
|
||||
];
|
||||
|
||||
// ── Audit log ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
const AUDIT: AuditLogEntry[] = [
|
||||
{ id: 901, entityType: 'PlatformConfig', entityId: 'platform_fee_rate', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), changedFields: { Value: { old: '0.18', new: '0.15' } } },
|
||||
{ id: 902, entityType: 'Refund', entityId: '7', action: 'created', actorUserId: 4, occurredAt: isoDaysAgo(4), changedFields: { Status: { old: null, new: 'succeeded' }, Amount: { old: null, new: '10000000' } } },
|
||||
{ id: 903, entityType: 'NurseVerification', entityId: '15', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(6), changedFields: { Status: { old: 'in_review', new: 'approved' }, IsVerified: { old: false, new: true } } },
|
||||
{ id: 904, entityType: 'PayoutBatch', entityId: '7003', action: 'created', actorUserId: 5, occurredAt: isoDaysAgo(1), changedFields: { Status: { old: null, new: 'processing' }, PayoutCount: { old: null, new: 12 } } },
|
||||
{ id: 905, entityType: 'Review', entityId: '44', action: 'updated', actorUserId: 6, occurredAt: isoDaysAgo(2), changedFields: { ModerationStatus: { old: 'pending_moderation', new: 'published' } } },
|
||||
{ id: 906, entityType: 'PartnerCenter', entityId: '1', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(8), changedFields: { SettlementIban: { old: '<redacted>', new: '<redacted>' }, IsActive: { old: false, new: true } } },
|
||||
];
|
||||
|
||||
// ── Support alerts (one of every type; all statuses) ────────────────────────────────────────────────────
|
||||
const ALERTS: SupportAlert[] = [
|
||||
{ id: 801, type: 'low_rating', severity: 'medium', status: 'open', entityType: 'Review', entityId: '44', bookingId: 5001, reviewId: 44, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(1) },
|
||||
{ id: 802, type: 'evv_no_show', severity: 'high', status: 'open', entityType: 'Booking', entityId: '5002', bookingId: 5002, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(2) },
|
||||
{ id: 803, type: 'evv_location_mismatch', severity: 'medium', status: 'assigned', entityType: 'Booking', entityId: '5003', bookingId: 5003, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(3) },
|
||||
{ id: 804, type: 'verification_expired', severity: 'high', status: 'open', entityType: 'NurseVerification', entityId: '15', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(4) },
|
||||
{ id: 805, type: 'shared_sim', severity: 'high', status: 'assigned', entityType: 'NurseVerification', entityId: '16', bookingId: null, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(5) },
|
||||
{ id: 806, type: 'payment_anomaly', severity: 'high', status: 'open', entityType: 'PaymentTransaction', entityId: '3001', bookingId: 5004, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(6) },
|
||||
{ id: 807, type: 'fraud_signal', severity: 'high', status: 'open', entityType: 'User', entityId: '7099', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(7) },
|
||||
{ id: 808, type: 'nurse_clawback', severity: 'medium', status: 'resolved', entityType: 'NurseClawback', entityId: '210', bookingId: 5004, reviewId: null, ownerUserId: 4, resolutionNote: 'Netted in the next payout batch.', resolvedAt: isoDaysAgo(1), createdAt: isoDaysAgo(9) },
|
||||
{ id: 809, type: 'emergency', severity: 'high', status: 'resolved', entityType: 'Booking', entityId: '5001', bookingId: 5001, reviewId: null, ownerUserId: 3, resolutionNote: 'Called 115; patient stable.', resolvedAt: isoDaysAgo(2), createdAt: isoDaysAgo(10) },
|
||||
];
|
||||
|
||||
// ── RBAC grants ──────────────────────────────────────────────────────────────────────────────────────
|
||||
const ROLES: RoleGrant[] = [
|
||||
{ userId: 2, role: 'admin', grantedBy: 1, grantedAt: isoDaysAgo(400), revokedAt: null },
|
||||
{ userId: 3, role: 'support', grantedBy: 2, grantedAt: isoDaysAgo(200), revokedAt: null },
|
||||
{ userId: 4, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(180), revokedAt: null },
|
||||
{ userId: 5, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(90), revokedAt: null },
|
||||
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
|
||||
];
|
||||
|
||||
export const adminMockApi: AdminApi = {
|
||||
listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
|
||||
|
||||
updateConfig: async (key, value) => {
|
||||
const row = CONFIGS.find((c) => c.key === key);
|
||||
if (!row) throw new Error(`Mock config ${key} not found`);
|
||||
const old = row.value;
|
||||
row.value = value;
|
||||
row.updatedAt = new Date().toISOString();
|
||||
row.updatedBy = 'you@balinyaar';
|
||||
(CONFIG_HISTORY[key] ??= []).unshift({
|
||||
id: Math.floor(1000 + (CONFIG_HISTORY[key]?.length ?? 0)),
|
||||
action: 'updated',
|
||||
actorUserId: 1,
|
||||
occurredAt: row.updatedAt,
|
||||
oldValue: old,
|
||||
newValue: value,
|
||||
});
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
getConfigHistory: async (key, params) => delay(paginate([...(CONFIG_HISTORY[key] ?? [])], params)),
|
||||
|
||||
listHolidays: async (filters, params) => {
|
||||
let items = [...HOLIDAYS];
|
||||
if (filters.from) items = items.filter((h) => h.holidayDate >= filters.from!);
|
||||
if (filters.to) items = items.filter((h) => h.holidayDate <= filters.to!);
|
||||
items.sort((a, b) => a.holidayDate.localeCompare(b.holidayDate));
|
||||
return delay(paginate(items, params));
|
||||
},
|
||||
|
||||
upsertHoliday: async (input) => {
|
||||
const existing = HOLIDAYS.find((h) => h.holidayDate === input.holidayDate);
|
||||
if (existing) Object.assign(existing, input);
|
||||
else HOLIDAYS.push({ id: Math.max(0, ...HOLIDAYS.map((h) => h.id)) + 1, ...input });
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
listAuditLogs: async (filters, params) => {
|
||||
let items = [...AUDIT];
|
||||
if (filters.entityType) items = items.filter((a) => a.entityType.toLowerCase().includes(filters.entityType!.toLowerCase()));
|
||||
if (filters.entityId) items = items.filter((a) => a.entityId === filters.entityId);
|
||||
if (filters.actorUserId != null) items = items.filter((a) => a.actorUserId === filters.actorUserId);
|
||||
if (filters.action) items = items.filter((a) => a.action === filters.action);
|
||||
if (filters.from) items = items.filter((a) => a.occurredAt >= filters.from!);
|
||||
if (filters.to) items = items.filter((a) => a.occurredAt <= filters.to!);
|
||||
items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
return delay(paginate(items, params));
|
||||
},
|
||||
|
||||
listSupportAlerts: async (filters, params) => {
|
||||
let items = [...ALERTS];
|
||||
if (filters.type) items = items.filter((a) => a.type === filters.type);
|
||||
if (filters.status) items = items.filter((a) => a.status === filters.status);
|
||||
if (filters.ownerUserId != null) items = items.filter((a) => a.ownerUserId === filters.ownerUserId);
|
||||
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
return delay(paginate(items, params));
|
||||
},
|
||||
|
||||
assignSupportAlert: async (alertId, ownerUserId) => {
|
||||
const a = ALERTS.find((x) => x.id === alertId);
|
||||
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not assignable`);
|
||||
a.status = 'assigned';
|
||||
a.ownerUserId = ownerUserId;
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
resolveSupportAlert: async (alertId, note) => {
|
||||
const a = ALERTS.find((x) => x.id === alertId);
|
||||
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not resolvable`);
|
||||
a.status = 'resolved';
|
||||
a.resolutionNote = note;
|
||||
a.resolvedAt = new Date().toISOString();
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
listRoles: async (userId) => delay(userId == null ? [...ROLES] : ROLES.filter((r) => r.userId === userId)),
|
||||
|
||||
grantRole: async (userId, role) => {
|
||||
const existing = ROLES.find((r) => r.userId === userId && r.role === role);
|
||||
if (existing) existing.revokedAt = null;
|
||||
else ROLES.push({ userId, role, grantedBy: 1, grantedAt: new Date().toISOString(), revokedAt: null });
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
revokeRole: async (userId, role) => {
|
||||
const r = ROLES.find((x) => x.userId === userId && x.role === role && !x.revokedAt);
|
||||
if (r) r.revokedAt = new Date().toISOString();
|
||||
return delay(undefined);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* When true, the admin domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `AdminApi`
|
||||
* seam. **Mock is primary this phase:** the b1 config/holiday/audit/support-alert endpoints are live and
|
||||
* the real `clientApi.ts` maps them 1:1, but (a) config `updatedAt/updatedBy` and the actor/action/date
|
||||
* audit filters aren't on the wire yet (REQ-029/030) and (b) the RBAC role endpoints don't exist in the
|
||||
* b15 contract at all (REQ-031). The mock supplies a realistic, filter-exercising world for every console;
|
||||
* flip to `false` per area once each upstream is complete — the swap is the one line in `apis/index.ts`.
|
||||
*/
|
||||
export const USE_ADMIN_MOCK = true;
|
||||
|
||||
/** Worklist page sizes (api-conventions `pageSize`, default 50 / max 100). */
|
||||
export const ADMIN_PAGE_SIZE = 20;
|
||||
export const AUDIT_PAGE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* Config + holidays are near-static reference data — a long `staleTime` avoids refetching on revisit; a
|
||||
* mutation invalidates the relevant key so the change shows immediately. Audit + support-alerts move at
|
||||
* ops speed (moderate staleness). All are gc'd after a few minutes off-screen.
|
||||
*/
|
||||
export const ADMIN_CONFIG_STALE_TIME = 5 * 60 * 1000;
|
||||
export const ADMIN_HOLIDAYS_STALE_TIME = 5 * 60 * 1000;
|
||||
export const ADMIN_AUDIT_STALE_TIME = 30 * 1000;
|
||||
export const ADMIN_ALERTS_STALE_TIME = 20 * 1000;
|
||||
export const ADMIN_GC_TIME = 5 * 60 * 1000;
|
||||
|
||||
/** Config keys that are **rates** and must validate to the closed-open interval [0, 1). */
|
||||
export const RATE_CONFIG_KEYS: readonly string[] = [
|
||||
'platform_fee_rate',
|
||||
'vat_rate',
|
||||
];
|
||||
|
||||
/** Grouping of config keys into UI sections (any key not listed falls into "other"). */
|
||||
export const CONFIG_GROUPS: Record<string, readonly string[]> = {
|
||||
fees: ['platform_fee_rate', 'vat_rate'],
|
||||
deadlines: ['dispute_window_hours', 'nurse_payout_interval_days', 'payout_satna_threshold_irr', 'booking_request_response_deadline_minutes', 'payment_window_minutes'],
|
||||
evv: ['evv_location_tolerance_meters'],
|
||||
bnpl: ['bnpl_provider_enabled', 'bnpl_min_amount_irr'],
|
||||
cancellation: ['cancellation_tier1_refund_rate', 'cancellation_tier2_refund_rate', 'cancellation_tier3_refund_rate'],
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME } from '../constants';
|
||||
|
||||
/** The RBAC role grants (all, or scoped to one user). Deferred-if-missing — mock-primary (REQ-031). */
|
||||
export function useAdminRoles(userId?: number) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.roleList(userId),
|
||||
queryFn: () => adminApi.listRoles(userId),
|
||||
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
|
||||
/** Assign an alert to an owner (open → assigned). Invalidate the alert worklist on success. */
|
||||
export function useAssignSupportAlert() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { alertId: number; ownerUserId: number }>({
|
||||
mutationFn: ({ alertId, ownerUserId }) => adminApi.assignSupportAlert(alertId, ownerUserId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_AUDIT_STALE_TIME, ADMIN_GC_TIME, AUDIT_PAGE_SIZE } from '../constants';
|
||||
import type { AuditFilters } from '../types';
|
||||
|
||||
/** The append-only audit trail (read-only), filtered + paginated. Filters + page key the cache. */
|
||||
export function useAuditLogs(filters: AuditFilters, page = 1) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.auditList(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
|
||||
queryFn: () => adminApi.listAuditLogs(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
|
||||
staleTime: ADMIN_AUDIT_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||
|
||||
/**
|
||||
* The audited change history for one config key (newest first) — finance proves the rate in effect at any
|
||||
* past moment. `enabled` gates the fetch to when the drawer is open (a closed drawer never fetches).
|
||||
*/
|
||||
export function useConfigChangeHistory(key: string | null, page = 1, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.configHistory(key ?? '', { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
queryFn: () => adminApi.getConfigHistory(key!, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
enabled: enabled && !!key,
|
||||
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import type { AdminRole } from '../types';
|
||||
|
||||
/** Grant an admin role to a user (records `grantedBy`/`grantedAt`). Invalidate the roles list. */
|
||||
export function useGrantRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
|
||||
mutationFn: ({ userId, role }) => adminApi.grantRole(userId, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_GC_TIME, ADMIN_HOLIDAYS_STALE_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||
import type { HolidayFilters } from '../types';
|
||||
|
||||
/** The `iranian_holidays` calendar for a date range. The filter object keys the cache separately. */
|
||||
export function useHolidays(filters: HolidayFilters, page = 1) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.holidayList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
queryFn: () => adminApi.listHolidays(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
staleTime: ADMIN_HOLIDAYS_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||
|
||||
/** All `platform_configs` rows (paginated). Near-static reference data → long `staleTime`. */
|
||||
export function usePlatformConfigs(page = 1) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.configList({ page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
queryFn: () => adminApi.listConfigs({ page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
|
||||
/** Resolve an alert with a note (→ resolved). Invalidate the alert worklist on success. */
|
||||
export function useResolveSupportAlert() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { alertId: number; note: string }>({
|
||||
mutationFn: ({ alertId, note }) => adminApi.resolveSupportAlert(alertId, note),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import type { AdminRole } from '../types';
|
||||
|
||||
/** Revoke an admin role from a user (sets `revokedAt`). Invalidate the roles list. */
|
||||
export function useRevokeRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
|
||||
mutationFn: ({ userId, role }) => adminApi.revokeRole(userId, role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_ALERTS_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||
import type { SupportAlertFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The internal support-alert worklist, filtered by type/status/owner + paginated. **Internal-only** — this
|
||||
* query is admin-scoped and its data never reaches a non-admin surface (phase §5). Filters + page key the cache.
|
||||
*/
|
||||
export function useSupportAlerts(filters: SupportAlertFilters, page = 1) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.supportAlertList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
queryFn: () => adminApi.listSupportAlerts(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||
staleTime: ADMIN_ALERTS_STALE_TIME,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Update one config row (audited server-side). On success invalidate the whole config sub-tree — both the
|
||||
* list and the change-history drawer — so the new value + the new history row show without a manual refresh.
|
||||
* A config change is **not** retroactive (the confirmation copy says so); the client never re-prices.
|
||||
*/
|
||||
export function useUpdatePlatformConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { key: string; value: string }>({
|
||||
mutationFn: ({ key, value }) => adminApi.updateConfig(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.config() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import type { HolidayInput } from '../types';
|
||||
|
||||
/**
|
||||
* Add or edit a holiday (upsert keyed on `holidayDate`). Invalidate the holidays sub-tree so every cached
|
||||
* range reflects the change. The client never computes the next-business-day shift — it only maintains the
|
||||
* calendar the server uses for payout scheduling (phase §5).
|
||||
*/
|
||||
export function useUpsertHoliday() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, HolidayInput>({
|
||||
mutationFn: (input) => adminApi.upsertHoliday(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: adminKeys.holidays() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Admin domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
|
||||
* types/keys/apis directly from their files when needed (e.g. `import type { PlatformConfig } from
|
||||
* '@/services/admin/types'`).
|
||||
*/
|
||||
export { usePlatformConfigs } from './hooks/usePlatformConfigs';
|
||||
export { useUpdatePlatformConfig } from './hooks/useUpdatePlatformConfig';
|
||||
export { useConfigChangeHistory } from './hooks/useConfigChangeHistory';
|
||||
export { useHolidays } from './hooks/useHolidays';
|
||||
export { useUpsertHoliday } from './hooks/useUpsertHoliday';
|
||||
export { useAuditLogs } from './hooks/useAuditLogs';
|
||||
export { useSupportAlerts } from './hooks/useSupportAlerts';
|
||||
export { useAssignSupportAlert } from './hooks/useAssignSupportAlert';
|
||||
export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
|
||||
export { useAdminRoles } from './hooks/useAdminRoles';
|
||||
export { useGrantRole } from './hooks/useGrantRole';
|
||||
export { useRevokeRole } from './hooks/useRevokeRole';
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
|
||||
* **filters + page object keys each list** so every filter/page combination caches independently — paging
|
||||
* or switching a worklist filter never refetches data already held (phase §5). Mutations invalidate the
|
||||
* relevant sub-tree (`config()`/`holidays()`/`audit()`/`supportAlerts()`/`roles()`).
|
||||
*/
|
||||
export const adminKeys = {
|
||||
all: ['admin'] as const,
|
||||
|
||||
config: () => [...adminKeys.all, 'config'] as const,
|
||||
configList: (params: PageParams) => [...adminKeys.config(), 'list', params] as const,
|
||||
configHistory: (key: string, params: PageParams) => [...adminKeys.config(), 'history', key, params] as const,
|
||||
|
||||
holidays: () => [...adminKeys.all, 'holidays'] as const,
|
||||
holidayList: (filters: HolidayFilters, params: PageParams) =>
|
||||
[...adminKeys.holidays(), 'list', filters, params] as const,
|
||||
|
||||
audit: () => [...adminKeys.all, 'audit'] as const,
|
||||
auditList: (filters: AuditFilters, params: PageParams) => [...adminKeys.audit(), 'list', filters, params] as const,
|
||||
|
||||
supportAlerts: () => [...adminKeys.all, 'supportAlerts'] as const,
|
||||
supportAlertList: (filters: SupportAlertFilters, params: PageParams) =>
|
||||
[...adminKeys.supportAlerts(), 'list', filters, params] as const,
|
||||
|
||||
roles: () => [...adminKeys.all, 'roles'] as const,
|
||||
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Admin domain — the backoffice-owned data the f15 consoles read and act on: platform config, the
|
||||
* Iranian-holiday calendar, the append-only audit trail, the internal support-alert worklist, and RBAC
|
||||
* role grants. Shapes derive from the **b1** config/reference contract
|
||||
* (`dev/contracts/domains/config-reference.md`) and the **b15** admin contract
|
||||
* (`dev/contracts/domains/messaging-notifications-admin.md`); the wire is camelCase and `clientFetch`
|
||||
* unwraps the `ApiResult<T>` envelope, so these are the post-`unwrap()` payloads.
|
||||
*
|
||||
* Load-bearing rules (phase §5):
|
||||
* - **Internal-only:** `SupportAlert`s are staff-only and must never reach a non-admin surface.
|
||||
* - **Server is the authority:** config parsing is *rendering by `dataType`* only; the client validates at
|
||||
* the boundary (a rate is 0–1) but never re-derives money, holiday shifts, or eligibility.
|
||||
* - **Append-only audit:** there is no edit/delete — the viewer is read-only.
|
||||
*
|
||||
* Enums cross the wire as stable string codes, mirrored here as string-literal unions.
|
||||
*/
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/** `platform_configs.data_type` — how to parse (and which typed input to render for) a config `value`. */
|
||||
export type ConfigDataType = 'string' | 'int' | 'decimal' | 'bool' | 'json';
|
||||
|
||||
/**
|
||||
* `PlatformConfigDto` (b1). `value` is the **raw string** — parse per `dataType`. `updatedAt`/`updatedBy`
|
||||
* are **not** on the b1 wire (REQ-029) — optional and mock-supplied until delivered; the row degrades
|
||||
* gracefully without them.
|
||||
*/
|
||||
export interface PlatformConfig {
|
||||
key: string;
|
||||
value: string;
|
||||
dataType: ConfigDataType;
|
||||
description: string | null;
|
||||
updatedAt?: string | null;
|
||||
updatedBy?: string | null;
|
||||
}
|
||||
|
||||
/** `action` on an audit/config-change row. */
|
||||
export type AuditAction = 'created' | 'updated' | 'deleted';
|
||||
|
||||
/**
|
||||
* A config change-history row, derived from `ConfigChangeDto` (b1). The wire carries `changedFieldsJson`
|
||||
* (`{ "Value": { "old": …, "new": … } }`); the client parses the value delta into `oldValue`/`newValue`
|
||||
* for the drawer so finance can prove the rate in effect at any past moment.
|
||||
*/
|
||||
export interface ConfigChange {
|
||||
id: number;
|
||||
action: AuditAction;
|
||||
actorUserId: number | null;
|
||||
occurredAt: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
// ── Holidays ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
/** `iranian_holidays.type`. */
|
||||
export type HolidayType = 'official' | 'religious' | 'national';
|
||||
|
||||
/** `HolidayDto` (b1). `isBankClosed` is what shifts payout scheduling (server-computed). */
|
||||
export interface Holiday {
|
||||
id: number;
|
||||
holidayDate: string;
|
||||
nameFa: string;
|
||||
type: HolidayType;
|
||||
isBankClosed: boolean;
|
||||
}
|
||||
|
||||
/** Upsert body (`upsert_holiday`, keyed on `holidayDate`). */
|
||||
export interface HolidayInput {
|
||||
holidayDate: string;
|
||||
nameFa: string;
|
||||
type: HolidayType;
|
||||
isBankClosed: boolean;
|
||||
}
|
||||
|
||||
/** Holiday list range. */
|
||||
export interface HolidayFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/** `AuditLogDto` (b1). `changedFields` is the parsed `changedFieldsJson` (field → {old,new}); PII redacted. */
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
actorUserId: number | null;
|
||||
occurredAt: string;
|
||||
changedFields: Record<string, { old: unknown; new: unknown }> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit filters. The b1 wire supports only `entityType`/`entityId` (REQ-030 covers actor/action/date) —
|
||||
* the extra filters are honoured by the mock and requested from the backend; the real client passes the
|
||||
* supported ones and lets the rest degrade.
|
||||
*/
|
||||
export interface AuditFilters {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
actorUserId?: number;
|
||||
action?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
// ── Support alerts ────────────────────────────────────────────────────────────────────────────────────
|
||||
/** `support_alert.type` — the broad b15 union (superset of the b1 list). */
|
||||
export type SupportAlertType =
|
||||
| 'low_rating'
|
||||
| 'evv_no_show'
|
||||
| 'evv_location_mismatch'
|
||||
| 'verification_expired'
|
||||
| 'shared_sim'
|
||||
| 'payment_anomaly'
|
||||
| 'fraud_signal'
|
||||
| 'nurse_clawback'
|
||||
| 'emergency';
|
||||
|
||||
export type SupportAlertSeverity = 'low' | 'medium' | 'high';
|
||||
export type SupportAlertStatus = 'open' | 'assigned' | 'resolved';
|
||||
|
||||
/** `SupportAlertDto` (b1/b15). **Internal-only** — never rendered outside an admin route. */
|
||||
export interface SupportAlert {
|
||||
id: number;
|
||||
type: SupportAlertType;
|
||||
severity: SupportAlertSeverity;
|
||||
status: SupportAlertStatus;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
bookingId: number | null;
|
||||
reviewId: number | null;
|
||||
ownerUserId: number | null;
|
||||
resolutionNote: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SupportAlertFilters {
|
||||
type?: SupportAlertType;
|
||||
status?: SupportAlertStatus;
|
||||
ownerUserId?: number;
|
||||
}
|
||||
|
||||
// ── RBAC (b15 — role endpoints not yet in the contract; mock-primary, REQ-031) ─────────────────────────
|
||||
/** The fine-grained admin roles the RBAC grid grants/revokes (aligned with the b2 `AdminRole` enum). */
|
||||
export type AdminRole = 'super_admin' | 'admin' | 'support' | 'finance' | 'moderation';
|
||||
|
||||
/** A role grant row (`RoleGrant`). `revokedAt` set once revoked. */
|
||||
export interface RoleGrant {
|
||||
userId: number;
|
||||
role: AdminRole;
|
||||
grantedBy: number | null;
|
||||
grantedAt: string;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
// ── The seam ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* The admin API seam — the real HTTP client and the in-memory mock both implement it; selection is by
|
||||
* config (`USE_ADMIN_MOCK`), never scattered `if (mock)` checks. Mutations return void (the wire returns
|
||||
* `true`); hooks invalidate the affected keys.
|
||||
*/
|
||||
export interface AdminApi {
|
||||
// config
|
||||
listConfigs(params: PageParams): Promise<Paginated<PlatformConfig>>;
|
||||
updateConfig(key: string, value: string): Promise<void>;
|
||||
getConfigHistory(key: string, params: PageParams): Promise<Paginated<ConfigChange>>;
|
||||
// holidays
|
||||
listHolidays(filters: HolidayFilters, params: PageParams): Promise<Paginated<Holiday>>;
|
||||
upsertHoliday(input: HolidayInput): Promise<void>;
|
||||
// audit
|
||||
listAuditLogs(filters: AuditFilters, params: PageParams): Promise<Paginated<AuditLogEntry>>;
|
||||
// support alerts
|
||||
listSupportAlerts(filters: SupportAlertFilters, params: PageParams): Promise<Paginated<SupportAlert>>;
|
||||
assignSupportAlert(alertId: number, ownerUserId: number): Promise<void>;
|
||||
resolveSupportAlert(alertId: number, note: string): Promise<void>;
|
||||
// rbac (deferred-if-missing)
|
||||
listRoles(userId?: number): Promise<RoleGrant[]>;
|
||||
grantRole(userId: number, role: AdminRole): Promise<void>;
|
||||
revokeRole(userId: number, role: AdminRole): Promise<void>;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ export function useSessionRoleSync(): void {
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } });
|
||||
dispatch({
|
||||
type: 'LOG_IN',
|
||||
// `roleCodes` preserves the server's fine-grained codes for the f15 admin-capability gate;
|
||||
// the collapsed `roles` still drive shell chrome.
|
||||
user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles), roleCodes: me.roles },
|
||||
});
|
||||
}, [me, dispatch]);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -2,17 +2,27 @@ import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminPayoutBatchDetail,
|
||||
AdminPayoutRow,
|
||||
EarningsListParams,
|
||||
EligibleNurseEarnings,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutBatchFilters,
|
||||
PayoutBatchStatus,
|
||||
PayoutBatchSummary,
|
||||
PayoutStatus,
|
||||
PayoutsApi,
|
||||
} from '../types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
||||
const ADMIN_PAYOUTS = '/api/v1/admin_payouts';
|
||||
|
||||
/** The header b13's process/retry read the per-run idempotency key from (same convention as b10/b12). */
|
||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
|
||||
/**
|
||||
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
||||
@@ -51,6 +61,91 @@ function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin batch wire DTOs (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||
|
||||
/** `PayoutBatchDto` — note it has `initiatedByAdminId` but **no** `holidayShifted` flag (REQ-036). */
|
||||
interface PayoutBatchWire {
|
||||
id: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
status: PayoutBatchStatus;
|
||||
processedAt: string | null;
|
||||
failureNotes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** `PayoutDto` — note the transferred amount is `amount`, mapped to `amountIrr`. */
|
||||
interface PayoutWire {
|
||||
id: number;
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
maskedIban: string;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
amount: string;
|
||||
status: PayoutStatus;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
failureReason: string | null;
|
||||
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
|
||||
}
|
||||
|
||||
interface PayoutBatchDetailWire {
|
||||
batch: PayoutBatchWire;
|
||||
payouts: PayoutWire[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface GeneratePayoutBatchResultWire {
|
||||
batch: PayoutBatchWire;
|
||||
}
|
||||
|
||||
function toBatchSummary(wire: PayoutBatchWire): PayoutBatchSummary {
|
||||
return {
|
||||
id: wire.id,
|
||||
periodStart: wire.periodStart,
|
||||
periodEnd: wire.periodEnd,
|
||||
processingDate: wire.processingDate,
|
||||
totalAmount: wire.totalAmount,
|
||||
payoutCount: wire.payoutCount,
|
||||
status: wire.status,
|
||||
processedAt: wire.processedAt,
|
||||
failureNotes: wire.failureNotes,
|
||||
createdAt: wire.createdAt,
|
||||
// REQ-036: PayoutBatchDto exposes no holidayShifted flag — a single preview endpoint returning
|
||||
// eligible+skipped+processingDate+holidayShifted would carry it. Defaults false until then.
|
||||
holidayShifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toPayoutRow(wire: PayoutWire): AdminPayoutRow {
|
||||
return {
|
||||
id: wire.id,
|
||||
nurseId: wire.nurseId,
|
||||
nurseName: wire.nurseName,
|
||||
maskedIban: wire.maskedIban,
|
||||
grossEarningsIrr: wire.grossEarningsIrr,
|
||||
clawbackAppliedIrr: wire.clawbackAppliedIrr,
|
||||
netAmountIrr: wire.netAmountIrr,
|
||||
amountIrr: wire.amount,
|
||||
status: wire.status,
|
||||
transferReference: wire.transferReference,
|
||||
paidAt: wire.paidAt,
|
||||
failureReason: wire.failureReason,
|
||||
bookings: wire.bookings.map((b) => ({
|
||||
bookingId: b.bookingId,
|
||||
sessionId: b.sessionId,
|
||||
payoutAmountIrr: b.payoutAmountIrr,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -90,4 +185,87 @@ export const payoutsClientApi: PayoutsApi = {
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
||||
|
||||
// ── Admin batch actions (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||
|
||||
listPayoutBatches: async (
|
||||
filters: PayoutBatchFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<PayoutBatchSummary>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.status) query.set('status', filters.status);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<PayoutBatchWire>>>(`${ADMIN_PAYOUTS}/batches?${query.toString()}`),
|
||||
);
|
||||
return { ...page, items: page.items.map(toBatchSummary) };
|
||||
},
|
||||
|
||||
previewPayoutBatch: async (periodStart: string, periodEnd: string) => {
|
||||
const query = new URLSearchParams({ periodStart, periodEnd });
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<EligibleNurseEarnings>>>(
|
||||
`${ADMIN_PAYOUTS}/eligible?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
const eligible = page.items;
|
||||
const totalNet = eligible.reduce((sum, e) => sum + BigInt(e.netAmountIrr), BigInt(0));
|
||||
// REQ-036: a single preview endpoint returning eligible + skipped + processingDate + holidayShifted in
|
||||
// one shot. The b13 `eligible` read is paged and returns only the eligible rows — no skipped list (that
|
||||
// is materialized by the generate call) and no processingDate/holidayShifted — so carry what we can:
|
||||
// processingDate falls back to periodEnd and skipped is empty until the preview route lands.
|
||||
return {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
processingDate: periodEnd,
|
||||
holidayShifted: false,
|
||||
eligible,
|
||||
skipped: [],
|
||||
totalNetIrr: String(totalNet),
|
||||
};
|
||||
},
|
||||
|
||||
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||
const result = unwrap(
|
||||
await clientFetch<ApiEnvelope<GeneratePayoutBatchResultWire>>(`${ADMIN_PAYOUTS}/batches`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
body: JSON.stringify({ periodStart, periodEnd }),
|
||||
}),
|
||||
);
|
||||
return toBatchSummary(result.batch);
|
||||
},
|
||||
|
||||
getPayoutBatchDetail: async (batchId: number, page: number): Promise<AdminPayoutBatchDetail> => {
|
||||
const query = new URLSearchParams({ page: String(page) });
|
||||
const detail = unwrap(
|
||||
await clientFetch<ApiEnvelope<PayoutBatchDetailWire>>(
|
||||
`${ADMIN_PAYOUTS}/batches/${batchId}?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
batch: toBatchSummary(detail.batch),
|
||||
payouts: detail.payouts.map(toPayoutRow),
|
||||
total: detail.total,
|
||||
page: detail.page,
|
||||
pageSize: detail.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
});
|
||||
},
|
||||
|
||||
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||
// REQ-036: b13 has `mark_failed` but no record-transfer-reference route — a manually reconciled bank
|
||||
// transfer reference has nowhere to land. Proposed action-style slug; 404s until the backend adds it.
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/transfer_reference`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reference }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { MOCK_SCENARIO } from '../constants';
|
||||
import { ADMIN_BATCH_DETAIL_PAGE_SIZE, MOCK_SCENARIO } from '../constants';
|
||||
import type {
|
||||
AdminPayoutBatchDetail,
|
||||
AdminPayoutRow,
|
||||
EarningsListParams,
|
||||
EarningsState,
|
||||
EligibleNurseEarnings,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutBatchFilters,
|
||||
PayoutBatchPreview,
|
||||
PayoutBatchSummary,
|
||||
PayoutsApi,
|
||||
SkippedNurse,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -320,6 +327,262 @@ function delay<T>(value: T): Promise<T> {
|
||||
|
||||
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
||||
|
||||
// ══ Admin batch actions (b13 admin_payouts/*) — mutable in-memory state ════════════════════════════════
|
||||
//
|
||||
// Engineered to exercise every admin UI state and stay money-correct: batches spanning completed /
|
||||
// partially_failed / processing, at least one holiday-shifted; per-payout rows including a `failed` row
|
||||
// (so retry is demonstrable) and `paid` rows with a last-4 masked IBAN + transfer reference. Every row
|
||||
// reconciles `gross − clawback = net = amount`, its booking links sum to its gross, and each batch's
|
||||
// `totalAmount = Σ its payouts' net`. Rows are **mutated in place** by retry / record-reference so state
|
||||
// persists across calls within the session.
|
||||
|
||||
/** ISO `YYYY-MM-DD` shifted `days` off `isoDate` (holiday-shifted processing date). */
|
||||
function isoDateShift(isoDate: string, days: number): string {
|
||||
return new Date(new Date(isoDate).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** IRR-string sum (integer-safe BigInt; never a float). */
|
||||
function sumIrr(values: string[]): string {
|
||||
return String(values.reduce((total, v) => total + BigInt(v), BigInt(0)));
|
||||
}
|
||||
|
||||
const ADMIN_MASKED_IBAN_A = 'IR••••••••••••••••••4821';
|
||||
const ADMIN_MASKED_IBAN_B = 'IR••••••••••••••••••7734';
|
||||
const ADMIN_MASKED_IBAN_C = 'IR••••••••••••••••••1092';
|
||||
|
||||
/** Per-nurse payout rows keyed by batch id. Mutated in place by `retryPayout` / `recordTransferReference`. */
|
||||
const BATCH_DETAILS: Record<number, AdminPayoutRow[]> = {
|
||||
// 7101 — partially_failed: one paid + one failed (retryable). Σ net = 4,250,000 + 3,400,000 = 7,650,000.
|
||||
7101: [
|
||||
{
|
||||
id: 9201,
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'PAYA-14050412-9201',
|
||||
paidAt: isoFromNowHours(-20),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
{
|
||||
id: 9202,
|
||||
nurseId: 302,
|
||||
nurseName: 'مریم احمدی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
amountIrr: '3400000',
|
||||
status: 'failed',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: 'invalid_sheba',
|
||||
bookings: [{ bookingId: 5002, sessionId: 1, payoutAmountIrr: '3400000' }],
|
||||
},
|
||||
],
|
||||
// 7102 — completed: a clawback-netted paid + a clean paid. Σ net = 4,250,000 + 2,000,000 = 6,250,000.
|
||||
7102: [
|
||||
{
|
||||
id: 9203,
|
||||
nurseId: 303,
|
||||
nurseName: 'فاطمه کریمی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_C,
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '750000',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'SATNA-14050405-9203',
|
||||
paidAt: isoFromNowHours(-96),
|
||||
failureReason: null,
|
||||
bookings: [
|
||||
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
|
||||
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9204,
|
||||
nurseId: 304,
|
||||
nurseName: 'سکینه رضایی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
amountIrr: '2000000',
|
||||
status: 'paid',
|
||||
transferReference: 'PAYA-14050405-9204',
|
||||
paidAt: isoFromNowHours(-100),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5006, sessionId: 1, payoutAmountIrr: '2000000' }],
|
||||
},
|
||||
],
|
||||
// 7103 — processing: submitted, awaiting settlement. Σ net = 3,000,000.
|
||||
7103: [
|
||||
{
|
||||
id: 9205,
|
||||
nurseId: 305,
|
||||
nurseName: 'اکرم حسینی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||
grossEarningsIrr: '3000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3000000',
|
||||
amountIrr: '3000000',
|
||||
status: 'submitted',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '3000000' }],
|
||||
},
|
||||
],
|
||||
// 7104 — completed (holiday-shifted), older. Σ net = 4,250,000.
|
||||
7104: [
|
||||
{
|
||||
id: 9206,
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'SATNA-14050328-9206',
|
||||
paidAt: isoFromNowHours(-260),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 4980, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Batch headers (newest-first by `createdAt` at read time). `totalAmount = Σ its detail rows' net`. */
|
||||
const BATCHES: PayoutBatchSummary[] = [
|
||||
{
|
||||
id: 7103,
|
||||
periodStart: isoDateDaysAgo(7),
|
||||
periodEnd: isoDateDaysAgo(1),
|
||||
processingDate: isoDateDaysAgo(0),
|
||||
totalAmount: '3000000',
|
||||
payoutCount: 1,
|
||||
status: 'processing',
|
||||
processedAt: null,
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-6),
|
||||
holidayShifted: false,
|
||||
},
|
||||
{
|
||||
id: 7101,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(6),
|
||||
totalAmount: '7650000',
|
||||
payoutCount: 2,
|
||||
status: 'partially_failed',
|
||||
processedAt: isoFromNowHours(-20),
|
||||
failureNotes: '۱ انتقال توسط سامانه بانکی رد شد (invalid_sheba)',
|
||||
createdAt: isoFromNowHours(-26),
|
||||
holidayShifted: true,
|
||||
},
|
||||
{
|
||||
id: 7102,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
totalAmount: '6250000',
|
||||
payoutCount: 2,
|
||||
status: 'completed',
|
||||
processedAt: isoFromNowHours(-96),
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-120),
|
||||
holidayShifted: false,
|
||||
},
|
||||
{
|
||||
id: 7104,
|
||||
periodStart: isoDateDaysAgo(21),
|
||||
periodEnd: isoDateDaysAgo(15),
|
||||
processingDate: isoDateDaysAgo(13),
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
status: 'completed',
|
||||
processedAt: isoFromNowHours(-260),
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-264),
|
||||
holidayShifted: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Idempotency ledgers: the SAME key returns the SAME result (never a double-run / double-pay). */
|
||||
const RUN_BATCH_IDEMPOTENCY = new Map<string, PayoutBatchSummary>();
|
||||
const RETRY_IDEMPOTENCY = new Set<string>();
|
||||
let nextBatchId = 7200;
|
||||
let nextAdminPayoutId = 9300;
|
||||
|
||||
/**
|
||||
* The eligibility dry-run: ≥3 eligible nurses (one with a netted clawback), one flagged with no verified
|
||||
* IBAN (`hasVerifiedPrimaryIban:false` — shown, not dropped), one skipped (`no_verified_primary_iban`), and
|
||||
* a holiday-shifted processing date. `totalNetIrr = Σ eligible.netAmountIrr`. The server owns eligibility +
|
||||
* the shifted date; the client only renders this.
|
||||
*/
|
||||
function buildPreview(periodStart: string, periodEnd: string): PayoutBatchPreview {
|
||||
const eligible: EligibleNurseEarnings[] = [
|
||||
{
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
bookingCount: 2,
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '5000000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
nurseId: 302,
|
||||
nurseName: 'مریم احمدی',
|
||||
bookingCount: 1,
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
// netted clawback: 6,000,000 gross − 1,500,000 clawback = 4,500,000 net
|
||||
nurseId: 303,
|
||||
nurseName: 'فاطمه کریمی',
|
||||
bookingCount: 3,
|
||||
grossEarningsIrr: '6000000',
|
||||
clawbackAppliedIrr: '1500000',
|
||||
netAmountIrr: '4500000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
// flagged (no verified primary IBAN) — surfaced with the flag, NOT dropped (contract semantics)
|
||||
nurseId: 306,
|
||||
nurseName: 'نرگس علوی',
|
||||
bookingCount: 1,
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
hasVerifiedPrimaryIban: false,
|
||||
},
|
||||
];
|
||||
const skipped: SkippedNurse[] = [
|
||||
{ nurseId: 307, nurseName: 'طاهره یوسفی', grossEarningsIrr: '1200000', reason: 'no_verified_primary_iban' },
|
||||
];
|
||||
return {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
// holiday-shifted a couple days past periodEnd (the server owns the shift; the client renders it)
|
||||
processingDate: isoDateShift(periodEnd, 2),
|
||||
holidayShifted: true,
|
||||
eligible,
|
||||
skipped,
|
||||
totalNetIrr: sumIrr(eligible.map((e) => e.netAmountIrr)),
|
||||
};
|
||||
}
|
||||
|
||||
export const payoutsMockApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () => delay(buildSummary()),
|
||||
|
||||
@@ -336,4 +599,111 @@ export const payoutsMockApi: PayoutsApi = {
|
||||
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
||||
return delay(detail);
|
||||
},
|
||||
|
||||
// ── Admin batch actions ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
listPayoutBatches: async (filters: PayoutBatchFilters, params: PageParams) => {
|
||||
const sorted = [...BATCHES].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
const filtered = filters.status ? sorted.filter((b) => b.status === filters.status) : sorted;
|
||||
return delay(paginate(filtered, params));
|
||||
},
|
||||
|
||||
previewPayoutBatch: async (periodStart: string, periodEnd: string) =>
|
||||
delay(buildPreview(periodStart, periodEnd)),
|
||||
|
||||
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||
// Idempotency: the same key returns the same batch — a retried run never opens a second batch.
|
||||
const prior = RUN_BATCH_IDEMPOTENCY.get(idempotencyKey);
|
||||
if (prior) return delay(prior);
|
||||
|
||||
const preview = buildPreview(periodStart, periodEnd);
|
||||
// Only nurses with a verified primary IBAN are materialized into payouts (the flagged ones are skipped).
|
||||
const payable = preview.eligible.filter((e) => e.hasVerifiedPrimaryIban);
|
||||
const id = nextBatchId++;
|
||||
const batch: PayoutBatchSummary = {
|
||||
id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
processingDate: preview.processingDate,
|
||||
totalAmount: sumIrr(payable.map((e) => e.netAmountIrr)),
|
||||
payoutCount: payable.length,
|
||||
status: 'processing',
|
||||
processedAt: null,
|
||||
failureNotes: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
holidayShifted: preview.holidayShifted,
|
||||
};
|
||||
BATCHES.unshift(batch);
|
||||
BATCH_DETAILS[id] = payable.map((e) => ({
|
||||
id: nextAdminPayoutId++,
|
||||
nurseId: e.nurseId,
|
||||
nurseName: e.nurseName,
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: e.grossEarningsIrr,
|
||||
clawbackAppliedIrr: e.clawbackAppliedIrr,
|
||||
netAmountIrr: e.netAmountIrr,
|
||||
amountIrr: e.netAmountIrr,
|
||||
status: 'submitted',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5000 + e.nurseId, sessionId: 1, payoutAmountIrr: e.grossEarningsIrr }],
|
||||
}));
|
||||
RUN_BATCH_IDEMPOTENCY.set(idempotencyKey, batch);
|
||||
return delay(batch);
|
||||
},
|
||||
|
||||
getPayoutBatchDetail: async (batchId: number, page: number) => {
|
||||
const batch = BATCHES.find((b) => b.id === batchId);
|
||||
if (!batch) throw new Error(`Mock payout batch ${batchId} not found`);
|
||||
const rows = BATCH_DETAILS[batchId] ?? [];
|
||||
const pageSize = ADMIN_BATCH_DETAIL_PAGE_SIZE;
|
||||
const p = Math.max(1, page);
|
||||
const start = (p - 1) * pageSize;
|
||||
const detail: AdminPayoutBatchDetail = {
|
||||
batch,
|
||||
payouts: rows.slice(start, start + pageSize),
|
||||
total: rows.length,
|
||||
page: p,
|
||||
pageSize,
|
||||
};
|
||||
return delay(detail);
|
||||
},
|
||||
|
||||
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||
// Idempotency: a re-fired retry with the same key never re-applies (no double-pay).
|
||||
if (RETRY_IDEMPOTENCY.has(idempotencyKey)) return delay(undefined);
|
||||
RETRY_IDEMPOTENCY.add(idempotencyKey);
|
||||
for (const [key, rows] of Object.entries(BATCH_DETAILS)) {
|
||||
const row = rows.find((r) => r.id === payoutId);
|
||||
if (!row) continue;
|
||||
if (row.status === 'failed') {
|
||||
row.status = 'paid';
|
||||
row.failureReason = null;
|
||||
row.paidAt = new Date().toISOString();
|
||||
row.transferReference = `PAYA-RETRY-${payoutId}`;
|
||||
row.amountIrr = row.netAmountIrr;
|
||||
const batch = BATCHES.find((b) => b.id === Number(key));
|
||||
// if that was the last failure in the batch, it re-settles partially_failed → completed
|
||||
if (batch && batch.status === 'partially_failed' && rows.every((r) => r.status !== 'failed')) {
|
||||
batch.status = 'completed';
|
||||
batch.processedAt = new Date().toISOString();
|
||||
batch.failureNotes = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||
for (const rows of Object.values(BATCH_DETAILS)) {
|
||||
const row = rows.find((r) => r.id === payoutId);
|
||||
if (row) {
|
||||
row.transferReference = reference;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return delay(undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,5 +34,15 @@ export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
|
||||
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
|
||||
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The **admin** reconciliation surfaces are more volatile than the nurse read — within one session an admin
|
||||
* opens a draft, runs it, retries a failed payout, records a reference. A short `staleTime`, backed by
|
||||
* explicit invalidation on every mutation (`useRunPayoutBatch`/`useRetryPayout`/`useRecordTransferReference`).
|
||||
*/
|
||||
export const ADMIN_BATCHES_STALE_TIME = 30 * 1000;
|
||||
export const ADMIN_BATCH_DETAIL_STALE_TIME = 30 * 1000;
|
||||
/** Page size for the admin batch-detail payout rows (contract default 50). */
|
||||
export const ADMIN_BATCH_DETAIL_PAGE_SIZE = 50;
|
||||
|
||||
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
|
||||
export const PAYOUTS_PAGE_SIZE = 10;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { ADMIN_BATCH_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* One batch expanded — its header + paginated per-payout rows (status, net, masked IBAN, transfer reference,
|
||||
* booking links). Disabled until a batch is selected (`batchId` present); each page keys separately and
|
||||
* `keepPreviousData` holds the prior page while the next loads. Invalidated by retry / record-reference.
|
||||
*/
|
||||
export function usePayoutBatchDetail(batchId: number | null, page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.adminBatchDetail(batchId ?? -1, page),
|
||||
queryFn: () => payoutsApi.getPayoutBatchDetail(batchId as number, page),
|
||||
enabled: batchId != null && batchId > 0,
|
||||
staleTime: ADMIN_BATCH_DETAIL_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { ADMIN_BATCHES_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type { PayoutBatchFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin reconciliation list of payout batches (newest first). The **filter + page params are part of the
|
||||
* query key** so each status filter / page caches independently; `keepPreviousData` avoids an empty flash
|
||||
* while paging or switching filters. Volatile relative to the nurse read — a short `staleTime`, refreshed by
|
||||
* explicit invalidation from the batch mutations.
|
||||
*/
|
||||
export function usePayoutBatches(filters: PayoutBatchFilters, page: number) {
|
||||
const params = { page, pageSize: PAYOUTS_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.adminBatches(filters, params),
|
||||
queryFn: () => payoutsApi.listPayoutBatches(filters, params),
|
||||
staleTime: ADMIN_BATCHES_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import type { PayoutBatchPreview } from '../types';
|
||||
|
||||
/**
|
||||
* The eligibility dry-run for a window — a **mutation**, not an auto-fetching query: it runs only when the
|
||||
* admin explicitly asks to preview (never on mount), and its result (the eligible/skipped breakdown + the
|
||||
* server's holiday-shifted processing date) is read from the mutation's `data`. The client renders it; it
|
||||
* never computes eligibility or the shifted date.
|
||||
*/
|
||||
export function usePreviewPayoutBatch() {
|
||||
return useMutation<PayoutBatchPreview, unknown, { periodStart: string; periodEnd: string }>({
|
||||
mutationFn: ({ periodStart, periodEnd }) => payoutsApi.previewPayoutBatch(periodStart, periodEnd),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Record a manually reconciled bank transfer reference on a payout (REQ-036 — b13 has `mark_failed` but no
|
||||
* record-reference route). On success we invalidate the owning batch's detail so the reference renders on the
|
||||
* row without a manual refresh.
|
||||
*/
|
||||
export function useRecordTransferReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { payoutId: number; reference: string; batchId: number }>({
|
||||
mutationFn: ({ payoutId, reference }) => payoutsApi.recordTransferReference(payoutId, reference),
|
||||
onSuccess: (_data, { batchId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Re-submit a single `failed` payout to the bank rail. **Idempotency-keyed**: a re-fired retry with the same
|
||||
* key never re-pays. On success we invalidate the owning batch's detail (the row flips `failed → paid`, and
|
||||
* if it was the batch's last failure the batch re-settles `partially_failed → completed`) and the batches
|
||||
* list (its status may have changed).
|
||||
*/
|
||||
export function useRetryPayout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { payoutId: number; idempotencyKey: string; batchId: number }>({
|
||||
mutationFn: ({ payoutId, idempotencyKey }) => payoutsApi.retryPayout(payoutId, idempotencyKey),
|
||||
onSuccess: (_data, { batchId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
|
||||
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import type { PayoutBatchSummary } from '../types';
|
||||
|
||||
/**
|
||||
* Open + run a payout batch for a window. **Idempotency-keyed**: the caller passes a stable `idempotencyKey`
|
||||
* per run so a retried submit converges on the same batch (never a double-run). On success we invalidate the
|
||||
* batches list so the new batch appears at the top without a manual refresh. Domain 4xx (e.g. no eligible
|
||||
* bookings) surface to the caller's `onError`.
|
||||
*/
|
||||
export function useRunPayoutBatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
PayoutBatchSummary,
|
||||
unknown,
|
||||
{ periodStart: string; periodEnd: string; idempotencyKey: string }
|
||||
>({
|
||||
mutationFn: ({ periodStart, periodEnd, idempotencyKey }) =>
|
||||
payoutsApi.runPayoutBatch(periodStart, periodEnd, idempotencyKey),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,3 +6,11 @@ export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance';
|
||||
export { useNurseEarnings } from './hooks/useNurseEarnings';
|
||||
export { useNursePayoutHistory } from './hooks/useNursePayoutHistory';
|
||||
export { useNursePayoutDetail } from './hooks/useNursePayoutDetail';
|
||||
|
||||
// Admin batch actions (b13 admin_payouts/*).
|
||||
export { usePayoutBatches } from './hooks/usePayoutBatches';
|
||||
export { usePayoutBatchDetail } from './hooks/usePayoutBatchDetail';
|
||||
export { usePreviewPayoutBatch } from './hooks/usePreviewPayoutBatch';
|
||||
export { useRunPayoutBatch } from './hooks/useRunPayoutBatch';
|
||||
export { useRetryPayout } from './hooks/useRetryPayout';
|
||||
export { useRecordTransferReference } from './hooks/useRecordTransferReference';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EarningsState } from './types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { EarningsState, PayoutBatchFilters } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern).
|
||||
@@ -22,4 +23,14 @@ export const payoutKeys = {
|
||||
|
||||
details: () => [...payoutKeys.all, 'detail'] as const,
|
||||
detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const,
|
||||
|
||||
// Admin batch actions. The filter object + page params are part of the key (so each filter/page caches
|
||||
// separately); mutations invalidate the `adminBatchLists` / `adminBatchDetails` prefixes.
|
||||
adminBatchLists: () => [...payoutKeys.all, 'admin_batches'] as const,
|
||||
adminBatches: (filters: PayoutBatchFilters, params: PageParams) =>
|
||||
[...payoutKeys.adminBatchLists(), filters, params] as const,
|
||||
|
||||
adminBatchDetails: () => [...payoutKeys.all, 'admin_batch_detail'] as const,
|
||||
adminBatchDetail: (batchId: number, page: number) =>
|
||||
[...payoutKeys.adminBatchDetails(), batchId, page] as const,
|
||||
};
|
||||
|
||||
@@ -173,14 +173,123 @@ export interface EarningsListParams extends PageParams {
|
||||
state?: EarningsState;
|
||||
}
|
||||
|
||||
// ── Admin payout-batch actions (b13 `admin_payouts/*`) ────────────────────────────────────────────────
|
||||
//
|
||||
// The admin side of the same weekly engine: preview eligible earnings, open/run a batch, read batches +
|
||||
// per-payout rows, retry a failed payout, record a reconciled transfer reference. These map published b13
|
||||
// routes 1:1 (`clientApi.ts`) but the domain stays **mock-primary** this phase (see `constants.ts`).
|
||||
// Money is IRR digit-strings; the client renders the **server's** eligibility + holiday-shifted date — it
|
||||
// never computes them. Invariants: per eligible nurse / payout `gross − clawback = net`; a batch's
|
||||
// `totalAmount = Σ its payouts' net`; a payout's booking links sum to its `grossEarningsIrr`.
|
||||
|
||||
/** One nurse's payout-eligible, unpaid earnings for a window (`EligibleNurseEarningsDto`). A nurse missing a
|
||||
* verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban:false`) here, not dropped. */
|
||||
export interface EligibleNurseEarnings {
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
bookingCount: number;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
/** `= grossEarningsIrr − clawbackAppliedIrr` (clawbacks netted into the preview). */
|
||||
netAmountIrr: string;
|
||||
hasVerifiedPrimaryIban: boolean;
|
||||
}
|
||||
|
||||
/** A nurse excluded from a generated batch, with the reason (`SkippedNurseDto`; e.g. `no_verified_primary_iban`). */
|
||||
export interface SkippedNurse {
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
grossEarningsIrr: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** A `nurse_payout_batches` header for the admin reconciliation list (`PayoutBatchDto` + `holidayShifted`). */
|
||||
export interface PayoutBatchSummary {
|
||||
id: number;
|
||||
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
/** `= Σ its payouts' net_amount_irr`. IRR digit-string. */
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
status: PayoutBatchStatus;
|
||||
processedAt: string | null;
|
||||
failureNotes: string | null;
|
||||
createdAt: string;
|
||||
/** Whether `processingDate` was shifted off a bank-closed day (server truth; the client only renders it). */
|
||||
holidayShifted: boolean;
|
||||
}
|
||||
|
||||
/** The dry-run before a batch: the eligible nurses, the ones that would be skipped, and the shifted date. */
|
||||
export interface PayoutBatchPreview {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
holidayShifted: boolean;
|
||||
eligible: EligibleNurseEarnings[];
|
||||
skipped: SkippedNurse[];
|
||||
/** `= Σ eligible.netAmountIrr`. IRR digit-string. */
|
||||
totalNetIrr: string;
|
||||
}
|
||||
|
||||
/** One `nurse_payouts` row expanded for the admin batch detail (`PayoutDto`). Every row reconciles:
|
||||
* `grossEarningsIrr − clawbackAppliedIrr = netAmountIrr`; on a clean payout `amountIrr = netAmountIrr`. */
|
||||
export interface AdminPayoutRow {
|
||||
id: number;
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
|
||||
maskedIban: string;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
/** What was actually transferred (`PayoutDto.amount`); `= netAmountIrr` on a clean payout. */
|
||||
amountIrr: string;
|
||||
status: PayoutStatus;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
/** `failed` only. Empty otherwise. */
|
||||
failureReason: string | null;
|
||||
/** The bookings this payout covered; `Σ payoutAmountIrr = grossEarningsIrr`. */
|
||||
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
|
||||
}
|
||||
|
||||
/** A batch header + its paginated payout rows (`PayoutBatchDetailDto`). */
|
||||
export interface AdminPayoutBatchDetail {
|
||||
batch: PayoutBatchSummary;
|
||||
payouts: AdminPayoutRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** `listPayoutBatches` filter — the optional status is part of the query key so each filter caches separately. */
|
||||
export interface PayoutBatchFilters {
|
||||
status?: PayoutBatchStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payouts API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. **All reads; no
|
||||
* mutations** (a nurse never writes payout state).
|
||||
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks.
|
||||
*
|
||||
* The **nurse read** methods are all reads (a nurse never writes payout state). The **admin** methods
|
||||
* (`admin_payouts/*`) add the batch actions: previewing eligibility, running a batch, retrying a failed
|
||||
* payout, and recording a transfer reference. `runPayoutBatch`/`retryPayout` are **idempotency-keyed** —
|
||||
* the same key returns the same result (never a double-pay).
|
||||
*/
|
||||
export interface PayoutsApi {
|
||||
// Nurse read side.
|
||||
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
|
||||
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
|
||||
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
|
||||
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
|
||||
|
||||
// Admin batch actions.
|
||||
listPayoutBatches(filters: PayoutBatchFilters, params: PageParams): Promise<Paginated<PayoutBatchSummary>>;
|
||||
previewPayoutBatch(periodStart: string, periodEnd: string): Promise<PayoutBatchPreview>;
|
||||
runPayoutBatch(periodStart: string, periodEnd: string, idempotencyKey: string): Promise<PayoutBatchSummary>;
|
||||
getPayoutBatchDetail(batchId: number, page: number): Promise<AdminPayoutBatchDetail>;
|
||||
retryPayout(payoutId: number, idempotencyKey: string): Promise<void>;
|
||||
recordTransferReference(payoutId: number, reference: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type {
|
||||
AdminRefundResult,
|
||||
CancelBookingInput,
|
||||
CancellationPolicyPreview,
|
||||
InitiateRefundInput,
|
||||
RefundChannel,
|
||||
RefundPreview,
|
||||
RefundStatus,
|
||||
RefundSummary,
|
||||
RefundsApi,
|
||||
@@ -12,6 +15,7 @@ import type {
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
const REFUNDS = '/api/v1/refunds';
|
||||
const ADMIN_REFUNDS = '/api/v1/admin_refunds';
|
||||
|
||||
/**
|
||||
* The thin b11 customer refund payload (`GET refunds/{id}/status`) — the only refund shape the contract
|
||||
@@ -93,4 +97,52 @@ export const refundsClientApi: RefundsApi = {
|
||||
|
||||
getRefund: async (refundId: number) =>
|
||||
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
|
||||
|
||||
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
|
||||
// read-only preview route), yet the admin console must disclose the split before initiating. Filed as a
|
||||
// proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today.
|
||||
getRefundPreview: async (bookingId: number, ticketId: number | null) => {
|
||||
const params = new URLSearchParams({ booking_id: String(bookingId) });
|
||||
if (ticketId != null) params.set('ticket_id', String(ticketId));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<RefundPreview>>(`${ADMIN_REFUNDS}/preview?${params.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
// Create + immediately execute a ticket-linked refund (b11 `POST api/v1/admin_refunds`). The response is
|
||||
// already `AdminRefundResult`-shaped (refundId/status/channel/decomposed legs/eta/clawbackId).
|
||||
initiateRefund: async (input: InitiateRefundInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AdminRefundResult>>(ADMIN_REFUNDS, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bookingId: input.bookingId,
|
||||
ticketId: input.ticketId,
|
||||
refundPercentage: input.refundPercentage,
|
||||
refundChannel: input.refundChannel,
|
||||
reasonCategory: input.reasonCategory,
|
||||
reasonNotes: input.reasonNotes,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
// REQ-035: approve/reject a refund. b11 has no separate approve/reject step — `POST admin_refunds` both
|
||||
// creates and executes — so a failed-channel retry and an explicit rejection are proposed as
|
||||
// `POST api/v1/admin_refunds/{id}/approve` and `.../{id}/reject`; the mock serves both today.
|
||||
approveRefund: async (refundId: number) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AdminRefundResult>>(`${ADMIN_REFUNDS}/${refundId}/approve`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
),
|
||||
|
||||
// REQ-035: see approveRefund. Reject records a reason and moves the refund to `rejected`.
|
||||
rejectRefund: async (refundId: number, reason: string) => {
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<true>>(`${ADMIN_REFUNDS}/${refundId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,11 +5,15 @@ import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
|
||||
import {
|
||||
isBookingCancellable,
|
||||
isTerminalRefundStatus,
|
||||
type AdminRefundResult,
|
||||
type CancelBookingInput,
|
||||
type CancellationPolicyCode,
|
||||
type CancellationPolicyPreview,
|
||||
type CancellationSessionPreview,
|
||||
type InitiateRefundInput,
|
||||
type RefundChannel,
|
||||
type RefundPreview,
|
||||
type RefundStatus,
|
||||
type RefundSummary,
|
||||
type RefundsApi,
|
||||
} from '../types';
|
||||
@@ -184,6 +188,99 @@ function advanceRefund(refund: MockRefund): void {
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------------
|
||||
* Admin refund tooling mock (b11 `admin_refunds`). Self-contained fixtures keyed by a **sentinel booking
|
||||
* id** — one per console panel state — kept separate from the customer booking store above so every admin
|
||||
* state is deterministic. Each fixture reconciles `platformFeeRefunded + nursePayoutRefunded === amount`
|
||||
* (BigInt). The states:
|
||||
* 6001 — normal card: `psp_card`, `succeeded` immediately, no ETA, no clawback.
|
||||
* 6002 — BNPL: `bnpl_revert`, `processing` on initiate, a ~9-business-day `expectedCustomerRefundEta`.
|
||||
* 6003 — post-payout: `willCreateClawback` (nurse already paid), initiate returns a `clawbackId`.
|
||||
* 6004 — provider decline: the FIRST `initiateRefund` returns `failed` (channel refused); a subsequent
|
||||
* `approveRefund` (retry) succeeds — so the retry path is demonstrable.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
/** Sentinel booking whose first refund attempt the channel declines (`failed`), then a retry succeeds. */
|
||||
const PROVIDER_DECLINE_BOOKING_ID = 6004;
|
||||
|
||||
/** The admin decomposition preview per fixture booking (the BNPL ETA is filled dynamically at read time). */
|
||||
const ADMIN_PREVIEW_FIXTURES: Record<number, Omit<RefundPreview, 'expectedCustomerRefundEta'>> = {
|
||||
6001: {
|
||||
bookingId: 6001,
|
||||
refundPercentageApplied: 1,
|
||||
amountIrr: '10000000',
|
||||
platformFeeRefundedIrr: '1500000',
|
||||
nursePayoutRefundedIrr: '8500000',
|
||||
refundChannel: 'psp_card',
|
||||
willCreateClawback: false,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
},
|
||||
6002: {
|
||||
bookingId: 6002,
|
||||
refundPercentageApplied: 1,
|
||||
amountIrr: '6000000',
|
||||
platformFeeRefundedIrr: '900000',
|
||||
nursePayoutRefundedIrr: '5100000',
|
||||
refundChannel: 'bnpl_revert',
|
||||
willCreateClawback: false,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
},
|
||||
6003: {
|
||||
bookingId: 6003,
|
||||
refundPercentageApplied: 0.5,
|
||||
amountIrr: '5000000',
|
||||
platformFeeRefundedIrr: '750000',
|
||||
nursePayoutRefundedIrr: '4250000',
|
||||
refundChannel: 'psp_card',
|
||||
willCreateClawback: true,
|
||||
cancellationPolicyCode: 'partial_under_24h',
|
||||
},
|
||||
[PROVIDER_DECLINE_BOOKING_ID]: {
|
||||
bookingId: PROVIDER_DECLINE_BOOKING_ID,
|
||||
refundPercentageApplied: 1,
|
||||
amountIrr: '8000000',
|
||||
platformFeeRefundedIrr: '1200000',
|
||||
nursePayoutRefundedIrr: '6800000',
|
||||
refundChannel: 'psp_card',
|
||||
willCreateClawback: false,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
},
|
||||
};
|
||||
|
||||
/** The BNPL admin ETA — ~9 business days out (Fridays skipped), within the product's ~7–10-day window. */
|
||||
const ADMIN_BNPL_ETA_BUSINESS_DAYS = 9;
|
||||
|
||||
/** Assert the fee legs sum to the total (BigInt) — a preview must reconcile to the rial before it ships. */
|
||||
function assertReconciles(preview: Omit<RefundPreview, 'expectedCustomerRefundEta'>): void {
|
||||
const sum = parseIrr(preview.platformFeeRefundedIrr) + parseIrr(preview.nursePayoutRefundedIrr);
|
||||
if (sum !== parseIrr(preview.amountIrr)) {
|
||||
throw new Error(`Admin refund preview ${preview.bookingId} does not reconcile`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the full preview for a fixture booking (filling the BNPL ETA); `404` on an unknown booking. */
|
||||
function adminPreviewFor(bookingId: number): RefundPreview {
|
||||
const base = ADMIN_PREVIEW_FIXTURES[bookingId];
|
||||
if (!base) throw new ApiError(404, 'No captured payment for this booking', 'not_found');
|
||||
assertReconciles(base);
|
||||
return {
|
||||
...base,
|
||||
expectedCustomerRefundEta:
|
||||
base.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The executed status for a channel: a card refund succeeds immediately; BNPL/manual go to `processing`. */
|
||||
function executedStatusFor(channel: RefundChannel): RefundStatus {
|
||||
return channel === 'psp_card' ? 'succeeded' : 'processing';
|
||||
}
|
||||
|
||||
let nextAdminRefundId = 9001;
|
||||
let nextClawbackId = 4001;
|
||||
const adminRefundsById: Record<number, AdminRefundResult> = {};
|
||||
// Per-booking initiate counter — drives the provider-decline sentinel's first-attempt failure.
|
||||
const adminInitiateAttempts: Record<number, number> = {};
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
|
||||
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
|
||||
@@ -258,4 +355,70 @@ export const refundsMockApi: RefundsApi = {
|
||||
advanceRefund(refund);
|
||||
return toRefundSummary(refund);
|
||||
},
|
||||
|
||||
// --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). ---
|
||||
|
||||
getRefundPreview: async (bookingId, _ticketId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return adminPreviewFor(bookingId);
|
||||
},
|
||||
|
||||
initiateRefund: async (input: InitiateRefundInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const preview = adminPreviewFor(input.bookingId); // 404 if unknown
|
||||
const channel = input.refundChannel ?? preview.refundChannel;
|
||||
|
||||
const attempt = (adminInitiateAttempts[input.bookingId] ?? 0) + 1;
|
||||
adminInitiateAttempts[input.bookingId] = attempt;
|
||||
|
||||
// The provider-decline fixture fails its first attempt (channel refused); a later approve/retry recovers.
|
||||
const declined = input.bookingId === PROVIDER_DECLINE_BOOKING_ID && attempt === 1;
|
||||
const status: RefundStatus = declined ? 'failed' : executedStatusFor(channel);
|
||||
|
||||
const result: AdminRefundResult = {
|
||||
refundId: nextAdminRefundId++,
|
||||
bookingId: input.bookingId,
|
||||
status,
|
||||
refundChannel: channel,
|
||||
// The client renders the server's decomposition verbatim; it never recomputes the legs.
|
||||
amount: preview.amountIrr,
|
||||
platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
|
||||
nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
|
||||
expectedCustomerRefundEta: status === 'failed' ? null : preview.expectedCustomerRefundEta,
|
||||
// Post-payout: the nurse was already paid, so executing opens a pending clawback — never on a decline.
|
||||
clawbackId: preview.willCreateClawback && !declined ? nextClawbackId++ : null,
|
||||
};
|
||||
adminRefundsById[result.refundId] = result;
|
||||
return result;
|
||||
},
|
||||
|
||||
approveRefund: async (refundId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = adminRefundsById[refundId];
|
||||
if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
|
||||
// Approve/retry only applies to a refund awaiting execution or one the channel declined.
|
||||
if (existing.status !== 'failed' && existing.status !== 'requested') {
|
||||
throw new ApiError(409, 'Refund is not awaiting approval', 'not_approvable');
|
||||
}
|
||||
const status = executedStatusFor(existing.refundChannel);
|
||||
const updated: AdminRefundResult = {
|
||||
...existing,
|
||||
status,
|
||||
expectedCustomerRefundEta:
|
||||
existing.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null,
|
||||
};
|
||||
adminRefundsById[refundId] = updated;
|
||||
return updated;
|
||||
},
|
||||
|
||||
rejectRefund: async (refundId, reason) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!reason?.trim()) throw new ApiError(400, 'A rejection reason is required', 'reason_required');
|
||||
const existing = adminRefundsById[refundId];
|
||||
if (!existing) throw new ApiError(404, 'Refund not found', 'not_found');
|
||||
if (isTerminalRefundStatus(existing.status)) {
|
||||
throw new ApiError(409, 'Refund is already terminal', 'not_rejectable');
|
||||
}
|
||||
adminRefundsById[refundId] = { ...existing, status: 'rejected' };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,6 +50,12 @@ export const MOCK_POLICY_TIERS: Record<
|
||||
customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The admin decomposition preview depends on the booking's current payout/dispute state (a post-payout
|
||||
* refund forks to a clawback), so keep it short-lived — the console must never initiate off a stale split.
|
||||
*/
|
||||
export const ADMIN_REFUND_PREVIEW_STALE_TIME = 10 * 1000;
|
||||
|
||||
/**
|
||||
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's
|
||||
* ~7–10 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { invalidateAfterAdminRefund } from '../invalidations';
|
||||
import type { AdminRefundResult } from '../types';
|
||||
|
||||
/**
|
||||
* Approve / retry a refund (the console's action on a channel-declined `failed` refund, or an
|
||||
* approval-gated `requested` one). The result carries the booking id, so on success we invalidate that
|
||||
* booking's refund + previews via the shared helper. Domain 4xx (`404` not found, `409` not awaiting
|
||||
* approval) surface via `mutation.error`.
|
||||
*/
|
||||
export function useApproveRefund() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<AdminRefundResult, unknown, number>({
|
||||
mutationFn: (refundId) => refundsApi.approveRefund(refundId),
|
||||
onSuccess: (result) => invalidateAfterAdminRefund(queryClient, result.bookingId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { invalidateAfterAdminRefund } from '../invalidations';
|
||||
import type { AdminRefundResult, InitiateRefundInput } from '../types';
|
||||
|
||||
/**
|
||||
* Initiate (create + immediately execute) a ticket-linked admin refund. On success the customer-facing refund
|
||||
* read, the booking, every admin preview, and the linked support ticket are invalidated so the console and the
|
||||
* customer's status both reflect the outcome without a manual refetch. Domain 4xx (`400` invalid amount, `404`
|
||||
* no captured payment, `409` over-refund, `400` channel refused) surface via `mutation.error` for the panel to
|
||||
* render inline; the fetch layer already toasts 401/403/5xx, so this hook never double-toasts. A `failed`
|
||||
* result (channel declined) is a **success** here (the request completed) — the panel offers the retry.
|
||||
*/
|
||||
export function useInitiateRefund() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<AdminRefundResult, unknown, InitiateRefundInput>({
|
||||
mutationFn: (input) => refundsApi.initiateRefund(input),
|
||||
onSuccess: (_result, input) => invalidateAfterAdminRefund(queryClient, input.bookingId, input.ticketId),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { refundKeys } from '../keys';
|
||||
import { ADMIN_REFUND_PREVIEW_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The admin refund decomposition preview for a booking — the server's fee/payout split, applied percentage,
|
||||
* channel, ETA, and the `willCreateClawback` warning — disclosed **before** the admin initiates. Keyed by the
|
||||
* booking **and** the linking ticket (a refund is always previewed in a ticket's context). Short `staleTime`
|
||||
* (the split forks on the booking's live payout state); enabled only when a booking id is present. The
|
||||
* client renders the served numbers verbatim — it never recomputes the split.
|
||||
*/
|
||||
export function useRefundPreview(bookingId: number | null, ticketId: number | null = null) {
|
||||
return useQuery({
|
||||
queryKey: refundKeys.adminPreview(bookingId ?? -1, ticketId),
|
||||
queryFn: () => refundsApi.getRefundPreview(bookingId as number, ticketId),
|
||||
enabled: bookingId != null && bookingId > 0,
|
||||
staleTime: ADMIN_REFUND_PREVIEW_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { refundsApi } from '../apis';
|
||||
import { invalidateAfterRefundRejection } from '../invalidations';
|
||||
|
||||
/**
|
||||
* Reject a refund with a required reason (moves it to terminal `rejected`). Returns `void`, so on success we
|
||||
* invalidate the by-refund read + the admin previews via the shared helper. Domain 4xx (`400` missing reason,
|
||||
* `404` not found, `409` already terminal) surface via `mutation.error`.
|
||||
*/
|
||||
export function useRejectRefund() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { refundId: number; reason: string }>({
|
||||
mutationFn: ({ refundId, reason }) => refundsApi.rejectRefund(refundId, reason),
|
||||
onSuccess: (_void, { refundId }) => invalidateAfterRefundRejection(queryClient, refundId),
|
||||
});
|
||||
}
|
||||
@@ -5,3 +5,9 @@
|
||||
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
|
||||
export { useCancelBooking } from './hooks/useCancelBooking';
|
||||
export { useRefundStatus } from './hooks/useRefundStatus';
|
||||
|
||||
// Admin refund tooling (b11 admin_refunds; ticket-linked).
|
||||
export { useRefundPreview } from './hooks/useRefundPreview';
|
||||
export { useInitiateRefund } from './hooks/useInitiateRefund';
|
||||
export { useApproveRefund } from './hooks/useApproveRefund';
|
||||
export { useRejectRefund } from './hooks/useRejectRefund';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { bookingKeys } from '@/services/bookings/keys';
|
||||
import { ticketKeys } from '@/services/tickets/keys';
|
||||
import { refundKeys } from './keys';
|
||||
import type { RefundSummary } from './types';
|
||||
|
||||
@@ -20,3 +21,34 @@ export function invalidateAfterCancellation(
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* After an admin **initiates** a refund (or approves a failed one): the booking's refund now exists / moved,
|
||||
* so refresh the customer-facing refund read (`byBooking`), the booking detail + lists (status/refund
|
||||
* changed), every admin decomposition preview (the booking is no longer previewable the same way), and the
|
||||
* linked support ticket if one was passed (the console posts the outcome onto the ticket). Never a blanket
|
||||
* refetch. Called from `useInitiateRefund` / `useApproveRefund`.
|
||||
*/
|
||||
export function invalidateAfterAdminRefund(
|
||||
queryClient: QueryClient,
|
||||
bookingId: number,
|
||||
ticketId?: number | null,
|
||||
): void {
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.byBooking(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.details() });
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
if (ticketId != null) {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After an admin **rejects** a refund. Reject returns no booking id, so we refresh the by-refund read and
|
||||
* the admin previews (the refund is now terminal `rejected`); the worklist re-reads it on next fetch.
|
||||
*/
|
||||
export function invalidateAfterRefundRejection(queryClient: QueryClient, refundId: number): void {
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.detail(refundId) });
|
||||
queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() });
|
||||
}
|
||||
|
||||
@@ -15,4 +15,11 @@ export const refundKeys = {
|
||||
|
||||
details: () => [...refundKeys.all, 'detail'] as const,
|
||||
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
|
||||
|
||||
// Admin refund tooling: the decomposition preview is keyed by the booking **and** the linking ticket
|
||||
// (a refund is always initiated in a ticket's context), so re-previewing under a different ticket caches
|
||||
// independently.
|
||||
adminPreviews: () => [...refundKeys.all, 'admin_preview'] as const,
|
||||
adminPreview: (bookingId: number, ticketId: number | null) =>
|
||||
[...refundKeys.adminPreviews(), bookingId, ticketId] as const,
|
||||
};
|
||||
|
||||
@@ -182,6 +182,64 @@ export function isBookingCancellable(status: BookingStatus): boolean {
|
||||
return status === 'confirmed' || status === 'in_progress';
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------------------------------
|
||||
* Admin refund tooling (b11 `POST`/`GET api/v1/admin_refunds`). The console half of the story: an admin
|
||||
* previews the fee-leg decomposition, then initiates (create + immediately execute) a **ticket-linked**
|
||||
* refund — every initiate carries a `ticketId`. The server computes the split; the client only renders it.
|
||||
* Money stays an IRR digit-string; the client NEVER recomputes the percentage or the fee/payout legs —
|
||||
* `platformFeeRefundedIrr + nursePayoutRefundedIrr === amount` is a server invariant, rendered as served.
|
||||
* -------------------------------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The server's refund decomposition preview for a booking (admin console, before initiate). Reconciles by
|
||||
* construction: `platformFeeRefundedIrr + nursePayoutRefundedIrr === amountIrr`. `willCreateClawback` warns
|
||||
* that the nurse was already paid (a post-payout refund opens a `pending` clawback + support alert); the
|
||||
* `bnpl_revert` channel carries the ~7–10-business-day `expectedCustomerRefundEta` (a `YYYY-MM-DD` date).
|
||||
*/
|
||||
export interface RefundPreview {
|
||||
bookingId: number;
|
||||
refundPercentageApplied: number;
|
||||
amountIrr: string;
|
||||
platformFeeRefundedIrr: string;
|
||||
nursePayoutRefundedIrr: string;
|
||||
refundChannel: RefundChannel; // 'psp_card' | 'bnpl_revert' | 'manual'
|
||||
expectedCustomerRefundEta: string | null;
|
||||
willCreateClawback: boolean;
|
||||
cancellationPolicyCode: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST api/v1/admin_refunds` input. Supply **either** `refundPercentage` (0–1) **or** neither (the server
|
||||
* falls back to the booking's b9 cancellation-snapshot percentage). `ticketId` links the refund to its
|
||||
* support ticket — nullable only until `refund_ticket_required` config is enforced (b15).
|
||||
*/
|
||||
export interface InitiateRefundInput {
|
||||
bookingId: number;
|
||||
ticketId: number | null;
|
||||
refundPercentage?: number;
|
||||
refundChannel?: RefundChannel;
|
||||
reasonCategory?: string;
|
||||
reasonNotes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `POST api/v1/admin_refunds` result — the refund was created **and executed**. A card refund returns
|
||||
* `succeeded` immediately (no ETA); a BNPL/manual refund returns `processing` with an
|
||||
* `expectedCustomerRefundEta`; a post-payout refund sets `clawbackId`. `status` reuses the domain's
|
||||
* `RefundStatus` (a channel refusal returns `failed`, retryable via approve).
|
||||
*/
|
||||
export interface AdminRefundResult {
|
||||
refundId: number;
|
||||
bookingId: number;
|
||||
status: RefundStatus;
|
||||
refundChannel: RefundChannel;
|
||||
amount: string;
|
||||
platformFeeRefundedIrr: string;
|
||||
nursePayoutRefundedIrr: string;
|
||||
expectedCustomerRefundEta: string | null;
|
||||
clawbackId: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The refunds API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -192,4 +250,11 @@ export interface RefundsApi {
|
||||
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
|
||||
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
|
||||
getRefund(refundId: number): Promise<RefundSummary>;
|
||||
|
||||
/* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */
|
||||
/** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */
|
||||
getRefundPreview(bookingId: number, ticketId: number | null): Promise<RefundPreview>;
|
||||
initiateRefund(input: InitiateRefundInput): Promise<AdminRefundResult>;
|
||||
approveRefund(refundId: number): Promise<AdminRefundResult>;
|
||||
rejectRefund(refundId: number, reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { PageParams } from '@/lib/api/types';
|
||||
import { REVIEWS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CreateReviewRequest,
|
||||
ModerateReviewResult,
|
||||
ModerationAction,
|
||||
ModerationQueueFilters,
|
||||
ModerationQueueItem,
|
||||
MyReviewState,
|
||||
NurseReviews,
|
||||
ReviewEligibility,
|
||||
@@ -20,6 +24,12 @@ interface NurseReviewsWire {
|
||||
reviews: Paginated<ReviewListItem>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin
|
||||
* card can't show the review's tags); the client defaults it to `[]` on map.
|
||||
*/
|
||||
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>;
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
|
||||
* swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes:
|
||||
@@ -63,4 +73,30 @@ export const reviewsClientApi: ReviewsApi = {
|
||||
body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
|
||||
}),
|
||||
),
|
||||
|
||||
listModerationQueue: async (filters, params): Promise<Paginated<ModerationQueueItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('status', filters.status ?? 'pending_moderation');
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE));
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<ModerationQueueItemWire>>>(
|
||||
`${API}/admin/reviews/moderation_queue?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
// REQ-037: the wire dto omits tagCodes — default to [] so the admin card renders without them.
|
||||
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) };
|
||||
},
|
||||
|
||||
moderateReview: async (
|
||||
reviewId: number,
|
||||
action: ModerationAction,
|
||||
reason?: string,
|
||||
): Promise<ModerateReviewResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ModerateReviewResult>>(`${API}/reviews/${reviewId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ action, reason: reason ?? null }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi';
|
||||
import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants';
|
||||
import type {
|
||||
CreateReviewRequest,
|
||||
ModerateReviewResult,
|
||||
ModerationAction,
|
||||
ModerationQueueFilters,
|
||||
ModerationQueueItem,
|
||||
ModerationStatus,
|
||||
MyReviewState,
|
||||
NurseReviews,
|
||||
@@ -28,9 +32,13 @@ import type {
|
||||
* - **Submission tracking** — `createReview` records the customer's review as `pending_moderation` (it does
|
||||
* **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking`
|
||||
* returns the persistent "under review" state.
|
||||
* - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in for the deferred (f15) admin
|
||||
* moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse
|
||||
* profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen.
|
||||
* - **Admin moderation queue** — `listModerationQueue`/`moderateReview` back the admin worklist: a seeded set
|
||||
* of `pending_moderation` rows (incl. low-rating reviews with a linked `lowRatingAlertId`) filtered by
|
||||
* status, transitioned statefully in place. A transition returns a **plausible recomputed** nurse aggregate
|
||||
* (the numbers are server-authoritative — the client renders, never computes them).
|
||||
* - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in that moves a *customer-submitted* review
|
||||
* to `published` and onto the nurse profile so a human can watch it appear (aggregate + count updating on the
|
||||
* next fetch). Distinct from the seeded moderation queue above; never wired into a customer/nurse screen.
|
||||
*
|
||||
* Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a
|
||||
* completed booking deep-links correctly.
|
||||
@@ -82,6 +90,30 @@ const PUBLISHED: Record<number, ReviewListItem[]> = {
|
||||
|
||||
const submissions = new Map<number, SubmittedReview>();
|
||||
|
||||
// ── Admin moderation queue (the f13 admin worklist reads this) ────────────────────────────────────────────
|
||||
/**
|
||||
* A seeded moderation worklist: mostly `pending_moderation` rows (the default view) with a couple already
|
||||
* transitioned so a status filter has something to show. It **includes low-rating reviews** (rating 1/2 with a
|
||||
* linked `lowRatingAlertId`) alongside normal ones. Rows are mutated in place by `moderateReview`, so once a
|
||||
* row leaves `pending_moderation` it drops out of the pending view (filtered by status) and appears under its
|
||||
* new status — statefully, for the session.
|
||||
*/
|
||||
const MODERATION_QUEUE: ModerationQueueItem[] = [
|
||||
{ id: 9301, bookingId: 5011, nurseProfileId: 1, customerProfileId: 7001, rating: 2, body: 'کمی دیر رسید و ارتباط ضعیفی داشت.', tagCodes: ['communicative'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4501, createdAt: isoDaysAgo(1) },
|
||||
{ id: 9302, bookingId: 5012, nurseProfileId: 2, customerProfileId: 7002, rating: 5, body: 'مراقبت عالی و حرفهای؛ کاملاً راضی بودیم.', tagCodes: ['professional', 'kind'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(1) },
|
||||
{ id: 9303, bookingId: 5013, nurseProfileId: 1, customerProfileId: 7003, rating: 1, body: 'اصلاً سر وقت نیامد.', tagCodes: [], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4502, createdAt: isoDaysAgo(2) },
|
||||
{ id: 9304, bookingId: 5014, nurseProfileId: 3, customerProfileId: 7004, rating: 4, body: 'تمیز و منظم بود.', tagCodes: ['clean', 'punctual'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(3) },
|
||||
{ id: 9305, bookingId: 5015, nurseProfileId: 4, customerProfileId: 7005, rating: 5, body: null, tagCodes: ['professional'], moderationStatus: 'published', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(6) },
|
||||
];
|
||||
|
||||
/** action → resulting moderation status (the human decision; always overrides the AI pre-screen). */
|
||||
const ACTION_TO_STATUS: Record<ModerationAction, ModerationStatus> = {
|
||||
publish: 'published',
|
||||
hide: 'hidden',
|
||||
reject: 'rejected',
|
||||
unpublish: 'pending_moderation',
|
||||
};
|
||||
|
||||
/** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */
|
||||
function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } {
|
||||
const list = PUBLISHED[nurseProfileId] ?? [];
|
||||
@@ -102,6 +134,24 @@ function isReviewableStatus(status: string): boolean {
|
||||
return status === 'completed' || status === 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* A **plausible recomputed-looking** nurse aggregate returned after a transition. The real server recomputes
|
||||
* `averageRating`/`totalReviews` from source in the same transaction — the numbers are server-authoritative; the
|
||||
* mock just returns believable values. A `publish` folds the moderated review's rating into the nurse's
|
||||
* published rollup; the other actions leave the published set unchanged here.
|
||||
*/
|
||||
function recomputedAggregateFor(
|
||||
nurseProfileId: number,
|
||||
action: ModerationAction,
|
||||
rating: number,
|
||||
): { averageRating: number; totalReviews: number } {
|
||||
const base = aggregateFor(nurseProfileId);
|
||||
const totalReviews = base.publishedCount + (action === 'publish' ? 1 : 0);
|
||||
if (totalReviews === 0) return { averageRating: 0, totalReviews: 0 };
|
||||
const foldedSum = base.averageRating * base.publishedCount + (action === 'publish' ? rating : 0);
|
||||
return { averageRating: Math.round((foldedSum / totalReviews) * 100) / 100, totalReviews };
|
||||
}
|
||||
|
||||
export const reviewsMockApi: ReviewsApi = {
|
||||
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
@@ -164,6 +214,40 @@ export const reviewsMockApi: ReviewsApi = {
|
||||
lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT,
|
||||
};
|
||||
},
|
||||
|
||||
listModerationQueue: async (
|
||||
filters: ModerationQueueFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<ModerationQueueItem>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const status = filters.status ?? 'pending_moderation';
|
||||
const list = MODERATION_QUEUE.filter((q) => q.moderationStatus === status).sort(
|
||||
(a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
|
||||
);
|
||||
return paginate(list, params);
|
||||
},
|
||||
|
||||
moderateReview: async (
|
||||
reviewId: number,
|
||||
action: ModerationAction,
|
||||
reason?: string,
|
||||
): Promise<ModerateReviewResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// hide/reject demand a reason (contract) — surface a 400 so the caller's onError keeps the dialog open.
|
||||
if ((action === 'hide' || action === 'reject') && !reason?.trim()) {
|
||||
throw new ApiError(400, 'A reason is required to hide or reject a review', 'reason_required');
|
||||
}
|
||||
const item = MODERATION_QUEUE.find((q) => q.id === reviewId);
|
||||
if (!item) throw new ApiError(404, 'Review not found', 'review_not_found');
|
||||
|
||||
const nextStatus = ACTION_TO_STATUS[action];
|
||||
// Mutate in place: once it leaves pending_moderation it drops out of the pending view (filtered by status).
|
||||
item.moderationStatus = nextStatus;
|
||||
item.moderationReason = action === 'hide' || action === 'reject' ? reason?.trim() ?? null : null;
|
||||
|
||||
const agg = recomputedAggregateFor(item.nurseProfileId, action, item.rating);
|
||||
return { id: reviewId, moderationStatus: nextStatus, averageRating: agg.averageRating, totalReviews: agg.totalReviews };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,13 @@ export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000;
|
||||
export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000;
|
||||
export const REVIEWS_GC_TIME = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The admin moderation worklist is an actively-worked queue — a short `staleTime` keeps it fresh as items are
|
||||
* moderated by this (or another) admin, while still serving from cache during quick filter/page switches. A
|
||||
* moderation mutation invalidates it immediately, so this only governs background freshness.
|
||||
*/
|
||||
export const MODERATION_QUEUE_STALE_TIME = 20 * 1000;
|
||||
|
||||
/**
|
||||
* The low-rating support-alert threshold (server config, default ≤ 2). The mock echoes it on
|
||||
* `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** — it exists only so the mock's
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import type { ModerateReviewResult, ModerationAction } from '../types';
|
||||
|
||||
interface ModerateReviewVars {
|
||||
reviewId: number;
|
||||
action: ModerationAction;
|
||||
/** Required for `hide`/`reject` (≤ 500); a missing reason is a domain `400` surfaced to `onError`. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a review (admin/moderator). On success we invalidate the whole moderation queue subtree (the row
|
||||
* has moved out of / into a status view) **and** every nurse public list: the server recomputed the affected
|
||||
* nurse's `averageRating`/`totalReviews` from source, but that nurse's id isn't reachable from the mutation
|
||||
* vars or the result, so we drop all nurse lists rather than compute anything client-side (the client **never**
|
||||
* computes the aggregate). Domain 4xx (a missing `reason` on hide/reject) surface to the caller's `onError`, so
|
||||
* the moderation dialog keeps its draft.
|
||||
*/
|
||||
export function useModerateReview() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ModerateReviewResult, unknown, ModerateReviewVars>({
|
||||
mutationFn: ({ reviewId, action, reason }) => reviewsApi.moderateReview(reviewId, action, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: reviewKeys.moderationQueues() });
|
||||
queryClient.invalidateQueries({ queryKey: reviewKeys.nurseLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { reviewsApi } from '../apis';
|
||||
import { reviewKeys } from '../keys';
|
||||
import { MODERATION_QUEUE_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants';
|
||||
import type { ModerationQueueFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin moderation worklist — reviews awaiting a decision, filtered by `status` (default
|
||||
* `pending_moderation`) + paginated. **Admin-scoped**: these rows carry moderation internals (`moderationReason`,
|
||||
* `lowRatingAlertId`) and are never rendered on a customer/nurse surface. Filters + page key the cache so
|
||||
* switching a status tab or paging never refetches a page already held; `keepPreviousData` avoids an empty flash
|
||||
* while the next page/tab loads. A `useModerateReview` mutation invalidates this queue on success.
|
||||
*/
|
||||
export function useModerationQueue(filters: ModerationQueueFilters, page = 1) {
|
||||
return useQuery({
|
||||
queryKey: reviewKeys.moderationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }),
|
||||
queryFn: () => reviewsApi.listModerationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }),
|
||||
staleTime: MODERATION_QUEUE_STALE_TIME,
|
||||
gcTime: REVIEWS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -6,3 +6,5 @@ export { useNurseReviews } from './hooks/useNurseReviews';
|
||||
export { useReviewEligibility } from './hooks/useReviewEligibility';
|
||||
export { useMyReviewForBooking } from './hooks/useMyReviewForBooking';
|
||||
export { useCreateReview } from './hooks/useCreateReview';
|
||||
export { useModerationQueue } from './hooks/useModerationQueue';
|
||||
export { useModerateReview } from './hooks/useModerateReview';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { ModerationQueueFilters } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern).
|
||||
*
|
||||
@@ -5,6 +8,8 @@
|
||||
* cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state
|
||||
* independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the
|
||||
* booking — the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public).
|
||||
* A moderation transition (admin) invalidates `moderationQueues()` **and** `nurseLists()` (the affected nurse's
|
||||
* public list — its id isn't reachable from the mutation, so all nurse lists are dropped).
|
||||
*/
|
||||
export const reviewKeys = {
|
||||
all: ['reviews'] as const,
|
||||
@@ -15,4 +20,9 @@ export const reviewKeys = {
|
||||
|
||||
eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const,
|
||||
myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const,
|
||||
|
||||
moderationQueues: () => [...reviewKeys.all, 'moderation_queue'] as const,
|
||||
/** The admin worklist. Filters + page key the cache so switching a status filter or paging never refetches. */
|
||||
moderationQueue: (filters: ModerationQueueFilters, params: PageParams) =>
|
||||
[...reviewKeys.moderationQueues(), filters, params] as const,
|
||||
};
|
||||
|
||||
@@ -103,6 +103,51 @@ export interface MyReviewState {
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
// ── Admin moderation queue (b14 admin routes; admin/moderator-only) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Moderation **action** (the `PATCH .../status` body). `hide`/`reject` require a non-empty `reason`;
|
||||
* `unpublish` returns a `published` review to `pending_moderation`. The human decision always overrides the AI.
|
||||
*/
|
||||
export type ModerationAction = 'publish' | 'hide' | 'reject' | 'unpublish';
|
||||
|
||||
/**
|
||||
* A row in the admin moderation worklist (`ModerationQueueItemDto`). Unlike the public `ReviewListItem` this
|
||||
* **does** carry moderation internals — the linked `lowRatingAlertId` (id only; support alerts stay internal),
|
||||
* the `moderationReason`, and the customer/nurse profile ids the moderator needs. Never rendered on a user
|
||||
* surface. Note: the wire dto does **not** carry `tagCodes` (REQ-037) — the client defaults it to `[]`.
|
||||
*/
|
||||
export interface ModerationQueueItem {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
nurseProfileId: number;
|
||||
customerProfileId: number;
|
||||
rating: number;
|
||||
body: string | null;
|
||||
tagCodes: string[];
|
||||
moderationStatus: ModerationStatus;
|
||||
moderationReason: string | null;
|
||||
lowRatingAlertId: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Moderation-queue filter. `status` defaults to `pending_moderation` (the worklist) when omitted. */
|
||||
export interface ModerationQueueFilters {
|
||||
status?: ModerationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ModerateReviewResult` — the outcome of a transition plus the **server-recomputed-from-source** nurse
|
||||
* aggregate (`averageRating`/`totalReviews`). The client never computes these; it renders them and invalidates
|
||||
* the affected nurse's public list so the new average/count show up.
|
||||
*/
|
||||
export interface ModerateReviewResult {
|
||||
id: number;
|
||||
moderationStatus: ModerationStatus;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reviews API seam — the real HTTP client and the in-memory mock both implement this; selection is by
|
||||
* config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -116,4 +161,8 @@ export interface ReviewsApi {
|
||||
getMyReviewForBooking(bookingId: number): Promise<MyReviewState>;
|
||||
/** Submit the one review for a completed booking (`409` if already reviewed). */
|
||||
createReview(bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult>;
|
||||
/** Admin — the moderation worklist, filtered by status + paginated. */
|
||||
listModerationQueue(filters: ModerationQueueFilters, params: PageParams): Promise<Paginated<ModerationQueueItem>>;
|
||||
/** Admin — transition a review; returns the recomputed nurse aggregate. `hide`/`reject` need a `reason`. */
|
||||
moderateReview(reviewId: number, action: ModerationAction, reason?: string): Promise<ModerateReviewResult>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
|
||||
import { TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminTicketDetail,
|
||||
AdminTicketFilters,
|
||||
AdminTicketMessage,
|
||||
AdminTicketSummary,
|
||||
OpenTicketRequest,
|
||||
OpenTicketResult,
|
||||
PostAdminMessageRequest,
|
||||
PostMessageRequest,
|
||||
PostMessageResult,
|
||||
TicketAuthorRole,
|
||||
@@ -100,6 +105,56 @@ function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** Admin summary shares the wire shape of the user summary (no `unread`/`lastMessageAt`). */
|
||||
function mapAdminSummary(w: TicketSummaryWire): AdminTicketSummary {
|
||||
return {
|
||||
id: w.id,
|
||||
referenceCode: w.referenceCode,
|
||||
subject: w.subject,
|
||||
status: w.status as AdminTicketSummary['status'],
|
||||
category: w.category as AdminTicketSummary['category'],
|
||||
bookingId: w.bookingId,
|
||||
refundId: w.refundId,
|
||||
createdAt: w.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin thread mapper. Unlike `mapThread`, it **keeps internal messages** and carries `isInternal` through —
|
||||
* this IS the admin view, whose whole purpose is that staff see internal notes (contract §"Critical rules").
|
||||
*/
|
||||
function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicketDetail {
|
||||
const roleBySender = new Map<number, TicketAuthorRole>(
|
||||
w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]),
|
||||
);
|
||||
const messages: AdminTicketMessage[] = w.messages.map((m) => ({
|
||||
id: m.id,
|
||||
ticketId: w.id,
|
||||
body: m.body,
|
||||
authorRole: roleBySender.get(m.senderId) ?? 'system',
|
||||
createdAt: m.sentAt,
|
||||
isMine: viewerUserId != null && m.senderId === viewerUserId,
|
||||
isInternal: m.isInternal,
|
||||
sendStatus: 'sent' as const,
|
||||
}));
|
||||
return {
|
||||
id: w.id,
|
||||
referenceCode: w.referenceCode,
|
||||
subject: w.subject,
|
||||
status: w.status as AdminTicketDetail['status'],
|
||||
category: w.category as AdminTicketDetail['category'],
|
||||
bookingId: w.bookingId,
|
||||
refundId: w.refundId,
|
||||
openedById: w.openedById,
|
||||
closedAt: w.closedAt,
|
||||
participants: w.participants.map((p) => ({
|
||||
userId: p.userId,
|
||||
roleOnTicket: (p.roleOnTicket ?? 'system') as TicketAuthorRole,
|
||||
})),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `TicketsApi` seam (b15 contract). All four methods map published routes:
|
||||
* - `listMyTickets` → `GET /tickets` (own, paginated; `Status`/`ReferenceCode`/`Page`/`PageSize`).
|
||||
@@ -150,4 +205,40 @@ export const ticketsClientApi: TicketsApi = {
|
||||
body: JSON.stringify({ body: body.body }),
|
||||
}),
|
||||
),
|
||||
|
||||
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
|
||||
listAdminTickets: async (
|
||||
filters: AdminTicketFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminTicketSummary>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.status) query.set('Status', filters.status);
|
||||
if (filters.category) query.set('Category', filters.category);
|
||||
if (filters.referenceCode) query.set('ReferenceCode', filters.referenceCode);
|
||||
if (filters.bookingId != null) query.set('BookingId', String(filters.bookingId));
|
||||
if (filters.refundId != null) query.set('RefundId', String(filters.refundId));
|
||||
query.set('Page', String(params.page ?? 1));
|
||||
query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE));
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<TicketSummaryWire>>>(
|
||||
`${API}/admin/tickets?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return { ...wire, items: wire.items.map(mapAdminSummary) };
|
||||
},
|
||||
|
||||
getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
|
||||
// The admin view keeps internal notes — do NOT filter them here; that is the point of this endpoint.
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<TicketThreadWire>>(`${API}/admin/tickets/${ticketId}`));
|
||||
return mapAdminThread(wire, viewerUserId);
|
||||
},
|
||||
|
||||
// Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only.
|
||||
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: body.body, isInternal: body.isInternal }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants';
|
||||
import type {
|
||||
AdminTicketDetail,
|
||||
AdminTicketFilters,
|
||||
AdminTicketMessage,
|
||||
AdminTicketSummary,
|
||||
OpenTicketRequest,
|
||||
OpenTicketResult,
|
||||
PostAdminMessageRequest,
|
||||
PostMessageRequest,
|
||||
PostMessageResult,
|
||||
TicketAuthorRole,
|
||||
@@ -93,6 +98,12 @@ let nextMessageId = 50_000;
|
||||
*/
|
||||
let lastViewerUserId = CUSTOMER;
|
||||
|
||||
/**
|
||||
* The viewer of the most recent `getAdminTicket` — tracked SEPARATELY from `lastViewerUserId` so an admin
|
||||
* reading a thread never re-attributes a user's `postMessage`. `postAdminMessage` appends as this id.
|
||||
*/
|
||||
let lastAdminViewerUserId = ADMIN;
|
||||
|
||||
const tickets: StoredTicket[] = [
|
||||
{
|
||||
id: 1201,
|
||||
@@ -206,6 +217,52 @@ function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** Admin summary — the queue row (no unread/last-activity; that's a user-inbox concern). */
|
||||
function toAdminSummary(t: StoredTicket): AdminTicketSummary {
|
||||
return {
|
||||
id: t.id,
|
||||
referenceCode: t.referenceCode,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
category: t.category,
|
||||
bookingId: t.bookingId,
|
||||
refundId: t.refundId,
|
||||
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin detail — the INVERSE of `toDetail`: it KEEPS internal messages and carries `isInternal`, so the
|
||||
* admin thread shows the internal-styled bubble the user view drops (the no-leak test is demonstrable both
|
||||
* ways against the same store).
|
||||
*/
|
||||
function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail {
|
||||
const roleBySender = new Map<number, TicketAuthorRole>(t.participants.map((p) => [p.userId, p.roleOnTicket]));
|
||||
const messages: AdminTicketMessage[] = t.messages.map((m) => ({
|
||||
id: m.id,
|
||||
ticketId: t.id,
|
||||
body: m.body,
|
||||
authorRole: roleBySender.get(m.senderId) ?? 'system',
|
||||
createdAt: m.sentAt,
|
||||
isMine: m.senderId === viewerUserId,
|
||||
isInternal: m.internal,
|
||||
sendStatus: 'sent' as const,
|
||||
}));
|
||||
return {
|
||||
id: t.id,
|
||||
referenceCode: t.referenceCode,
|
||||
subject: t.subject,
|
||||
status: t.status,
|
||||
category: t.category,
|
||||
bookingId: t.bookingId,
|
||||
refundId: t.refundId,
|
||||
openedById: t.openedById,
|
||||
closedAt: t.closedAt,
|
||||
participants: t.participants,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/** `TKT-XXXXXXXX` — a stable, unique-looking reference (base36 of the id, not a real random code). */
|
||||
function makeReferenceCode(id: number): string {
|
||||
return `TKT-${(id * 2_654_435_761 % 0xffffffff).toString(36).toUpperCase().padStart(8, '0').slice(-8)}`;
|
||||
@@ -288,4 +345,56 @@ export const ticketsMockApi: TicketsApi = {
|
||||
t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt });
|
||||
return { messageId: id, ticketId, sentAt };
|
||||
},
|
||||
|
||||
// ── Admin lens ──────────────────────────────────────────────────────────────────────────────────
|
||||
// The global queue over EVERY ticket (own-scoping is a user-view rule), filterable the way the b15
|
||||
// admin console filters it.
|
||||
listAdminTickets: async (
|
||||
filters: AdminTicketFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminTicketSummary>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
let all = [...tickets];
|
||||
if (filters.status) all = all.filter((t) => t.status === filters.status);
|
||||
if (filters.category) all = all.filter((t) => t.category === filters.category);
|
||||
if (filters.referenceCode) {
|
||||
const needle = filters.referenceCode.trim().toLowerCase();
|
||||
all = all.filter((t) => t.referenceCode.toLowerCase().includes(needle));
|
||||
}
|
||||
if (filters.bookingId != null) all = all.filter((t) => t.bookingId === filters.bookingId);
|
||||
if (filters.refundId != null) all = all.filter((t) => t.refundId === filters.refundId);
|
||||
all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a)));
|
||||
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).map(toAdminSummary),
|
||||
total: all.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
// The admin thread — the FULL store INCLUDING the internal note (`isInternal: true`), so the admin
|
||||
// no-leak-inverse is demonstrable. Opening it does NOT clear a user's unread indicator.
|
||||
getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
lastAdminViewerUserId = viewerUserId ?? ADMIN;
|
||||
return toAdminDetail(t, lastAdminViewerUserId);
|
||||
},
|
||||
|
||||
// Staff post — appends with the given `internal` flag (the one caller allowed to). A staff caller may
|
||||
// also post to a closed ticket (contract: only NON-staff get a 403 there), so no closed-guard here.
|
||||
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) {
|
||||
throw new ApiError(500, 'Simulated send failure', 'mock_send_failed');
|
||||
}
|
||||
const id = nextMessageId++;
|
||||
const sentAt = new Date().toISOString();
|
||||
t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
|
||||
return { messageId: id, ticketId, sentAt };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
|
||||
export const TICKET_THREAD_STALE_TIME = 15 * 1000;
|
||||
export const TICKETS_GC_TIME = 5 * 60 * 1000;
|
||||
|
||||
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
|
||||
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000;
|
||||
|
||||
/**
|
||||
* DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message
|
||||
* body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* The full admin ticket thread (b15 `GET /admin/tickets/{id}`) — header + participants + messages,
|
||||
* **internal notes INCLUDED**. A single cached `adminDetail(id)` entry (the contract returns the whole
|
||||
* thread in one call). The viewer id drives which bubbles are "mine"; `useTicketViewer` yields the admin
|
||||
* "me" under the admin console (real path uses the authenticated id, mock falls back to the admin id), and
|
||||
* the mock defaults to an admin viewer if none is passed. `usePostAdminMessage` mutates this same entry.
|
||||
*/
|
||||
export function useAdminTicket(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import type { AdminTicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* Just the messages of an admin thread — a `select` over the same `adminDetail(id)` cache the admin header
|
||||
* reads (mirrors `useTicketThread`). One network fetch feeds both; the message list re-renders on a new
|
||||
* message without re-rendering the thread header. Because it is the admin view, the returned messages CARRY
|
||||
* `isInternal` (internal-styled bubbles render here — they never do in the user thread). Optimistic admin
|
||||
* sends mutate `adminDetail(id)`, so the list updates instantly.
|
||||
*/
|
||||
export function useAdminTicketThread(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
select: (detail): AdminTicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ADMIN_TICKETS_LIST_STALE_TIME, TICKETS_GC_TIME, TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type { AdminTicketFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (b15 `GET /admin/tickets`) — EVERY ticket, not one viewer's, filterable by
|
||||
* status/category/referenceCode/bookingId/refundId. The **filter object + page key the cache**, so each
|
||||
* filter/page combination caches independently and revisiting serves from cache; `keepPreviousData` avoids a
|
||||
* flash while a filter changes. Posting an admin message invalidates `adminLists()`, so new activity shows
|
||||
* without a manual refresh.
|
||||
*/
|
||||
export function useAdminTickets(filters: AdminTicketFilters = {}, page = 1) {
|
||||
const params: PageParams = { page, pageSize: TICKETS_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminList(filters, params),
|
||||
queryFn: () => ticketsApi.listAdminTickets(filters, params),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: ADMIN_TICKETS_LIST_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { AdminTicketDetail, AdminTicketMessage, PostMessageResult } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostAdminMessageVars {
|
||||
body: string;
|
||||
/** Staff-only: a `true` posts an internal note (never reaches the user view). */
|
||||
isInternal: boolean;
|
||||
/** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
interface PostAdminMessageContext {
|
||||
previous?: AdminTicketDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin optimistic message send — the same pattern as `usePostMessage`, but over the `adminDetail(id)`
|
||||
* cache and carrying `isInternal` so a pending internal note shows its internal styling immediately.
|
||||
*
|
||||
* `onMutate` appends a **pending** `AdminTicketMessage` (with its `isInternal`) after `cancelQueries` + a
|
||||
* snapshot. `onError` rolls the thread back (composer keeps the draft + offers retry). `onSuccess` reconciles
|
||||
* the pending bubble **by `clientMessageId`** with the server message (never double-rendered). `onSettled`
|
||||
* invalidates the admin thread + the admin queues (a new note moves the queue's activity).
|
||||
*/
|
||||
export function usePostAdminMessage(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
const { role } = useTicketViewer();
|
||||
|
||||
return useMutation<PostMessageResult, unknown, PostAdminMessageVars, PostAdminMessageContext>({
|
||||
mutationFn: ({ body, isInternal, clientMessageId }) =>
|
||||
ticketsApi.postAdminMessage(ticketId, { body, isInternal, clientMessageId }),
|
||||
|
||||
onMutate: async ({ body, isInternal, clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: AdminTicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
isInternal,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<AdminTicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.adminDetail(ticketId), context.previous);
|
||||
},
|
||||
|
||||
onSuccess: (result, { clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
const current = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<AdminTicketDetail>(key, {
|
||||
...current,
|
||||
messages: current.messages.map((m) =>
|
||||
m.clientMessageId === clientMessageId
|
||||
? { ...m, id: result.messageId, createdAt: result.sentAt, sendStatus: 'sent' }
|
||||
: m,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,3 +7,9 @@ export { useTicket } from './hooks/useTicket';
|
||||
export { useTicketThread } from './hooks/useTicketThread';
|
||||
export { useOpenTicket } from './hooks/useOpenTicket';
|
||||
export { usePostMessage } from './hooks/usePostMessage';
|
||||
|
||||
// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post.
|
||||
export { useAdminTickets } from './hooks/useAdminTickets';
|
||||
export { useAdminTicket } from './hooks/useAdminTicket';
|
||||
export { useAdminTicketThread } from './hooks/useAdminTicketThread';
|
||||
export { usePostAdminMessage } from './hooks/usePostAdminMessage';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TicketListParams } from './types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { AdminTicketFilters, TicketListParams } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the tickets domain (hierarchical, per the `services/{domain}` pattern).
|
||||
@@ -18,4 +19,13 @@ export const ticketKeys = {
|
||||
|
||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
|
||||
|
||||
// Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user
|
||||
// caches above (and invalidating one never touches the other). Filters + page key the global queue.
|
||||
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
|
||||
adminList: (filters: AdminTicketFilters, params: PageParams) =>
|
||||
[...ticketKeys.adminLists(), filters, params] as const,
|
||||
|
||||
adminDetails: () => [...ticketKeys.all, 'admin', 'detail'] as const,
|
||||
adminDetail: (ticketId: number) => [...ticketKeys.adminDetails(), ticketId] as const,
|
||||
};
|
||||
|
||||
@@ -141,6 +141,62 @@ export interface PostMessageResult {
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
/* ── Admin ticket lens (b15 `GET /admin/tickets…`) ───────────────────────────────────────────────────
|
||||
* The **admin** surface is a DELIBERATELY SEPARATE model from the user types above. The admin view
|
||||
* INCLUDES internal notes (`GET /admin/tickets/{id}`), so its message/detail shapes carry `isInternal` —
|
||||
* a flag the user types (`TicketMessage`/`TicketDetail`) must NEVER gain (an internal note can't bleed
|
||||
* into the user app; contract "Critical rules" + phase §5). Keeping the two surfaces distinct is the
|
||||
* enforcement: there is no `isInternal` on the user side to accidentally read/render.
|
||||
*/
|
||||
|
||||
/** Admin thread message — INCLUDES the internal-note flag (user types never do). */
|
||||
export interface AdminTicketMessage {
|
||||
id: number | null;
|
||||
clientMessageId?: string;
|
||||
ticketId: number;
|
||||
body: string;
|
||||
authorRole: TicketAuthorRole;
|
||||
createdAt: string;
|
||||
isMine: boolean;
|
||||
isInternal: boolean;
|
||||
sendStatus: MessageSendStatus;
|
||||
}
|
||||
export interface AdminTicketDetail {
|
||||
id: number;
|
||||
referenceCode: string;
|
||||
subject: string | null;
|
||||
status: TicketStatus;
|
||||
category: TicketCategory;
|
||||
bookingId: number | null;
|
||||
refundId: number | null;
|
||||
openedById: number;
|
||||
closedAt: string | null;
|
||||
participants: TicketParticipant[];
|
||||
messages: AdminTicketMessage[];
|
||||
}
|
||||
export interface AdminTicketSummary {
|
||||
id: number;
|
||||
referenceCode: string;
|
||||
subject: string | null;
|
||||
status: TicketStatus;
|
||||
category: TicketCategory;
|
||||
bookingId: number | null;
|
||||
refundId: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface AdminTicketFilters {
|
||||
status?: TicketStatus;
|
||||
category?: TicketCategory;
|
||||
referenceCode?: string;
|
||||
bookingId?: number;
|
||||
refundId?: number;
|
||||
}
|
||||
export interface PostAdminMessageRequest {
|
||||
body: string;
|
||||
isInternal: boolean;
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tickets API seam — the real HTTP client and the in-memory mock both implement this; selection is by
|
||||
* config (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. `getTicket` takes the viewer's user id
|
||||
@@ -157,4 +213,11 @@ export interface TicketsApi {
|
||||
*/
|
||||
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
|
||||
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
|
||||
|
||||
/* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a
|
||||
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`
|
||||
* returns the thread WITH internal notes; `postAdminMessage` may set `isInternal` (staff only). */
|
||||
listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>;
|
||||
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
|
||||
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,112 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types';
|
||||
import { ADMIN_QUEUE_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminVerificationCase,
|
||||
AdminVerificationQueueFilters,
|
||||
AdminVerificationQueueItem,
|
||||
AdminVerificationStepDetail,
|
||||
CredentialDetailsInput,
|
||||
DecideStepInput,
|
||||
DecideStepResult,
|
||||
DocumentConfirmedResult,
|
||||
IdentityKycInput,
|
||||
NurseCredential,
|
||||
RunStepResult,
|
||||
SignedDocumentUrl,
|
||||
TrustBadge,
|
||||
UploadUrlResult,
|
||||
VerificationAggregateStatus,
|
||||
VerificationApi,
|
||||
VerificationDocument,
|
||||
VerificationStatus,
|
||||
VerificationStepStatus,
|
||||
} from '../types';
|
||||
|
||||
const BASE = '/api/v1/nurse_verification';
|
||||
const NURSES_BASE = '/api/v1/nurses';
|
||||
const ADMIN_BASE = '/api/v1/admin_verifications';
|
||||
|
||||
/** `AdminPendingStepDto` — one **row per step** awaiting attention (not per nurse); documents carry signed GET URLs. */
|
||||
interface AdminPendingStepWire {
|
||||
nurseVerificationId: number;
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
stepId: number;
|
||||
stepCode: string;
|
||||
stepDisplayName: string;
|
||||
status: VerificationStepStatus;
|
||||
submittedAt: string | null;
|
||||
documents: VerificationDocument[];
|
||||
}
|
||||
|
||||
/** `AdminStepDetailDto` — note the id lives under `stepId` on the wire (mapped to `id`). */
|
||||
interface AdminStepDetailWire {
|
||||
stepId: number;
|
||||
code: string;
|
||||
displayName: string;
|
||||
status: VerificationStepStatus;
|
||||
isAutomated: boolean;
|
||||
expiresAt: string | null;
|
||||
failureReason: string | null;
|
||||
documents: VerificationDocument[];
|
||||
}
|
||||
|
||||
/** `AdminVerificationDetailDto`. */
|
||||
interface AdminVerificationDetailWire {
|
||||
nurseVerificationId: number;
|
||||
nurseId: number;
|
||||
identityName: string;
|
||||
status: VerificationAggregateStatus;
|
||||
steps: AdminStepDetailWire[];
|
||||
credentials: NurseCredential[];
|
||||
}
|
||||
|
||||
/**
|
||||
* REQ-034: the queue DTO is **per step**, so we fold rows to one item per nurse for the queue UI. This is
|
||||
* lossy — the per-step page carries no whole-nurse aggregate (`stepsPassed`/`stepsTotal`/expiry), and a
|
||||
* nurse's steps can straddle page boundaries — which is why a nurse-level queue endpoint is filed. We map
|
||||
* what the row gives (nurse identity, the step as `nextPendingStepCode`, `submittedAt`) and leave the
|
||||
* unavailable aggregate fields at neutral defaults.
|
||||
*/
|
||||
function foldQueueRows(rows: AdminPendingStepWire[]): AdminVerificationQueueItem[] {
|
||||
const byNurse = new Map<number, AdminVerificationQueueItem>();
|
||||
for (const row of rows) {
|
||||
const existing = byNurse.get(row.nurseVerificationId);
|
||||
if (existing) {
|
||||
if (row.submittedAt && (existing.submittedAt == null || row.submittedAt < existing.submittedAt)) {
|
||||
existing.submittedAt = row.submittedAt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byNurse.set(row.nurseVerificationId, {
|
||||
nurseVerificationId: row.nurseVerificationId,
|
||||
nurseId: row.nurseId,
|
||||
nurseName: row.nurseName,
|
||||
status: 'in_review',
|
||||
stepsPassed: 0,
|
||||
stepsTotal: 0,
|
||||
nextPendingStepCode: row.stepCode,
|
||||
submittedAt: row.submittedAt,
|
||||
hasExpiringCredential: false,
|
||||
});
|
||||
}
|
||||
return Array.from(byNurse.values());
|
||||
}
|
||||
|
||||
function toStepDetail(wire: AdminStepDetailWire): AdminVerificationStepDetail {
|
||||
return {
|
||||
id: wire.stepId,
|
||||
code: wire.code,
|
||||
displayName: wire.displayName,
|
||||
status: wire.status,
|
||||
isAutomated: wire.isAutomated,
|
||||
expiresAt: wire.expiresAt,
|
||||
failureReason: wire.failureReason,
|
||||
documents: wire.documents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
|
||||
@@ -120,4 +212,62 @@ export const verificationClientApi: VerificationApi = {
|
||||
|
||||
getTrustBadge: async (nurseId) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
|
||||
|
||||
listVerificationQueue: async (
|
||||
filters: AdminVerificationQueueFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminVerificationQueueItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.status) query.set('status', filters.status);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<AdminPendingStepWire>>>(`${ADMIN_BASE}?${query.toString()}`),
|
||||
);
|
||||
// REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint
|
||||
// exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact.
|
||||
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
|
||||
},
|
||||
|
||||
getVerificationCase: async (nurseVerificationId: number): Promise<AdminVerificationCase> => {
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<AdminVerificationDetailWire>>(`${ADMIN_BASE}/${nurseVerificationId}`),
|
||||
);
|
||||
return {
|
||||
nurseVerificationId: wire.nurseVerificationId,
|
||||
nurseId: wire.nurseId,
|
||||
identityName: wire.identityName,
|
||||
status: wire.status,
|
||||
steps: wire.steps.map(toStepDetail),
|
||||
credentials: wire.credentials,
|
||||
};
|
||||
},
|
||||
|
||||
// REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on
|
||||
// the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an
|
||||
// on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`.
|
||||
getDocumentSignedUrl: async (documentId: number): Promise<SignedDocumentUrl> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<SignedDocumentUrl>>(`${ADMIN_BASE}/documents/${documentId}/url`)),
|
||||
|
||||
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
// REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step
|
||||
// `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and
|
||||
// `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step).
|
||||
approveVerification: async (nurseVerificationId: number): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
|
||||
},
|
||||
|
||||
rejectVerification: async (nurseVerificationId: number, reason: string): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type {
|
||||
AdminVerificationCase,
|
||||
AdminVerificationQueueItem,
|
||||
AdminVerificationStepDetail,
|
||||
CredentialType,
|
||||
IdentityKycInput,
|
||||
NurseCredential,
|
||||
StepTypeCode,
|
||||
TrustBadge,
|
||||
VerificationApi,
|
||||
@@ -10,7 +15,7 @@ import type {
|
||||
VerificationStep,
|
||||
VerificationStepStatus,
|
||||
} from '../types';
|
||||
import { NATIONAL_ID_LENGTH } from '../constants';
|
||||
import { ADMIN_QUEUE_PAGE_SIZE, NATIONAL_ID_LENGTH } from '../constants';
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
@@ -73,6 +78,194 @@ function seedSteps(): void {
|
||||
}));
|
||||
}
|
||||
|
||||
/* --- Admin review queue fixtures + state ---------------------------------------------------------
|
||||
* A small in-memory review desk: three nurses spanning `pending` / `in_review`, each a full case with
|
||||
* ordered steps (automated steps `passed`, a manual credential-bearing step `in_review` with a document),
|
||||
* one nurse carrying an expiring credential. Decisions mutate this state so a human can watch a case move
|
||||
* through decide → approve/reject and drop off the queue. Timestamps are relative to module-load `Date.now()`.
|
||||
*/
|
||||
|
||||
const CREDENTIAL_BEARING: ReadonlySet<string> = new Set([
|
||||
'moh_competency_license',
|
||||
'ino_membership',
|
||||
'criminal_record',
|
||||
]);
|
||||
|
||||
/** Internal record: the admin case + the queue-only metadata (`nurseName`, `submittedAt`, expiry flag). */
|
||||
interface AdminCaseRecord extends AdminVerificationCase {
|
||||
nurseName: string;
|
||||
submittedAt: string | null;
|
||||
hasExpiringCredential: boolean;
|
||||
}
|
||||
|
||||
let nextAdminStepId = 1;
|
||||
let nextCredentialId = 8001;
|
||||
|
||||
const adminNowMs = Date.now();
|
||||
const daysAgo = (n: number): string => new Date(adminNowMs - n * 24 * 60 * 60 * 1000).toISOString();
|
||||
const daysFromNow = (n: number): string => new Date(adminNowMs + n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
function mkStep(
|
||||
code: StepTypeCode,
|
||||
status: VerificationStepStatus,
|
||||
isAutomated: boolean,
|
||||
extra: Partial<Pick<AdminVerificationStepDetail, 'expiresAt' | 'failureReason' | 'documents'>> = {},
|
||||
): AdminVerificationStepDetail {
|
||||
return {
|
||||
id: nextAdminStepId++,
|
||||
code,
|
||||
displayName: code,
|
||||
status,
|
||||
isAutomated,
|
||||
expiresAt: extra.expiresAt ?? null,
|
||||
failureReason: extra.failureReason ?? null,
|
||||
documents: extra.documents ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function mkDoc(id: number, originalFileName: string): VerificationDocument {
|
||||
return {
|
||||
id,
|
||||
contentType: 'application/pdf',
|
||||
fileSizeBytes: 482_000,
|
||||
originalFileName,
|
||||
// A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open.
|
||||
url: `https://mock.balinyaar.local/docs/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function mkCredential(
|
||||
credentialType: CredentialType,
|
||||
holderNameSnapshot: string,
|
||||
issuingAuthority: string,
|
||||
opts: { issuedAt?: string | null; expiresAt?: string | null } = {},
|
||||
): NurseCredential {
|
||||
return {
|
||||
id: nextCredentialId++,
|
||||
credentialType,
|
||||
holderNameSnapshot,
|
||||
issuingAuthority,
|
||||
issuedAt: opts.issuedAt ?? null,
|
||||
expiresAt: opts.expiresAt ?? null,
|
||||
verificationMethod: 'manual',
|
||||
};
|
||||
}
|
||||
|
||||
const adminCases: AdminCaseRecord[] = [
|
||||
{
|
||||
nurseVerificationId: 501,
|
||||
nurseId: 101,
|
||||
identityName: 'مریم رضایی',
|
||||
nurseName: 'مریم رضایی',
|
||||
status: 'in_review',
|
||||
submittedAt: daysAgo(2),
|
||||
hasExpiringCredential: false,
|
||||
steps: [
|
||||
mkStep('identity_kyc', 'passed', true),
|
||||
mkStep('shahkar_match', 'passed', true),
|
||||
mkStep('moh_competency_license', 'in_review', false, { documents: [mkDoc(9001, 'moh-license.pdf')] }),
|
||||
mkStep('ino_membership', 'pending', false),
|
||||
mkStep('criminal_record', 'pending', false),
|
||||
mkStep('bank_account_verification', 'passed', true),
|
||||
],
|
||||
credentials: [],
|
||||
},
|
||||
{
|
||||
nurseVerificationId: 502,
|
||||
nurseId: 102,
|
||||
identityName: 'زهرا محمدی',
|
||||
nurseName: 'زهرا محمدی',
|
||||
status: 'pending',
|
||||
submittedAt: daysAgo(5),
|
||||
hasExpiringCredential: true,
|
||||
steps: [
|
||||
mkStep('identity_kyc', 'passed', true),
|
||||
mkStep('shahkar_match', 'passed', true),
|
||||
mkStep('moh_competency_license', 'pending', false),
|
||||
mkStep('ino_membership', 'pending', false),
|
||||
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(18) }),
|
||||
mkStep('bank_account_verification', 'passed', true),
|
||||
],
|
||||
// A recorded criminal-record credential lapsing soon — drives the `hasExpiringCredential` queue chip.
|
||||
credentials: [mkCredential('criminal_record', 'زهرا محمدی', 'ناجا', { issuedAt: daysAgo(347), expiresAt: daysFromNow(18) })],
|
||||
},
|
||||
{
|
||||
nurseVerificationId: 503,
|
||||
nurseId: 103,
|
||||
identityName: 'علی کریمی',
|
||||
nurseName: 'علی کریمی',
|
||||
status: 'in_review',
|
||||
submittedAt: daysAgo(1),
|
||||
hasExpiringCredential: false,
|
||||
steps: [
|
||||
mkStep('identity_kyc', 'passed', true),
|
||||
mkStep('shahkar_match', 'passed', true),
|
||||
mkStep('moh_competency_license', 'passed', false),
|
||||
mkStep('ino_membership', 'in_review', false, { documents: [mkDoc(9002, 'ino-membership.pdf')] }),
|
||||
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(300) }),
|
||||
mkStep('bank_account_verification', 'passed', true),
|
||||
],
|
||||
credentials: [
|
||||
mkCredential('moh_competency_license', 'علی کریمی', 'وزارت بهداشت', { issuedAt: daysAgo(120) }),
|
||||
mkCredential('criminal_record', 'علی کریمی', 'ناجا', { issuedAt: daysAgo(65), expiresAt: daysFromNow(300) }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function findCaseById(nurseVerificationId: number): AdminCaseRecord | undefined {
|
||||
return adminCases.find((record) => record.nurseVerificationId === nurseVerificationId);
|
||||
}
|
||||
|
||||
function findCaseByStepId(stepId: number): { record: AdminCaseRecord; step: AdminVerificationStepDetail } | undefined {
|
||||
for (const record of adminCases) {
|
||||
const step = record.steps.find((candidate) => candidate.id === stepId);
|
||||
if (step) return { record, step };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The next step needing an admin's eyes: an `in_review` (uploaded, awaiting decision) step first, else a `pending` one. */
|
||||
function nextPendingCode(steps: AdminVerificationStepDetail[]): string | null {
|
||||
return (
|
||||
steps.find((step) => step.status === 'in_review')?.code ??
|
||||
steps.find((step) => step.status === 'pending')?.code ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-aggregate the case status exactly as the server would after a step decision. */
|
||||
function reaggregateCase(record: AdminCaseRecord): void {
|
||||
const allPassed = record.steps.every((step) => step.status === 'passed');
|
||||
const anyInReview = record.steps.some((step) => step.status === 'in_review');
|
||||
record.status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
|
||||
}
|
||||
|
||||
function toQueueItem(record: AdminCaseRecord): AdminVerificationQueueItem {
|
||||
return {
|
||||
nurseVerificationId: record.nurseVerificationId,
|
||||
nurseId: record.nurseId,
|
||||
nurseName: record.nurseName,
|
||||
status: record.status,
|
||||
stepsPassed: record.steps.filter((step) => step.status === 'passed').length,
|
||||
stepsTotal: record.steps.length,
|
||||
nextPendingStepCode: nextPendingCode(record.steps),
|
||||
submittedAt: record.submittedAt,
|
||||
hasExpiringCredential: record.hasExpiringCredential,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return the admin-case view (drops the queue-only metadata; deep-copies so callers can't mutate the store). */
|
||||
function toCaseView(record: AdminCaseRecord): AdminVerificationCase {
|
||||
return {
|
||||
nurseVerificationId: record.nurseVerificationId,
|
||||
nurseId: record.nurseId,
|
||||
identityName: record.identityName,
|
||||
status: record.status,
|
||||
steps: record.steps.map((step) => ({ ...step, documents: step.documents.map((doc) => ({ ...doc })) })),
|
||||
credentials: record.credentials.map((credential) => ({ ...credential })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end — the
|
||||
* automated runs (identity/shahkar/bank), the manual document uploads (→ in_review), the structured
|
||||
@@ -170,6 +363,88 @@ export const verificationMockApi: VerificationApi = {
|
||||
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
|
||||
} satisfies TrustBadge;
|
||||
},
|
||||
|
||||
listVerificationQueue: async (filters, params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// Default (no status filter) shows the whole desk — both `pending` and `in_review`.
|
||||
const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
|
||||
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem);
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE;
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
|
||||
},
|
||||
|
||||
getVerificationCase: async (nurseVerificationId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const record = findCaseById(nurseVerificationId);
|
||||
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
|
||||
return toCaseView(record);
|
||||
},
|
||||
|
||||
getDocumentSignedUrl: async (documentId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// Sentinel for the viewer's error/re-request path: this document can never be signed.
|
||||
if (documentId === 9999) {
|
||||
throw new ApiError(404, 'Document not found', 'document_not_found');
|
||||
}
|
||||
// A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache.
|
||||
const sig = Math.random().toString(36).slice(2, 12);
|
||||
return {
|
||||
url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`,
|
||||
expiresInSeconds: 60,
|
||||
};
|
||||
},
|
||||
|
||||
decideStep: async (stepId, input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const found = findCaseByStepId(stepId);
|
||||
if (!found) throw new ApiError(404, 'Step not found', 'not_found');
|
||||
const { record, step } = found;
|
||||
if (input.approve) {
|
||||
step.status = 'passed';
|
||||
step.failureReason = null;
|
||||
let credentialId: number | null = null;
|
||||
// On approving a credential-bearing step with a credential number, record the (encrypted-at-rest,
|
||||
// never re-serialized) credential — mirroring the server's `nurse_credentials` write.
|
||||
if (CREDENTIAL_BEARING.has(step.code) && input.credentialNumber) {
|
||||
const credential = mkCredential(
|
||||
step.code as CredentialType,
|
||||
input.holderName ?? record.identityName,
|
||||
input.issuingAuthority ?? '',
|
||||
{ issuedAt: input.issuedAt ?? null, expiresAt: input.expiresAt ?? null },
|
||||
);
|
||||
record.credentials.push(credential);
|
||||
credentialId = credential.id;
|
||||
}
|
||||
reaggregateCase(record);
|
||||
return { stepId, stepStatus: step.status, credentialId };
|
||||
}
|
||||
const reason = input.rejectionReason?.trim();
|
||||
if (!reason) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
|
||||
step.status = 'failed';
|
||||
step.failureReason = reason;
|
||||
reaggregateCase(record);
|
||||
return { stepId, stepStatus: step.status, credentialId: null };
|
||||
},
|
||||
|
||||
approveVerification: async (nurseVerificationId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const record = findCaseById(nurseVerificationId);
|
||||
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
|
||||
record.steps = record.steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
|
||||
// Aggregate → `approved`, which drops it out of the queue's `pending`/`in_review` filter.
|
||||
record.status = 'approved';
|
||||
},
|
||||
|
||||
rejectVerification: async (nurseVerificationId, reason) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const record = findCaseById(nurseVerificationId);
|
||||
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
|
||||
if (reason.trim().length === 0) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
|
||||
// Aggregate → `rejected`, dropping it out of the queue.
|
||||
record.status = 'rejected';
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,3 +25,21 @@ export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */
|
||||
export const NATIONAL_ID_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Admin review queue — moderately fresh; a decision invalidates it. `keepPreviousData` + a per-page key
|
||||
* make paging flicker-free, so a short `staleTime` is enough.
|
||||
*/
|
||||
export const ADMIN_QUEUE_STALE_TIME = 20_000;
|
||||
export const ADMIN_QUEUE_PAGE_SIZE = 20;
|
||||
|
||||
/** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */
|
||||
export const ADMIN_CASE_STALE_TIME = 20_000;
|
||||
|
||||
/**
|
||||
* A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it
|
||||
* out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the
|
||||
* stale URL soon after the viewer closes (never retry — a failed/expired sign is surfaced, not re-hammered).
|
||||
*/
|
||||
export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000;
|
||||
export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Approve the whole verification (all required steps pass → aggregate `approved`), removing it from the
|
||||
* queue. Takes the `nurseVerificationId`; invalidates the queue and that case so both reflect the flip.
|
||||
*/
|
||||
export function useApproveVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, number>({
|
||||
mutationFn: (nurseVerificationId) => verificationApi.approveVerification(nurseVerificationId),
|
||||
onSuccess: (_void, nurseVerificationId) => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { DecideStepInput, DecideStepResult } from '../types';
|
||||
|
||||
export interface DecideStepVars {
|
||||
stepId: number;
|
||||
/** The case this step belongs to — used to invalidate exactly that case on success. */
|
||||
nurseVerificationId: number;
|
||||
input: DecideStepInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve or reject a manual step. On approving a credential-bearing step the (encrypted) credential is
|
||||
* recorded server-side and its `credentialId` comes back; a reject requires `input.rejectionReason`. On
|
||||
* success we invalidate the case (its steps re-render) and the queue (the aggregate/counts may have moved,
|
||||
* or the case may have dropped off). A domain 4xx (missing reason, holder-name mismatch) surfaces to the
|
||||
* caller's `onError`.
|
||||
*/
|
||||
export function useDecideStep() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<DecideStepResult, unknown, DecideStepVars>({
|
||||
mutationFn: ({ stepId, input }) => verificationApi.decideStep(stepId, input),
|
||||
onSuccess: (_result, { nurseVerificationId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
export interface RejectVerificationVars {
|
||||
nurseVerificationId: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the whole verification (aggregate `rejected`), removing it from the queue. Takes the
|
||||
* `nurseVerificationId` + a `reason`; invalidates the queue and that case so both reflect the change.
|
||||
*/
|
||||
export function useRejectVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, RejectVerificationVars>({
|
||||
mutationFn: ({ nurseVerificationId, reason }) => verificationApi.rejectVerification(nurseVerificationId, reason),
|
||||
onSuccess: (_void, { nurseVerificationId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { ADMIN_CASE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check.
|
||||
* Keyed per `nurseVerificationId` and only enabled once one is selected (pass `null` from the queue until a
|
||||
* row is opened). Invalidated on every decide / approve / reject so the case reflects the new step states.
|
||||
*/
|
||||
export function useVerificationCase(nurseVerificationId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.adminCase(nurseVerificationId ?? -1),
|
||||
queryFn: () => verificationApi.getVerificationCase(nurseVerificationId as number),
|
||||
enabled: nurseVerificationId != null,
|
||||
staleTime: ADMIN_CASE_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass
|
||||
* `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache — a
|
||||
* reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is
|
||||
* surfaced to the viewer's error/re-request path, not silently re-hammered.
|
||||
*/
|
||||
export function useVerificationDocumentUrl(documentId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1),
|
||||
queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number),
|
||||
enabled: documentId != null,
|
||||
staleTime: SIGNED_DOCUMENT_URL_STALE_TIME,
|
||||
gcTime: SIGNED_DOCUMENT_URL_GC_TIME,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { ADMIN_QUEUE_PAGE_SIZE, ADMIN_QUEUE_STALE_TIME } from '../constants';
|
||||
import type { AdminVerificationQueueFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin review queue (one item per nurse), filtered by `status` and paginated. `filters` + `params`
|
||||
* are part of the query key, so switching the status filter or paging reuses cached pages; `keepPreviousData`
|
||||
* avoids an empty flash while the next page loads. A decision (`useDecideStep` / approve / reject) invalidates
|
||||
* the queue so the desk re-renders without a manual refetch.
|
||||
*/
|
||||
export function useVerificationQueue(filters: AdminVerificationQueueFilters, page: number) {
|
||||
const params = { page, pageSize: ADMIN_QUEUE_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.adminQueue(filters, params),
|
||||
queryFn: () => verificationApi.listVerificationQueue(filters, params),
|
||||
staleTime: ADMIN_QUEUE_STALE_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -5,3 +5,11 @@ export { useRunBankVerification } from './hooks/useRunBankVerification';
|
||||
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
|
||||
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
|
||||
export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
|
||||
|
||||
// Admin review queue (b6 AdminVerificationsController)
|
||||
export { useVerificationQueue } from './hooks/useVerificationQueue';
|
||||
export { useVerificationCase } from './hooks/useVerificationCase';
|
||||
export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl';
|
||||
export { useDecideStep } from './hooks/useDecideStep';
|
||||
export { useApproveVerification } from './hooks/useApproveVerification';
|
||||
export { useRejectVerification } from './hooks/useRejectVerification';
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { AdminVerificationQueueFilters } from './types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the verification domain. The nurse's own `status()` is the **single
|
||||
* cached source** that both B3 (checklist) and B6 (under-review) read — one query, two views. Every
|
||||
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
|
||||
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
|
||||
*
|
||||
* The admin subtree (`admin()` → queue / case / document-url) mirrors the same hierarchy: each queue
|
||||
* variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()`
|
||||
* prefixes let a decision invalidate every queue page and a single case in one call.
|
||||
*/
|
||||
export const verificationKeys = {
|
||||
all: ['verification'] as const,
|
||||
@@ -15,4 +22,21 @@ export const verificationKeys = {
|
||||
|
||||
// The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile.
|
||||
badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const,
|
||||
|
||||
// --- Admin review queue (b6 AdminVerificationsController) ---
|
||||
admin: () => [...verificationKeys.all, 'admin'] as const,
|
||||
|
||||
// The review queue — `filters` + `params` are part of the key, so paging / changing the status filter
|
||||
// never refetches a page already in cache (React Query hashes keys deterministically).
|
||||
adminQueues: () => [...verificationKeys.admin(), 'queue'] as const,
|
||||
adminQueue: (filters: AdminVerificationQueueFilters, params: PageParams) =>
|
||||
[...verificationKeys.adminQueues(), filters, params] as const,
|
||||
|
||||
// A single nurse's full case — invalidated on every decide / approve / reject.
|
||||
adminCases: () => [...verificationKeys.admin(), 'case'] as const,
|
||||
adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const,
|
||||
|
||||
// A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand).
|
||||
adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const,
|
||||
adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* credential **types** only.
|
||||
*/
|
||||
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
|
||||
export type VerificationAggregateStatus =
|
||||
| 'not_started'
|
||||
@@ -146,6 +148,87 @@ export interface CredentialDetailsInput {
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
/* --- Admin review queue (b6 `AdminVerificationsController`) ---------------------------------------
|
||||
* The admin-side surface of the same trust engine. The nurse builds the checklist above; an admin
|
||||
* reviews it here — works the queue, opens a case, decides each manual step, and (via a re-aggregate)
|
||||
* flips `is_verified`. `credentialNumber` is accepted only as **input** on a decide; it is NEVER on any
|
||||
* response DTO (encrypted at rest). Signed document URLs are short-lived — fetched on demand, not cached.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `AdminPendingStepDto`, folded to **one row per nurse** — the review queue item. The b6 endpoint returns
|
||||
* one row per *step* awaiting attention; the nurse-level aggregate (`stepsPassed`/`stepsTotal`/
|
||||
* `nextPendingStepCode`/`hasExpiringCredential`) is the shape the queue UI needs (a nurse-level queue
|
||||
* endpoint is filed as REQ-034; the client maps what it can).
|
||||
*/
|
||||
export interface AdminVerificationQueueItem {
|
||||
nurseVerificationId: number;
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
status: VerificationAggregateStatus;
|
||||
stepsPassed: number;
|
||||
stepsTotal: number;
|
||||
nextPendingStepCode: string | null;
|
||||
submittedAt: string | null;
|
||||
hasExpiringCredential: boolean;
|
||||
}
|
||||
|
||||
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */
|
||||
export interface AdminVerificationQueueFilters {
|
||||
status?: 'pending' | 'in_review';
|
||||
}
|
||||
|
||||
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
|
||||
export interface AdminVerificationStepDetail {
|
||||
id: number;
|
||||
code: string;
|
||||
displayName: string;
|
||||
status: VerificationStepStatus;
|
||||
isAutomated: boolean;
|
||||
expiresAt: string | null;
|
||||
failureReason: string | null;
|
||||
documents: VerificationDocument[];
|
||||
}
|
||||
|
||||
/** `AdminVerificationDetailDto` — the full case: ordered steps, recorded credentials, + the identity name for cross-check. */
|
||||
export interface AdminVerificationCase {
|
||||
nurseVerificationId: number;
|
||||
nurseId: number;
|
||||
identityName: string;
|
||||
status: VerificationAggregateStatus;
|
||||
steps: AdminVerificationStepDetail[];
|
||||
credentials: NurseCredential[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST admin_verifications/steps/{stepId}/decide`. `rejectionReason` is required when
|
||||
* `approve=false`; the credential fields (recorded only on approving a credential-bearing step) include
|
||||
* `credentialNumber` — accepted as **input** here, but never echoed back on any response.
|
||||
*/
|
||||
export interface DecideStepInput {
|
||||
approve: boolean;
|
||||
rejectionReason?: string;
|
||||
credentialNumber?: string;
|
||||
holderName?: string;
|
||||
issuingAuthority?: string;
|
||||
issuedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
verificationSource?: string;
|
||||
}
|
||||
|
||||
/** `ReviewStepResult` — the step's new status after a decision; `credentialId` is set only when one was recorded. */
|
||||
export interface DecideStepResult {
|
||||
stepId: number;
|
||||
stepStatus: VerificationStepStatus;
|
||||
credentialId: number | null;
|
||||
}
|
||||
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */
|
||||
export interface SignedDocumentUrl {
|
||||
url: string;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification domain's API seam — the real HTTP client and the in-memory mock both implement
|
||||
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -170,6 +253,20 @@ export interface VerificationApi {
|
||||
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
|
||||
/** The public trust badge for a nurse (types only). */
|
||||
getTrustBadge(nurseId: number): Promise<TrustBadge>;
|
||||
|
||||
// --- Admin review queue (b6 AdminVerificationsController) ---
|
||||
/** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */
|
||||
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<Paginated<AdminVerificationQueueItem>>;
|
||||
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
|
||||
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
|
||||
getDocumentSignedUrl(documentId: number): Promise<SignedDocumentUrl>;
|
||||
/** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
|
||||
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
|
||||
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */
|
||||
approveVerification(nurseVerificationId: number): Promise<void>;
|
||||
/** Reject the whole verification (`rejected`), removing it from the queue. */
|
||||
rejectVerification(nurseVerificationId: number, reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */
|
||||
|
||||
Reference in New Issue
Block a user