ui phase 10

This commit is contained in:
hamid
2026-07-19 17:13:32 +03:30
parent b638e25a0e
commit b4b8c9ea79
48 changed files with 1643 additions and 290 deletions
@@ -33,6 +33,7 @@ interface TicketSummaryWire {
createdAt: string;
lastMessageAt: string | null;
unreadCount: number;
// REQ-059 gap (extends REQ-028) — not yet on the wire; mapped as absent below until delivered.
}
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
@@ -72,6 +73,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
lastMessageAt: w.lastMessageAt,
unreadCount: w.unreadCount,
// REQ-059 gap — the wire summary has neither field yet; the card degrades gracefully (§3.1).
lastMessagePreview: null,
lastAuthorRole: null,
};
}
@@ -215,6 +219,9 @@ export const ticketsClientApi: TicketsApi = {
}),
),
// No wire aggregate yet (REQ-059) — the chrome badge (§3.1) renders only when a signal exists.
getUnreadTotal: async (): Promise<number | null> => null,
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
listAdminTickets: async (
filters: AdminTicketFilters,
+30 -3
View File
@@ -167,10 +167,28 @@ function findTicket(id: number): StoredTicket {
return t;
}
/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */
function lastMessageAt(t: StoredTicket): string {
/** Last non-internal message — the inbox's "last activity" (visible messages only; internal notes never surface). */
function lastVisibleMessage(t: StoredTicket): StoredMessage | undefined {
const visible = t.messages.filter((m) => !m.internal);
return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString();
return visible.length ? visible[visible.length - 1] : t.messages[0];
}
function lastMessageAt(t: StoredTicket): string {
return lastVisibleMessage(t)?.sentAt ?? new Date().toISOString();
}
/** First ~80 chars of the last visible message — the REQ-059 inbox preview line, mock-only until delivered. */
const PREVIEW_MAX_CHARS = 80;
function lastMessagePreview(t: StoredTicket): string | null {
const body = lastVisibleMessage(t)?.body?.trim();
if (!body) return null;
return body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}` : body;
}
function lastAuthorRole(t: StoredTicket): TicketAuthorRole | null {
const senderId = lastVisibleMessage(t)?.senderId;
if (senderId == null) return null;
return t.participants.find((p) => p.userId === senderId)?.roleOnTicket ?? 'system';
}
function toSummary(t: StoredTicket): TicketSummary {
@@ -185,6 +203,8 @@ function toSummary(t: StoredTicket): TicketSummary {
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
lastMessageAt: lastMessageAt(t),
unreadCount: t.unread,
lastMessagePreview: lastMessagePreview(t),
lastAuthorRole: lastAuthorRole(t),
};
}
@@ -331,6 +351,13 @@ export const ticketsMockApi: TicketsApi = {
return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category };
},
// §3.1 chrome support-badge — sums unread across every seeded ticket (a demo-world stand-in for a
// real server-side aggregate; REQ-059).
getUnreadTotal: async (): Promise<number | null> => {
await sleep(MOCK_LATENCY_MS);
return tickets.reduce((sum, t) => sum + t.unread, 0);
},
postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
+14
View File
@@ -26,6 +26,20 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
export const TICKET_THREAD_STALE_TIME = 15 * 1000;
export const TICKETS_GC_TIME = 5 * 60 * 1000;
/**
* Poll the open thread while it's mounted (§3.2) — TanStack Query only polls while the query has an
* active observer, so this is automatically scoped to the thread screen. Proportionate to a support
* conversation, not a real-time chat; SSE can replace this later behind the same query.
*/
export const TICKET_THREAD_REFETCH_INTERVAL = 15 * 1000;
/**
* Photo-attachment affordance capability gate (§3.3) — the composer's attachment button is designed but
* renders only when this is on. Default **off**: the object-storage linkage for ticket messages is a
* backend gap (REQ-060); flip once that contract lands — no component change beyond this flag.
*/
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;
@@ -0,0 +1,20 @@
import { useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import type { TicketDetail } from '../types';
/**
* The composer's "discard and retype" affordance on a failed bubble (§3.3) — removes it from the cached
* thread so the caller can restore its text into the composer draft. Not a mutation: a message that never
* left the client has nothing to tell the server.
*/
export function useDiscardFailedMessage() {
const queryClient = useQueryClient();
return (ticketId: number, clientMessageId: string): void => {
const key = ticketKeys.detail(ticketId);
queryClient.setQueryData<TicketDetail>(key, (current) =>
current
? { ...current, messages: current.messages.filter((m) => m.clientMessageId !== clientMessageId) }
: current,
);
};
}
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import { ticketsApi } from '../apis';
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
import type { PostMessageResult, TicketDetail } from '../types';
import { useTicketViewer } from './useTicketViewer';
interface PostMessageVars {
@@ -10,53 +10,65 @@ interface PostMessageVars {
clientMessageId: string;
}
interface PostMessageContext {
previous?: TicketDetail;
}
/**
* The optimistic message send — the interaction that must feel instant (phase §3.5).
* The optimistic message send — the interaction that must feel instant, and a failure must never lose the
* typed text (phase §3.3/§5 invariant).
*
* `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`.
* `onMutate` is idempotent on `clientMessageId`: a **fresh** send appends a pending bubble; a **retry** (the
* same id already sitting in the cache with `sendStatus: 'failed'`) flips it back to `sending` in place
* instead of appending a duplicate — so retry and first-send are the same call. `onError` does **not** roll
* the thread back — it flips the bubble to `sendStatus: 'failed'` and leaves it in the thread with its typed
* body, so the failed-bubble UI (retry / discard-and-retype) always has something to act on. `onSuccess`
* replaces the bubble **by `clientMessageId`** with the server message. `onSettled` invalidates the thread,
* the inbox lists (last-activity/unread move), and the chrome unread-total badge.
*/
export function usePostMessage(ticketId: number) {
const queryClient = useQueryClient();
const { role } = useTicketViewer();
const key = ticketKeys.detail(ticketId);
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
return useMutation<PostMessageResult, unknown, PostMessageVars>({
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 };
const current = queryClient.getQueryData<TicketDetail>(key);
if (!current) return;
const alreadyPending = current.messages.some((m) => m.clientMessageId === clientMessageId);
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: alreadyPending
? current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, body, sendStatus: 'sending' as const } : m,
)
: [
...current.messages,
{
id: null,
clientMessageId,
ticketId,
body,
authorRole: role,
createdAt: new Date().toISOString(),
isMine: true,
sendStatus: 'sending' as const,
},
],
});
},
onError: (_err, _vars, context) => {
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
onError: (_err, { clientMessageId }) => {
const current = queryClient.getQueryData<TicketDetail>(key);
if (!current) return;
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, sendStatus: 'failed' as const } : m,
),
});
},
onSuccess: (result, { clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
const current = queryClient.getQueryData<TicketDetail>(key);
if (current) {
queryClient.setQueryData<TicketDetail>(key, {
@@ -71,8 +83,9 @@ export function usePostMessage(ticketId: number) {
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
queryClient.invalidateQueries({ queryKey: key });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.unreadTotal() });
},
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME } from '../constants';
/**
* The chrome support-entry unread badge (§3.1) — a single number summed across the caller's tickets, or
* `null` when there's no signal to show (the real path, until REQ-059 lands). Callers must render the
* badge only when this is a positive number — never a fake "0 unread" placeholder. Gated on
* authentication, same posture as `useUnreadCount`.
*/
export function useSupportUnreadTotal(): number | null {
const isAuthenticated = useIsAuthenticated();
const { data } = useQuery({
queryKey: ticketKeys.unreadTotal(),
queryFn: () => ticketsApi.getUnreadTotal(),
enabled: isAuthenticated,
staleTime: TICKETS_LIST_STALE_TIME,
gcTime: TICKETS_GC_TIME,
});
return data ?? null;
}
@@ -1,14 +1,16 @@
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 { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, 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.
* `usePostMessage` mutates this same entry optimistically. **Polls while mounted** (§3.2) — TanStack Query
* scopes `refetchInterval` to active observers, so this only ticks while a thread screen is open; no
* `refetchIntervalInBackground` (the global polling posture stays polite — §5).
*/
export function useTicket(ticketId: number | undefined) {
const { userId } = useTicketViewer();
@@ -18,5 +20,6 @@ export function useTicket(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
});
}
@@ -1,7 +1,7 @@
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 { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
import type { TicketMessage } from '../types';
import { useTicketViewer } from './useTicketViewer';
@@ -9,7 +9,7 @@ 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.
* updates instantly. Polls while mounted, same as `useTicket` (§3.2) — both observers share one cache entry.
*/
export function useTicketThread(ticketId: number | undefined) {
const { userId } = useTicketViewer();
@@ -19,6 +19,7 @@ export function useTicketThread(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
select: (detail): TicketMessage[] => detail.messages,
});
}
+2
View File
@@ -7,6 +7,8 @@ export { useTicket } from './hooks/useTicket';
export { useTicketThread } from './hooks/useTicketThread';
export { useOpenTicket } from './hooks/useOpenTicket';
export { usePostMessage } from './hooks/usePostMessage';
export { useSupportUnreadTotal } from './hooks/useSupportUnreadTotal';
export { useDiscardFailedMessage } from './hooks/useDiscardFailedMessage';
// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post.
export { useAdminTickets } from './hooks/useAdminTickets';
+3
View File
@@ -20,6 +20,9 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
/** The chrome support-badge total (§3.1) — its own tiny key so it never collides with a list page's cache. */
unreadTotal: () => [...ticketKeys.all, 'unread_total'] as const,
// Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user
// caches above (and invalidating one never touches the other). Filters + page key the global queue.
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
+14 -5
View File
@@ -40,9 +40,10 @@ export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system';
export type MessageSendStatus = 'sent' | 'sending' | 'failed';
/**
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are **not**
* on the wire summary (REQ-028) — they are optional and only the mock supplies them today; the inbox
* renders the unread indicator / last-activity time only when present, else falls back to `createdAt`.
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are real
* (REQ-028, delivered). `lastMessagePreview`/`lastAuthorRole` are **not** on the wire summary yet
* (REQ-059, an extension of REQ-028) — they are optional and only the mock supplies them today; the
* inbox card degrades gracefully (no preview line, no empty slot) when they're absent.
*/
export interface TicketSummary {
id: number;
@@ -53,10 +54,12 @@ export interface TicketSummary {
bookingId: number | null;
refundId: number | null;
createdAt: string;
/** REQ-028 gap — the wire summary has no last-activity timestamp; mock-only until delivered. */
lastMessageAt?: string | null;
/** REQ-028 gap — the wire summary has no unread count; mock-only until delivered. */
unreadCount?: number;
/** REQ-059 gap — the first ~80 chars of the last non-internal message; mock-only until delivered. */
lastMessagePreview?: string | null;
/** REQ-059 gap — the last message's author role (labels the preview "you:" vs "them:"); mock-only. */
lastAuthorRole?: TicketAuthorRole | null;
}
/** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */
@@ -213,6 +216,12 @@ export interface TicketsApi {
*/
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
/**
* The chrome support-badge total (§3.1) — the sum of unread across the caller's tickets. There is no
* wire endpoint for this yet (REQ-059); the real implementation returns `null` (no signal — the badge
* renders only when a signal exists) and the mock sums its own `unreadCount`s.
*/
getUnreadTotal(): Promise<number | null>;
/* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`