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[]>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user