ui phase 11
This commit is contained in:
@@ -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 }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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 0–1 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user