frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
+178
View File
@@ -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 }),
});
},
};
+10
View File
@@ -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;
+196
View File
@@ -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 01 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);
},
};
+39
View File
@@ -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() });
},
});
}
+17
View File
@@ -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';
+30
View File
@@ -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,
};
+183
View File
@@ -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 01) 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>;
}