ui phase 11
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user