frontend phase 14

This commit is contained in:
hamid
2026-07-10 18:46:16 +03:30
parent 85488bc25b
commit bc51cf59b4
73 changed files with 3582 additions and 13 deletions
@@ -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 };
}