frontend phase 14
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME, TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type { TicketListParams } from '../types';
|
||||
|
||||
/**
|
||||
* The "My Tickets" inbox — the caller's own tickets, newest-activity first, optionally narrowed by status.
|
||||
* The **filter object keys the cache**, so paging/filtering caches independently and revisiting the inbox
|
||||
* serves from cache; `keepPreviousData` avoids a flash on a status change. Opening a ticket or posting a
|
||||
* message invalidates `tickets.lists()`, so new activity shows without a manual refresh.
|
||||
*/
|
||||
export function useMyTickets(params: TicketListParams = {}) {
|
||||
const listParams: TicketListParams = { page: params.page ?? 1, pageSize: params.pageSize ?? TICKETS_PAGE_SIZE, status: params.status };
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.list(listParams),
|
||||
queryFn: () => ticketsApi.listMyTickets(listParams),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: TICKETS_LIST_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
import type { OpenTicketRequest, OpenTicketResult } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* Open a ticket (support / coordination from a booking / …). We pass the viewer id so the mock attributes the
|
||||
* opening message to the right actor (a nurse-opened ticket's first message renders as the nurse's, not the
|
||||
* customer's). On success we invalidate the inbox lists so the new ticket appears at the top without a manual
|
||||
* refresh (phase §7 step 1). Domain 4xx (e.g. a `403` on a disallowed booking link) surface to the caller's
|
||||
* `onError`; the dialog keeps the draft.
|
||||
*/
|
||||
export function useOpenTicket() {
|
||||
const queryClient = useQueryClient();
|
||||
const { userId } = useTicketViewer();
|
||||
return useMutation<OpenTicketResult, unknown, OpenTicketRequest>({
|
||||
mutationFn: (body) => ticketsApi.openTicket(body, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostMessageVars {
|
||||
body: string;
|
||||
/** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
interface PostMessageContext {
|
||||
previous?: TicketDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The optimistic message send — the interaction that must feel instant (phase §3.5).
|
||||
*
|
||||
* `onMutate` appends a **pending** bubble to `detail(id)` (after `cancelQueries` + a snapshot) so it shows
|
||||
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the
|
||||
* pending bubble) and rejects — the composer keeps the typed draft and offers retry (never retype). `onSuccess`
|
||||
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders).
|
||||
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the
|
||||
* draft **only** in its own `onSuccess`.
|
||||
*/
|
||||
export function usePostMessage(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
const { role } = useTicketViewer();
|
||||
|
||||
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
|
||||
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
|
||||
|
||||
onMutate: async ({ body, clientMessageId }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: TicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
|
||||
},
|
||||
|
||||
onSuccess: (result, { clientMessageId }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<TicketDetail>(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.detail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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 ticket thread (header + participants + messages, user view — internal messages already stripped).
|
||||
* A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message
|
||||
* pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine".
|
||||
* `usePostMessage` mutates this same entry optimistically.
|
||||
*/
|
||||
export function useTicket(ticketId: number | undefined) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.detail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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 { TicketMessage } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
/**
|
||||
* Just the messages of a thread — a `select` over the same `detail(id)` cache the header reads (mirrors the
|
||||
* f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on
|
||||
* a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list
|
||||
* updates instantly.
|
||||
*/
|
||||
export function useTicketThread(ticketId: number | undefined) {
|
||||
const { userId } = useTicketViewer();
|
||||
return useQuery({
|
||||
queryKey: ticketKeys.detail(ticketId ?? -1),
|
||||
queryFn: () => ticketsApi.getTicket(ticketId as number, userId),
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
select: (detail): TicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useAuth } from '@/context/auth';
|
||||
import { useActorRole } from '@/hooks';
|
||||
import { MOCK_VIEWER_USER_ID } from '../constants';
|
||||
import type { TicketAuthorRole } from '../types';
|
||||
|
||||
/**
|
||||
* The current viewer's `{ userId, role }` for the tickets domain. `userId` drives `isMine` (whose bubble is
|
||||
* whose); `role` labels an optimistic bubble's author. On the real path the authenticated `/me` id wins;
|
||||
* under mock auth (no id yet) it falls back to the per-role mock "me" so bubbles still mirror correctly in
|
||||
* whichever app is open (customer vs nurse). Not exported from the barrel — an internal helper the domain
|
||||
* hooks share.
|
||||
*/
|
||||
export function useTicketViewer(): { userId: number; role: TicketAuthorRole } {
|
||||
const [auth] = useAuth();
|
||||
const role = useActorRole();
|
||||
const userId = auth.currentUser?.id ?? MOCK_VIEWER_USER_ID[role] ?? MOCK_VIEWER_USER_ID.customer;
|
||||
return { userId, role };
|
||||
}
|
||||
Reference in New Issue
Block a user