frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
+92 -1
View File
@@ -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 }),
}),
),
};