ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
@@ -4,9 +4,11 @@ import { ADMIN_PAGE_SIZE } from '../constants';
import type {
AdminApi,
AdminRole,
AdminUserSummary,
AuditFilters,
AuditLogEntry,
ConfigChange,
DirectoryUserRole,
Holiday,
HolidayFilters,
HolidayInput,
@@ -175,4 +177,18 @@ export const adminClientApi: AdminApi = {
body: JSON.stringify({ userId, role }),
});
},
// User directory (REQ-061 — routes proposed; not live). Kept real-shaped so the swap is one line.
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
const q = new URLSearchParams({ q: query });
if (roleFilter) q.set('role', roleFilter);
return unwrap(await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/search?${q.toString()}`));
},
lookupUsers: async (userIds: number[]) =>
unwrap(
await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/lookup`, {
method: 'POST',
body: JSON.stringify({ userIds }),
}),
),
};
+43
View File
@@ -2,9 +2,11 @@ import type { PageParams, Paginated } from '@/lib/api/types';
import type {
AdminApi,
AdminRole,
AdminUserSummary,
AuditFilters,
AuditLogEntry,
ConfigChange,
DirectoryUserRole,
Holiday,
HolidayFilters,
HolidayInput,
@@ -103,6 +105,34 @@ const ROLES: RoleGrant[] = [
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
];
// ── User directory (REQ-061 gap) — backs UserPicker/NursePicker + AuditLogRow's actor-name resolve.
// `phone` is the mock's own full-number field, kept out of `AdminUserSummary` — only the masked form
// ever leaves `toDirectorySummary` (the same write-then-masked PII discipline as everywhere else).
interface DirectoryEntry extends AdminUserSummary {
phone: string;
}
const DIRECTORY: DirectoryEntry[] = [
{ id: 1, displayName: 'مدیر ارشد بالین‌یار', phone: '09120000001', maskedPhone: '0912•••0001', roles: ['admin'] },
{ id: 2, displayName: 'مریم احمدی', phone: '09120000002', maskedPhone: '0912•••0002', roles: ['admin'] },
{ id: 3, displayName: 'سارا کریمی', phone: '09120000003', maskedPhone: '0912•••0003', roles: ['admin'] },
{ id: 4, displayName: 'رضا نوری', phone: '09120000004', maskedPhone: '0912•••0004', roles: ['admin'] },
{ id: 5, displayName: 'نگار صادقی', phone: '09120000005', maskedPhone: '0912•••0005', roles: ['admin'] },
{ id: 6, displayName: 'امیر حسینی', phone: '09120000006', maskedPhone: '0912•••0006', roles: ['admin'] },
{ id: 101, displayName: 'زهرا رضایی', phone: '09121110001', maskedPhone: '0912•••0001', roles: ['nurse'], nurseProfileId: 15 },
{ id: 102, displayName: 'فاطمه محمدی', phone: '09121110002', maskedPhone: '0912•••0002', roles: ['nurse'], nurseProfileId: 16 },
{ id: 103, displayName: 'لیلا صادقی', phone: '09121110003', maskedPhone: '0912•••0003', roles: ['nurse'], nurseProfileId: 17 },
{ id: 104, displayName: 'مینا رستمی', phone: '09121110004', maskedPhone: '0912•••0004', roles: ['nurse'], nurseProfileId: 18 },
{ id: 201, displayName: 'حسین یزدانی', phone: '09122220001', maskedPhone: '0912•••0001', roles: ['customer'] },
{ id: 202, displayName: 'مینا اکبری', phone: '09122220002', maskedPhone: '0912•••0002', roles: ['customer'] },
{ id: 301, displayName: 'شرکت پرستاری آرامش', phone: '09123330001', maskedPhone: '0912•••0001', roles: ['partner'] },
];
function toDirectorySummary(e: DirectoryEntry): AdminUserSummary {
const { phone: _phone, ...summary } = e;
return summary;
}
export const adminMockApi: AdminApi = {
listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
@@ -193,4 +223,17 @@ export const adminMockApi: AdminApi = {
if (r) r.revokedAt = new Date().toISOString();
return delay(undefined);
},
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
let items = DIRECTORY;
if (roleFilter) items = items.filter((e) => e.roles.includes(roleFilter));
const q = query.trim().toLowerCase();
if (q.length > 0) items = items.filter((e) => e.displayName.toLowerCase().includes(q) || e.phone.includes(q));
return delay(items.slice(0, 10).map(toDirectorySummary));
},
lookupUsers: async (userIds: number[]) => {
const ids = new Set(userIds);
return delay(DIRECTORY.filter((e) => ids.has(e.id)).map(toDirectorySummary));
},
};
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { AdminUserSummary } from '../types';
/**
* Batch id→label resolve (REQ-061, gap) — one request for every actor/owner id a page renders (`AuditLogRow`,
* role grants, …), never one request per row. Callers should memoize `userIds` (a stable array reference)
* so the query key doesn't churn every render; pass a deduped list.
*/
export function useUserLookup(userIds: number[]) {
return useQuery({
queryKey: adminKeys.userLookup(userIds),
queryFn: () => adminApi.lookupUsers(userIds),
enabled: userIds.length > 0,
staleTime: 5 * 60 * 1000,
gcTime: ADMIN_GC_TIME,
select: (data): Map<number, AdminUserSummary> => new Map(data.map((u) => [u.id, u])),
});
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { DirectoryUserRole } from '../types';
/** Minimum query length before searching — avoids a fan-out request per keystroke on 01 chars. */
const MIN_QUERY_LENGTH = 2;
/**
* Name/phone search over the admin user directory (REQ-061, gap) — backs `UserPicker`/`NursePicker`.
* The caller debounces `query`; this hook only gates on length so an empty/near-empty query renders no
* options instead of the whole directory.
*/
export function useUserSearch(query: string, roleFilter?: DirectoryUserRole) {
const trimmed = query.trim();
return useQuery({
queryKey: adminKeys.userSearch(trimmed, roleFilter),
queryFn: () => adminApi.searchUsers(trimmed, roleFilter),
enabled: trimmed.length >= MIN_QUERY_LENGTH,
staleTime: 30 * 1000,
gcTime: ADMIN_GC_TIME,
});
}
+2
View File
@@ -15,3 +15,5 @@ export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
export { useAdminRoles } from './hooks/useAdminRoles';
export { useGrantRole } from './hooks/useGrantRole';
export { useRevokeRole } from './hooks/useRevokeRole';
export { useUserSearch } from './hooks/useUserSearch';
export { useUserLookup } from './hooks/useUserLookup';
+6 -1
View File
@@ -1,5 +1,5 @@
import type { PageParams } from '@/lib/api/types';
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types';
import type { AuditFilters, DirectoryUserRole, HolidayFilters, SupportAlertFilters } from './types';
/**
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
@@ -27,4 +27,9 @@ export const adminKeys = {
roles: () => [...adminKeys.all, 'roles'] as const,
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
users: () => [...adminKeys.all, 'users'] as const,
userSearch: (query: string, roleFilter?: DirectoryUserRole) =>
[...adminKeys.users(), 'search', query, roleFilter ?? null] as const,
userLookup: (userIds: number[]) => [...adminKeys.users(), 'lookup', [...userIds].sort((a, b) => a - b)] as const,
};
+25
View File
@@ -143,6 +143,26 @@ export interface SupportAlertFilters {
ownerUserId?: number;
}
// ── User directory (admin lookup; gap — REQ-061) ─────────────────────────────────────────────────────
/** The coarse app roles a directory entry may hold — enough to label a picker option. */
export type DirectoryUserRole = 'customer' | 'nurse' | 'admin' | 'partner';
/**
* `AdminUserSummaryDto` (REQ-061, gap — no user-search endpoint exists yet). Backs `UserPicker`/
* `NursePicker`: search-by-name/phone + a batch id→label lookup, so every audited action can target a
* resolved **person** instead of a hand-typed numeric id. `maskedPhone` follows the same PII discipline
* as everywhere else (never the full number). `nurseProfileId` is set only when `roles` includes `nurse`
* — the id `NursePicker` actually needs for sponsorship/roster assignment (a different id space than
* `id`, the user id).
*/
export interface AdminUserSummary {
id: number;
displayName: string;
maskedPhone: string;
roles: DirectoryUserRole[];
nurseProfileId?: number | null;
}
// ── 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';
@@ -180,4 +200,9 @@ export interface AdminApi {
listRoles(userId?: number): Promise<RoleGrant[]>;
grantRole(userId: number, role: AdminRole): Promise<void>;
revokeRole(userId: number, role: AdminRole): Promise<void>;
// user directory (deferred-if-missing — REQ-061)
/** Search by name/phone (min 2 chars); `roleFilter` narrows to one coarse role (e.g. `NursePicker`). */
searchUsers(query: string, roleFilter?: DirectoryUserRole): Promise<AdminUserSummary[]>;
/** Batch id→label resolve — powers `AuditLogRow`'s actor names without one request per row. */
lookupUsers(userIds: number[]): Promise<AdminUserSummary[]>;
}