frontend phase 15
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
|
||||
import { TICKETS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminTicketDetail,
|
||||
AdminTicketFilters,
|
||||
AdminTicketMessage,
|
||||
AdminTicketSummary,
|
||||
OpenTicketRequest,
|
||||
OpenTicketResult,
|
||||
PostAdminMessageRequest,
|
||||
PostMessageRequest,
|
||||
PostMessageResult,
|
||||
TicketAuthorRole,
|
||||
@@ -100,6 +105,56 @@ function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail {
|
||||
};
|
||||
}
|
||||
|
||||
/** Admin summary shares the wire shape of the user summary (no `unread`/`lastMessageAt`). */
|
||||
function mapAdminSummary(w: TicketSummaryWire): AdminTicketSummary {
|
||||
return {
|
||||
id: w.id,
|
||||
referenceCode: w.referenceCode,
|
||||
subject: w.subject,
|
||||
status: w.status as AdminTicketSummary['status'],
|
||||
category: w.category as AdminTicketSummary['category'],
|
||||
bookingId: w.bookingId,
|
||||
refundId: w.refundId,
|
||||
createdAt: w.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin thread mapper. Unlike `mapThread`, it **keeps internal messages** and carries `isInternal` through —
|
||||
* this IS the admin view, whose whole purpose is that staff see internal notes (contract §"Critical rules").
|
||||
*/
|
||||
function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicketDetail {
|
||||
const roleBySender = new Map<number, TicketAuthorRole>(
|
||||
w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]),
|
||||
);
|
||||
const messages: AdminTicketMessage[] = w.messages.map((m) => ({
|
||||
id: m.id,
|
||||
ticketId: w.id,
|
||||
body: m.body,
|
||||
authorRole: roleBySender.get(m.senderId) ?? 'system',
|
||||
createdAt: m.sentAt,
|
||||
isMine: viewerUserId != null && m.senderId === viewerUserId,
|
||||
isInternal: m.isInternal,
|
||||
sendStatus: 'sent' as const,
|
||||
}));
|
||||
return {
|
||||
id: w.id,
|
||||
referenceCode: w.referenceCode,
|
||||
subject: w.subject,
|
||||
status: w.status as AdminTicketDetail['status'],
|
||||
category: w.category as AdminTicketDetail['category'],
|
||||
bookingId: w.bookingId,
|
||||
refundId: w.refundId,
|
||||
openedById: w.openedById,
|
||||
closedAt: w.closedAt,
|
||||
participants: w.participants.map((p) => ({
|
||||
userId: p.userId,
|
||||
roleOnTicket: (p.roleOnTicket ?? 'system') as TicketAuthorRole,
|
||||
})),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `TicketsApi` seam (b15 contract). All four methods map published routes:
|
||||
* - `listMyTickets` → `GET /tickets` (own, paginated; `Status`/`ReferenceCode`/`Page`/`PageSize`).
|
||||
@@ -150,4 +205,40 @@ export const ticketsClientApi: TicketsApi = {
|
||||
body: JSON.stringify({ body: body.body }),
|
||||
}),
|
||||
),
|
||||
|
||||
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
|
||||
listAdminTickets: async (
|
||||
filters: AdminTicketFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminTicketSummary>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.status) query.set('Status', filters.status);
|
||||
if (filters.category) query.set('Category', filters.category);
|
||||
if (filters.referenceCode) query.set('ReferenceCode', filters.referenceCode);
|
||||
if (filters.bookingId != null) query.set('BookingId', String(filters.bookingId));
|
||||
if (filters.refundId != null) query.set('RefundId', String(filters.refundId));
|
||||
query.set('Page', String(params.page ?? 1));
|
||||
query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE));
|
||||
const wire = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<TicketSummaryWire>>>(
|
||||
`${API}/admin/tickets?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return { ...wire, items: wire.items.map(mapAdminSummary) };
|
||||
},
|
||||
|
||||
getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail> => {
|
||||
// The admin view keeps internal notes — do NOT filter them here; that is the point of this endpoint.
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<TicketThreadWire>>(`${API}/admin/tickets/${ticketId}`));
|
||||
return mapAdminThread(wire, viewerUserId);
|
||||
},
|
||||
|
||||
// Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only.
|
||||
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: body.body, isInternal: body.isInternal }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user