frontend phase 15
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* The full admin ticket thread (b15 `GET /admin/tickets/{id}`) — header + participants + messages,
|
||||
* **internal notes INCLUDED**. A single cached `adminDetail(id)` entry (the contract returns the whole
|
||||
* thread in one call). The viewer id drives which bubbles are "mine"; `useTicketViewer` yields the admin
|
||||
* "me" under the admin console (real path uses the authenticated id, mock falls back to the admin id), and
|
||||
* the mock defaults to an admin viewer if none is passed. `usePostAdminMessage` mutates this same entry.
|
||||
*/
|
||||
export function useAdminTicket(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants';
|
||||
import type { AdminTicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* Just the messages of an admin thread — a `select` over the same `adminDetail(id)` cache the admin header
|
||||
* reads (mirrors `useTicketThread`). One network fetch feeds both; the message list re-renders on a new
|
||||
* message without re-rendering the thread header. Because it is the admin view, the returned messages CARRY
|
||||
* `isInternal` (internal-styled bubbles render here — they never do in the user thread). Optimistic admin
|
||||
* sends mutate `adminDetail(id)`, so the list updates instantly.
|
||||
*/
|
||||
export function useAdminTicketThread(ticketId: number | null) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminDetail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
select: (detail): AdminTicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ADMIN_TICKETS_LIST_STALE_TIME, TICKETS_GC_TIME, TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type { AdminTicketFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (b15 `GET /admin/tickets`) — EVERY ticket, not one viewer's, filterable by
|
||||
* status/category/referenceCode/bookingId/refundId. The **filter object + page key the cache**, so each
|
||||
* filter/page combination caches independently and revisiting serves from cache; `keepPreviousData` avoids a
|
||||
* flash while a filter changes. Posting an admin message invalidates `adminLists()`, so new activity shows
|
||||
* without a manual refresh.
|
||||
*/
|
||||
export function useAdminTickets(filters: AdminTicketFilters = {}, page = 1) {
|
||||
const params: PageParams = { page, pageSize: TICKETS_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.adminList(filters, params),
|
||||
queryFn: () => ticketsApi.listAdminTickets(filters, params),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: ADMIN_TICKETS_LIST_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { AdminTicketDetail, AdminTicketMessage, PostMessageResult } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostAdminMessageVars {
|
||||
body: string;
|
||||
/** Staff-only: a `true` posts an internal note (never reaches the user view). */
|
||||
isInternal: boolean;
|
||||
/** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
interface PostAdminMessageContext {
|
||||
previous?: AdminTicketDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin optimistic message send — the same pattern as `usePostMessage`, but over the `adminDetail(id)`
|
||||
* cache and carrying `isInternal` so a pending internal note shows its internal styling immediately.
|
||||
*
|
||||
* `onMutate` appends a **pending** `AdminTicketMessage` (with its `isInternal`) after `cancelQueries` + a
|
||||
* snapshot. `onError` rolls the thread back (composer keeps the draft + offers retry). `onSuccess` reconciles
|
||||
* the pending bubble **by `clientMessageId`** with the server message (never double-rendered). `onSettled`
|
||||
* invalidates the admin thread + the admin queues (a new note moves the queue's activity).
|
||||
*/
|
||||
export function usePostAdminMessage(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
const { role } = useTicketViewer();
|
||||
|
||||
return useMutation<PostMessageResult, unknown, PostAdminMessageVars, PostAdminMessageContext>({
|
||||
mutationFn: ({ body, isInternal, clientMessageId }) =>
|
||||
ticketsApi.postAdminMessage(ticketId, { body, isInternal, clientMessageId }),
|
||||
|
||||
onMutate: async ({ body, isInternal, clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: AdminTicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
isInternal,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<AdminTicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.adminDetail(ticketId), context.previous);
|
||||
},
|
||||
|
||||
onSuccess: (result, { clientMessageId }) => {
|
||||
const key = ticketKeys.adminDetail(ticketId);
|
||||
const current = queryClient.getQueryData<AdminTicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<AdminTicketDetail>(key, {
|
||||
...current,
|
||||
messages: current.messages.map((m) =>
|
||||
m.clientMessageId === clientMessageId
|
||||
? { ...m, id: result.messageId, createdAt: result.sentAt, sendStatus: 'sent' }
|
||||
: m,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user