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
@@ -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>;
}