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[]>;
}
@@ -8,6 +8,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -111,6 +112,9 @@ export const partnerCenterClientApi: PartnerCenterApi = {
if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
},
// REQ-064 — no single-booking read in the b15 contract yet; proposed shape (sibling of the list route).
getMySponsoredBookingDetail: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredBookingDetail>>(`${API}/centers/me/bookings/${bookingId}`)),
listMySettlement: async (params) => {
const q = new URLSearchParams();
q.set('page', String(params.page ?? 1));
@@ -7,6 +7,7 @@ import type {
PartnerCenterFilters,
PartnerCenterInput,
SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters,
SponsoredNurse,
} from '../types';
@@ -119,6 +120,44 @@ const BOOKINGS: Record<number, SponsoredBooking[]> = {
3: [],
};
/** The normal-path lifecycle ladder a booking climbs before any dispute/cancellation branch. */
const BOOKING_STATUS_LADDER = ['confirmed', 'in_progress', 'completed', 'closed'] as const;
/**
* A synthetic 2-4-step status timeline consistent with the booking's current `status` (REQ-064 — the wire
* has no timeline endpoint yet). Earlier steps get earlier (larger days-ago) timestamps than later ones.
*/
function buildBookingTimeline(status: string): { status: string; occurredAt: string }[] {
if (status === 'pending_payment') return [{ status: 'pending_payment', occurredAt: isoDaysAgo(1) }];
if (status === 'cancelled') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(4) },
{ status: 'cancelled', occurredAt: isoDaysAgo(3) },
];
}
if (status === 'disputed') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(6) },
{ status: 'in_progress', occurredAt: isoDaysAgo(4) },
{ status: 'completed', occurredAt: isoDaysAgo(3) },
{ status: 'disputed', occurredAt: isoDaysAgo(1) },
];
}
const stepIndex = BOOKING_STATUS_LADDER.indexOf(status as (typeof BOOKING_STATUS_LADDER)[number]);
if (stepIndex === -1) return [{ status, occurredAt: isoDaysAgo(1) }];
return BOOKING_STATUS_LADDER.slice(0, stepIndex + 1).map((s, i) => ({
status: s,
occurredAt: isoDaysAgo(stepIndex - i + 1),
}));
}
function findSponsoredBooking(bookingId: number): SponsoredBooking {
const all = Object.values(BOOKINGS).flat();
const booking = all.find((b) => b.bookingId === bookingId);
if (!booking) throw new Error(`Mock sponsored booking ${bookingId} not found`);
return booking;
}
/** 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);
@@ -241,6 +280,10 @@ export const partnerCenterMockApi: PartnerCenterApi = {
if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params));
},
getMySponsoredBookingDetail: async (bookingId: number): Promise<SponsoredBookingDetail> => {
const booking = findSponsoredBooking(bookingId);
return delay({ ...booking, timeline: buildBookingTimeline(booking.status) });
},
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.
@@ -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 scoped, read-only booking detail behind a sponsored-bookings-list row (REQ-064) — dates + a status
* timeline only, no clinical content. `bookingId` is `undefined` while the route param hasn't resolved yet
* (mirrors the disabled-until-ready pattern used by the nurse payout detail hook).
*/
export function useMySponsoredBookingDetail(bookingId: number | undefined) {
return useQuery({
queryKey: centerKeys.mySponsoredBookingDetail(bookingId ?? -1),
queryFn: () => partnerCenterApi.getMySponsoredBookingDetail(bookingId as number),
enabled: bookingId != null && Number.isFinite(bookingId),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -13,4 +13,5 @@ export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCe
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySponsoredBookingDetail } from './hooks/useMySponsoredBookingDetail';
export { useMySettlement } from './hooks/useMySettlement';
@@ -21,5 +21,6 @@ export const centerKeys = {
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySponsoredBookingDetail: (bookingId: number) => [...centerKeys.myCenter(), 'bookings', bookingId] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
};
+19 -3
View File
@@ -72,6 +72,16 @@ export interface SponsoredBooking {
status: string;
}
/**
* The portal's scoped, read-only booking detail (REQ-064) — `SponsoredBooking` plus a server-truth status
* timeline. Deliberately bounded to dates/status/patient display name: no clinical content, no address, no
* money — the portal never sees more than a center legally needs to confirm a booking happened.
*/
export interface SponsoredBookingDetail extends SponsoredBooking {
/** Server-truth status timeline for this booking — dates only, no clinical content (portal scope). */
timeline: { status: string; occurredAt: string }[];
}
/** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
@@ -104,10 +114,14 @@ export interface PartnerCenterFilters {
isActive?: boolean;
}
/** Bookings list filter (portal). */
export interface SponsoredBookingFilters {
/**
* Bookings list filter (portal). A `type` (not `interface`) so it satisfies `useAdminListState`'s
* `Record<string, unknown>` generic constraint — a plain interface has no implicit index signature and
* TS rejects it as a type argument there, even though it's structurally identical.
*/
export type SponsoredBookingFilters = {
status?: string;
}
};
/**
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
@@ -127,6 +141,8 @@ export interface PartnerCenterApi {
getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
/** REQ-064 — the scoped read-only detail behind a bookings-list row (dates + status timeline only). */
getMySponsoredBookingDetail(bookingId: number): Promise<SponsoredBookingDetail>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
}
@@ -262,4 +262,18 @@ export const ticketsClientApi: TicketsApi = {
}),
),
// Lifecycle (REQ-063 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship;
// gated behind `TICKET_LIFECYCLE_ENABLED` on the caller side until then.
closeTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/close`, { method: 'POST' });
},
reopenTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/reopen`, { method: 'POST' });
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/assign`, {
method: 'POST',
body: JSON.stringify({ ownerUserId }),
});
},
};
@@ -67,6 +67,8 @@ interface StoredTicket {
messages: StoredMessage[];
/** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */
unread: number;
/** The staff member the ticket is assigned to (REQ-063, mock-only — no wire field yet). */
assigneeUserId: number | null;
}
const CUSTOMER = MOCK_VIEWER_USER_ID.customer;
@@ -117,6 +119,7 @@ const tickets: StoredTicket[] = [
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 1,
assigneeUserId: null,
messages: [
{ id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) },
{ id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) },
@@ -137,6 +140,7 @@ const tickets: StoredTicket[] = [
closedAt: null,
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
assigneeUserId: null,
messages: [
{ id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) },
{ id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) },
@@ -154,6 +158,7 @@ const tickets: StoredTicket[] = [
closedAt: isoMinsAgo(4_000),
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0,
assigneeUserId: ADMIN,
messages: [
{ id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) },
{ id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) },
@@ -280,6 +285,7 @@ function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail
closedAt: t.closedAt,
participants: t.participants,
messages,
assigneeUserId: t.assigneeUserId,
};
}
@@ -343,6 +349,7 @@ export const ticketsMockApi: TicketsApi = {
refundId: body.refundId ?? null,
openedById: opener,
closedAt: null,
assigneeUserId: null,
participants,
unread: 0,
messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }],
@@ -424,4 +431,25 @@ export const ticketsMockApi: TicketsApi = {
t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
return { messageId: id, ticketId, sentAt };
},
// Lifecycle (REQ-063). Mock is the source of truth here — no wire route exists yet.
closeTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'closed';
t.closedAt = new Date().toISOString();
},
reopenTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'open';
t.closedAt = null;
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.assigneeUserId = ownerUserId;
},
};
+8
View File
@@ -43,6 +43,14 @@ export const TICKETS_ATTACHMENTS_ENABLED = false;
/** 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;
/**
* Ticket lifecycle controls (close/reopen/assign) capability gate — mirrors the `TICKETS_ATTACHMENTS_ENABLED`
* pattern above. Default **off**: the backend has no close/reopen/assign routes yet (REQ-063), so the
* real-path controls stay hidden rather than pointing at a route that would 404. Flip once the endpoints
* land — no component change beyond this flag.
*/
export const TICKET_LIFECYCLE_ENABLED = false;
/**
* 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,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Assign a ticket to a staff owner (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job;
* today the only caller is "assign to me"). Invalidates the admin thread + queue on success.
*/
export function useAssignTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, { ownerUserId: number }>({
mutationFn: ({ ownerUserId }) => ticketsApi.assignTicket(ticketId, ownerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Close an open ticket (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket leaves the open worklist immediately.
*/
export function useCloseTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.closeTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Reopen a closed ticket (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket reappears in the open worklist immediately.
*/
export function useReopenTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.reopenTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
+5
View File
@@ -15,3 +15,8 @@ export { useAdminTickets } from './hooks/useAdminTickets';
export { useAdminTicket } from './hooks/useAdminTicket';
export { useAdminTicketThread } from './hooks/useAdminTicketThread';
export { usePostAdminMessage } from './hooks/usePostAdminMessage';
// Ticket lifecycle (ui-phase-11, REQ-063) — close/reopen/assign, gated behind TICKET_LIFECYCLE_ENABLED.
export { useCloseTicket } from './hooks/useCloseTicket';
export { useReopenTicket } from './hooks/useReopenTicket';
export { useAssignTicket } from './hooks/useAssignTicket';
+15
View File
@@ -176,6 +176,12 @@ export interface AdminTicketDetail {
closedAt: string | null;
participants: TicketParticipant[];
messages: AdminTicketMessage[];
/**
* The staff member the ticket is assigned to, or `null` when unassigned. **Not on the wire yet** — the
* b15 admin DTOs carry no assignment field (REQ-063). Mock-only until delivered; the real mapper always
* yields `null` (never fabricates an owner).
*/
assigneeUserId?: number | null;
}
export interface AdminTicketSummary {
id: number;
@@ -229,4 +235,13 @@ export interface TicketsApi {
listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>;
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
/**
* Ticket lifecycle mutations (REQ-063 — no live route yet, gated behind `TICKET_LIFECYCLE_ENABLED`). A
* resolved ticket has no way to leave the admin queue today; these three close the loop. `assignTicket`
* sets the (currently mock-only) `assigneeUserId`.
*/
closeTicket(ticketId: number): Promise<void>;
reopenTicket(ticketId: number): Promise<void>;
assignTicket(ticketId: number, ownerUserId: number): Promise<void>;
}
@@ -6,6 +6,7 @@ import type {
AdminVerificationCase,
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
AdminVerificationQueuePage,
AdminVerificationStepDetail,
CredentialDetailsInput,
DecideStepInput,
@@ -216,9 +217,12 @@ export const verificationClientApi: VerificationApi = {
listVerificationQueue: async (
filters: AdminVerificationQueueFilters,
params: PageParams,
): Promise<Paginated<AdminVerificationQueueItem>> => {
): Promise<AdminVerificationQueuePage> => {
const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status);
// REQ-062: proposed `q` search param — the server ignores it today (no-op, never a 400) until the
// endpoint gains the filter; the client sends it so the swap is a no-op once it lands.
if (filters.search) query.set('q', filters.search);
query.set('page', String(params.page ?? 1));
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
const page = unwrap(
@@ -226,6 +230,8 @@ export const verificationClientApi: VerificationApi = {
);
// 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.
// REQ-062: `counts` stays undefined on the real path — the queue screen renders the status tabs
// without badge counts until the endpoint serves the whole-desk totals.
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
},
@@ -388,13 +388,23 @@ export const verificationMockApi: VerificationApi = {
listVerificationQueue: async (filters, params) => {
await sleep(MOCK_LATENCY_MS);
// REQ-062: whole-desk counts, computed over the ENTIRE unfiltered set — never the current
// status/search/page slice — so the status-tab badges reflect the true queue at all times.
const counts = {
pending: adminCases.filter((record) => record.status === 'pending').length,
in_review: adminCases.filter((record) => record.status === 'in_review').length,
};
// 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 search = filters.search?.trim().toLowerCase();
const matched = adminCases
.filter((record) => wanted.includes(record.status))
.filter((record) => !search || record.nurseName.toLowerCase().includes(search))
.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 };
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize, counts };
},
getVerificationCase: async (nurseVerificationId) => {
+18 -2
View File
@@ -197,6 +197,22 @@ export interface AdminVerificationQueueItem {
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */
export interface AdminVerificationQueueFilters {
status?: 'pending' | 'in_review';
/**
* Case-insensitive name/phone search (REQ-062, filed by ui-phase-11 — not yet on the wire). The mock
* matches against the seeded `nurseName`; the real client maps it to a proposed `q` query param and the
* server currently ignores it (no-ops, never throws) until the endpoint gains the filter.
*/
search?: string;
}
/**
* The queue list response — the standard paginated envelope plus optional whole-desk `counts` for the
* status tabs (REQ-062). `counts` reflects the **entire unfiltered queue** (not the current page/status/
* search), so the tab badges never drift from the true `pending`/`in_review` totals. Mock-tolerant:
* `undefined` on the real path until the endpoint serves it — callers render the tabs without counts.
*/
export interface AdminVerificationQueuePage extends Paginated<AdminVerificationQueueItem> {
counts?: { pending: number; in_review: number };
}
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
@@ -276,8 +292,8 @@ export interface VerificationApi {
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 review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
/** 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). */