export const API_BASE = import.meta.env.VITE_API_BASE || 'https://sv.rikkeiraia.org/api'; export function getWsUrl(path: string): string { try { if (API_BASE.startsWith('http://') || API_BASE.startsWith('https://')) { const url = new URL(API_BASE); const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${url.host}${path}`; } } catch (e) { // ignore } const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}${path}`; } function getToken(): string | null { return localStorage.getItem('sc_staff_token'); } export async function staffFetch(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); if (init.body && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } return fetch(`${API_BASE}${path}`, { ...init, headers }); } async function parseError(res: Response, fallback: string): Promise { const errData = await res.json().catch(() => ({})); const msg = (errData as { error?: string }).error; if (msg) throw new Error(msg); if (res.status === 413) throw new Error('File quá lớn (tối đa 100MB)'); throw new Error(`${fallback} (HTTP ${res.status})`); } export interface StaffUser { id: number; email: string; fullName: string; mustChangePassword: boolean; } export const apiAuth = { provision: async (email: string): Promise<{ ok: boolean; message: string }> => { const res = await fetch(`${API_BASE}/auth/provision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!res.ok) await parseError(res, 'Provision failed'); return res.json(); }, login: async (email: string, password: string): Promise<{ token: string; staff: StaffUser; mustChangePassword: boolean }> => { const res = await fetch(`${API_BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!res.ok) await parseError(res, 'Đăng nhập thất bại'); return res.json(); }, me: async (): Promise<{ staff: StaffUser }> => { const res = await staffFetch('/auth/me'); if (!res.ok) await parseError(res, 'Unauthorized'); return res.json(); }, changePassword: async (oldPassword: string, newPassword: string): Promise<{ ok: boolean; token: string; staff: StaffUser }> => { const res = await staffFetch('/auth/change-password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }), }); if (!res.ok) await parseError(res, 'Đổi mật khẩu thất bại'); return res.json(); }, forgotPassword: async (email: string): Promise<{ ok: boolean; message: string }> => { const res = await fetch(`${API_BASE}/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!res.ok) await parseError(res, 'Gửi mail thất bại'); return res.json(); }, resetPassword: async (token: string, newPassword: string): Promise<{ ok: boolean; message: string }> => { const res = await fetch(`${API_BASE}/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, newPassword }), }); if (!res.ok) await parseError(res, 'Đặt lại mật khẩu thất bại'); return res.json(); }, }; export interface GitHubStatus { connected: boolean; githubLogin?: string; connectedAt?: string; scope?: string; canDeleteRepos?: boolean; } export const apiGitHub = { status: async (): Promise => { const res = await staffFetch('/auth/github/status'); if (!res.ok) await parseError(res, 'Không tải trạng thái GitHub'); return res.json(); }, authorizeUrl: async (): Promise<{ authorizeUrl: string }> => { const res = await staffFetch('/auth/github/authorize'); if (!res.ok) await parseError(res, 'Không bắt đầu OAuth GitHub'); return res.json(); }, disconnect: async () => { const res = await staffFetch('/auth/github', { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Ngắt kết nối GitHub thất bại'); return res.json(); }, }; export interface GitHubRepoItem { key: string; ownerLogin: string; repoName: string; repoHtmlUrl: string; private?: boolean; examRoomId?: number; examRoomName?: string; studentLabel?: string; publishMode?: 'room' | 'student' | ''; createdAt: string; updatedAt?: string; fromSimpleCare?: boolean; } export const apiGitHubRepos = { list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => { const res = await staffFetch('/auth/github/repos'); if (!res.ok) await parseError(res, 'Không tải danh sách repo'); return res.json(); }, bulkDelete: async (repos: string[]) => { const res = await staffFetch('/auth/github/repos/bulk-delete', { method: 'POST', body: JSON.stringify({ repos }), }); if (!res.ok) await parseError(res, 'Xóa repo thất bại'); return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>; }, }; export interface EmailDomainItem { id: number; domain: string; isActive: boolean; } export const apiAdmin = { listEmailDomains: async (): Promise<{ data: EmailDomainItem[] }> => { const res = await staffFetch('/admin/email-domains'); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, addEmailDomain: async (domain: string) => { const res = await staffFetch('/admin/email-domains', { method: 'POST', body: JSON.stringify({ domain }) }); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, deleteEmailDomain: async (id: number) => { const res = await staffFetch(`/admin/email-domains/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, }; export interface ChatStudent { studentRkId: number; fullName: string; studentCode: string; email: string; online: boolean; } export interface ChatMessage { id: number; staffId: number; targetStaffId?: number; studentRkId: number; senderRole: 'staff' | 'student'; body: string; createdAt: string; staffName?: string; } export interface ChatConversation { studentRkId: number; fullName: string; studentCode: string; lastMessage: string; lastAt: string; unread: number; } export const apiChat = { listConversations: async (): Promise<{ data: ChatConversation[] }> => { const res = await staffFetch('/chat/conversations'); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, searchStudents: async (q = ''): Promise<{ data: ChatStudent[] }> => { const params = q ? `?q=${encodeURIComponent(q)}` : ''; const res = await staffFetch(`/chat/students${params}`); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, listMessages: async (studentRkId: number): Promise<{ data: ChatMessage[] }> => { const res = await staffFetch(`/chat/messages/${studentRkId}`); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, sendMessage: async (studentRkId: number, body: string): Promise<{ ok: boolean; data: ChatMessage }> => { const res = await staffFetch('/chat/messages', { method: 'POST', body: JSON.stringify({ studentRkId, body }), }); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, }; export interface ClassItem { rkId: number; name: string; classCode: string; type: string; studentCount: number; specializeRkId?: number; specializeName?: string; systemRkId?: number; systemCode?: string; systemName?: string; isStudying: boolean; courses: { courseRkId: number; courseName: string }[]; } export interface StudentItem { id: number; rkId: number; studentCode: string; fullName: string; phone?: string; email: string; dateOfBirth?: string; gender?: number; status?: string; location?: string; systemId?: number; systemName?: string; avatar?: string; } export interface StatsResponse { totalClasses: number; activeClasses: number; totalStudents: number; } export interface GuideLinkItem { id: number; createdAt: string; updatedAt: string; title: string; url: string; } export interface AppDownloadItem { id: number; createdAt: string; updatedAt: string; name: string; description: string; downloadUrl: string; platform: string; } export interface AppGuideItem { id: number; createdAt: string; updatedAt: string; title: string; url: string; } export interface PaginatedResponse { data: T[]; total: number; page: number; pageSize: number; } export interface SyncStatus { running: boolean; done: boolean; error?: string; total: number; synced: number; updatedAt: number; page?: number; pageSize?: number; } export interface ClassScheduleItem { id?: number; classRkId?: number; dayOfWeek: number; period: number; startTime: string; endTime: string; courseId: number; courseName: string; isActive: boolean; } export interface ClassCourseItem { id: number; name: string; courseCode?: string; } export interface AttendanceRow { studentRkId: number; studentCode: string; fullName: string; email: string; status: number; statusLabel: string; onlineMinutes: number; statusEditedByTeacher: boolean; pushedToQldtAt?: string; } export interface AttendanceShiftInfo { startTime?: string; endTime?: string; courseId?: number; courseName?: string; isActive?: boolean; pushedToQldtAt?: string; qldtDirty?: boolean; } export const ATTENDANCE_STATUS_OPTIONS = [ { value: 0, label: 'Nghỉ không phép', short: 'NKP' }, { value: 1, label: 'Nghỉ có phép', short: 'NCP' }, { value: 2, label: 'Nghỉ nửa buổi', short: 'Nửa buổi' }, { value: 3, label: 'Đi học muộn', short: 'Muộn' }, { value: 4, label: 'Đi học đầy đủ', short: 'Đầy đủ' }, ] as const; export function attendanceStatusClass(status: number): string { return `attendance-status--${status}`; } export function attendanceStatusLabel(status: number): string { return ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status)?.label ?? `Trạng thái ${status}`; } export interface StudentSessionLogItem { id: number; studentRkId: number; studentCode: string; fullName: string; email: string; onlineSeconds: number; offlineSeconds: number; wifiSsids: string; lastActiveAt?: string; } export interface StudentViolationItem { id: number; studentRkId: number; studentCode: string; fullName: string; classRkId: number; examRoomId?: number; kind: string; reason: string; monitorMode: string; clientAt?: string; createdAt: string; } export const VIOLATION_KIND_OPTIONS = [ { value: '', label: 'Tất cả loại' }, { value: 'app_closed', label: 'Tắt ứng dụng' }, { value: 'unclean_shutdown', label: 'Tắt đột ngột' }, { value: 'multi_monitor', label: 'Nhiều màn hình' }, { value: 'user_switch', label: 'Đổi user' }, { value: 'session_change', label: 'Khóa / đổi phiên' }, { value: 'virtual_desktop', label: 'Desktop ảo' }, { value: 'wifi', label: 'WiFi trái phép' }, { value: 'guard', label: 'Vi phạm môi trường (cũ)' }, ] as const; export const api = { getStats: async (): Promise => { const res = await staffFetch('/stats'); if (!res.ok) throw new Error('Failed to fetch dashboard stats'); return res.json(); }, listGuideLinks: async (): Promise<{ data: GuideLinkItem[] }> => { const res = await staffFetch('/guide-links'); if (!res.ok) throw new Error('Không thể tải danh sách tài liệu'); return res.json(); }, createGuideLink: async (title: string, url: string): Promise<{ data: GuideLinkItem }> => { const res = await staffFetch('/guide-links', { method: 'POST', body: JSON.stringify({ title, url }), }); if (!res.ok) await parseError(res, 'Thêm tài liệu thất bại'); return res.json(); }, updateGuideLink: async (id: number, title: string, url: string): Promise<{ data: GuideLinkItem }> => { const res = await staffFetch(`/guide-links/${id}`, { method: 'PUT', body: JSON.stringify({ title, url }), }); if (!res.ok) await parseError(res, 'Cập nhật tài liệu thất bại'); return res.json(); }, deleteGuideLink: async (id: number): Promise<{ ok: boolean }> => { const res = await staffFetch(`/guide-links/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa tài liệu thất bại'); return res.json(); }, listAppDownloads: async (): Promise<{ data: AppDownloadItem[] }> => { const res = await staffFetch('/app-downloads'); if (!res.ok) throw new Error('Không thể tải danh sách ứng dụng'); return res.json(); }, createAppDownload: async (name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => { const res = await staffFetch('/app-downloads', { method: 'POST', body: JSON.stringify({ name, description, downloadUrl, platform }), }); if (!res.ok) await parseError(res, 'Thêm ứng dụng thất bại'); return res.json(); }, updateAppDownload: async (id: number, name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => { const res = await staffFetch(`/app-downloads/${id}`, { method: 'PUT', body: JSON.stringify({ name, description, downloadUrl, platform }), }); if (!res.ok) await parseError(res, 'Cập nhật ứng dụng thất bại'); return res.json(); }, deleteAppDownload: async (id: number): Promise<{ ok: boolean }> => { const res = await staffFetch(`/app-downloads/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa ứng dụng thất bại'); return res.json(); }, listAppGuides: async (): Promise<{ data: AppGuideItem[] }> => { const res = await staffFetch('/app-guides'); if (!res.ok) throw new Error('Không thể tải danh sách tài liệu hướng dẫn'); return res.json(); }, createAppGuide: async (title: string, url: string): Promise<{ data: AppGuideItem }> => { const res = await staffFetch('/app-guides', { method: 'POST', body: JSON.stringify({ title, url }), }); if (!res.ok) await parseError(res, 'Thêm tài liệu hướng dẫn thất bại'); return res.json(); }, updateAppGuide: async (id: number, title: string, url: string): Promise<{ data: AppGuideItem }> => { const res = await staffFetch(`/app-guides/${id}`, { method: 'PUT', body: JSON.stringify({ title, url }), }); if (!res.ok) await parseError(res, 'Cập nhật tài liệu hướng dẫn thất bại'); return res.json(); }, deleteAppGuide: async (id: number): Promise<{ ok: boolean }> => { const res = await staffFetch(`/app-guides/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa tài liệu hướng dẫn thất bại'); return res.json(); }, getClasses: async (params: { page: number; pageSize: number; q?: string; systemRkId?: number; studyingOnly?: boolean; }): Promise> => { const query = new URLSearchParams({ page: String(params.page), pageSize: String(params.pageSize), }); if (params.q) query.set('q', params.q); if (params.systemRkId) query.set('systemRkId', String(params.systemRkId)); if (params.studyingOnly) query.set('studyingOnly', 'true'); const res = await staffFetch(`/classes?${query}`); if (!res.ok) throw new Error('Failed to fetch classes'); return res.json(); }, updateClassStudying: async (rkId: number, isStudying: boolean): Promise => { const res = await staffFetch(`/classes/${rkId}/studying`, { method: 'PATCH', body: JSON.stringify({ isStudying }), }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Failed to update class studying status'); } return res.json(); }, getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => { const res = await staffFetch(`/classes/${rkId}/students`); if (!res.ok) throw new Error('Failed to fetch class students roster'); return res.json(); }, getClass: async (rkId: number): Promise => { const res = await staffFetch(`/classes/${rkId}`); if (!res.ok) throw new Error('Failed to fetch class'); return res.json(); }, getStudents: async (params: { page: number; pageSize: number; q?: string; }): Promise> => { const query = new URLSearchParams({ page: String(params.page), pageSize: String(params.pageSize), }); if (params.q) query.set('q', params.q); const res = await staffFetch(`/students?${query}`); if (!res.ok) throw new Error('Failed to fetch students'); return res.json(); }, startClassesSync: async (): Promise<{ ok: boolean; message: string }> => { const res = await staffFetch('/sync/classes/start', { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Failed to start classes sync'); } return res.json(); }, getClassesSyncStatus: async (): Promise => { const res = await staffFetch('/sync/classes/status'); if (!res.ok) throw new Error('Failed to get classes sync status'); return res.json(); }, startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => { const res = await staffFetch('/sync/students/start', { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.error || 'Failed to start students sync'); } return res.json(); }, getStudentsSyncStatus: async (): Promise => { const res = await staffFetch('/sync/students/status'); if (!res.ok) throw new Error('Failed to get students sync status'); return res.json(); }, }; // Export individual learning functions to simplify imports in components export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => { const res = await staffFetch('/classes/schedules'); if (!res.ok) throw new Error('Failed to fetch active schedules'); return res.json(); }; export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => { const res = await staffFetch(`/classes/${rkId}/schedule`); if (!res.ok) throw new Error('Failed to fetch class schedule'); return res.json(); }; export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise => { const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'POST', body: JSON.stringify({ schedules }), }); if (!res.ok) throw new Error('Failed to save class schedule'); return res.json(); }; export const apiDeleteClassSchedule = async (rkId: number): Promise => { const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete class schedule'); return res.json(); }; export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => { const res = await staffFetch(`/classes/${rkId}/allowed-apps`); if (!res.ok) throw new Error('Failed to fetch allowed apps keywords'); return res.json(); }; export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise => { const res = await staffFetch(`/classes/${rkId}/allowed-apps`, { method: 'POST', body: JSON.stringify({ keywords }), }); if (!res.ok) throw new Error('Failed to save allowed apps keywords'); return res.json(); }; export interface AppPoolItem { id: number; processName: string; windowTitle: string; keyword: string; hitCount: number; lastSeenAt: string; lastStudentRkId?: number; lastClassRkId?: number; } export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPoolItem[]; q?: string; limit?: number }> => { const params = new URLSearchParams(); if (q.trim()) params.set('q', q.trim()); params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; const res = await staffFetch(`/app-pool${query}`); if (!res.ok) throw new Error('Failed to fetch app pool'); return res.json(); }; export interface AppTemplateItem { id: number; name: string; description: string; keywords: string; createdAt: string; updatedAt: string; } export const apiAppTemplates = { list: async (): Promise<{ data: AppTemplateItem[] }> => { const res = await staffFetch('/app-templates'); if (!res.ok) throw new Error('Không tải được khung ứng dụng'); return res.json(); }, create: async (body: { name: string; description?: string; keywords: string }) => { const res = await staffFetch('/app-templates', { method: 'POST', body: JSON.stringify(body) }); if (!res.ok) await parseError(res, 'Không tạo được khung'); return res.json(); }, update: async (id: number, body: { name?: string; description?: string; keywords?: string }) => { const res = await staffFetch(`/app-templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) }); if (!res.ok) await parseError(res, 'Không cập nhật được khung'); return res.json(); }, delete: async (id: number) => { const res = await staffFetch(`/app-templates/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Không xóa được khung'); return res.json(); }, }; export interface WifiPoolItem { id: number; ssid: string; bssid: string; hitCount: number; lastSeenAt: string; lastStudentRkId?: number; } export interface AcceptedWifiItem { id: number; ssid: string; bssid: string; createdAt: string; } export type WifiAcceptItem = { ssid: string; bssid: string }; export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: WifiPoolItem[] }> => { const params = new URLSearchParams(); if (q.trim()) params.set('q', q.trim()); params.set('limit', String(limit)); const query = params.toString() ? `?${params.toString()}` : ''; const res = await staffFetch(`/wifi-pool${query}`); if (!res.ok) throw new Error('Failed to fetch wifi pool'); return res.json(); }; export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => { const res = await staffFetch('/network/accepted-wifis'); if (!res.ok) throw new Error('Failed to fetch accepted wifis'); return res.json(); }; export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise => { const res = await staffFetch('/network/accepted-wifis', { method: 'POST', body: JSON.stringify({ items }), }); if (!res.ok) throw new Error('Failed to save accepted wifis'); return res.json(); }; export const apiFetchClassStudents = api.getClassStudents; export const apiFetchClasses = api.getClasses; export const apiFetchClassSessionLogs = async ( rkId: number, date?: string, period?: number ): Promise<{ data: StudentSessionLogItem[]; period?: number; date?: string }> => { const params = new URLSearchParams(); if (date) params.set('date', date); if (period) params.set('period', String(period)); const query = params.toString() ? `?${params.toString()}` : ''; const res = await staffFetch(`/classes/${rkId}/session-logs${query}`); if (!res.ok) throw new Error('Failed to fetch class session logs'); return res.json(); }; export const apiFetchClassViolations = async ( rkId: number, date: string, kind = '' ): Promise<{ data: StudentViolationItem[]; date: string }> => { const params = new URLSearchParams({ date }); if (kind) params.set('kind', kind); const res = await staffFetch(`/classes/${rkId}/violations?${params}`); if (!res.ok) throw new Error('Failed to fetch class violations'); return res.json(); }; export const apiFetchExamViolations = async ( examId: number, date: string, kind = '' ): Promise<{ data: StudentViolationItem[]; date: string }> => { const params = new URLSearchParams({ date }); if (kind) params.set('kind', kind); const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`); if (!res.ok) throw new Error('Failed to fetch exam violations'); return res.json(); }; export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { const res = await staffFetch(`/classes/${rkId}/online-students`); if (!res.ok) throw new Error('Failed to fetch online students list'); return res.json(); }; export interface ScheduleConflictItem { studentRkId: number; fullName: string; studentCode: string; conflictClassRkId: number; conflictClassName: string; conflictClassCode: string; } export const apiFetchScheduleConflicts = async (rkId: number): Promise<{ conflicts: ScheduleConflictItem[]; ok: boolean }> => { const res = await staffFetch(`/classes/${rkId}/schedule-conflicts`); if (!res.ok) throw new Error('Failed to check schedule conflicts'); return res.json(); }; export const apiFetchClassCourses = async (rkId: number): Promise<{ data: ClassCourseItem[]; source?: string; cached?: boolean; warning?: string; hint?: string; }> => { const res = await staffFetch(`/classes/${rkId}/courses`); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT'); } return body; }; export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => { const res = await staffFetch(`/classes/${rkId}/schedule/apply-template`, { method: 'POST' }); if (!res.ok) throw new Error('Failed to apply schedule template'); return res.json(); }; export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => { const q = date ? `?date=${date}` : ''; const res = await staffFetch(`/classes/${rkId}/attendance/shifts${q}`); if (!res.ok) throw new Error('Failed to fetch attendance shifts'); return res.json(); }; export const apiFetchAttendance = async (rkId: number, date: string, period: number) => { const res = await staffFetch(`/classes/${rkId}/attendance?date=${date}&period=${period}`); if (!res.ok) throw new Error('Failed to fetch attendance'); return res.json(); }; export const apiUpdateAttendanceStatus = async ( rkId: number, payload: { date: string; period: number; studentRkId: number; status: number } ) => { const res = await staffFetch(`/classes/${rkId}/attendance/status`, { method: 'PUT', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Failed to update attendance status'); } return res.json(); }; export const apiUpdateAttendanceBulkStatus = async ( rkId: number, payload: { date: string; period: number; studentRkIds: number[]; status: number } ) => { const res = await staffFetch(`/classes/${rkId}/attendance/bulk-status`, { method: 'PUT', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Failed to update attendance status'); } return res.json(); }; export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => { const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, { method: 'POST', body: JSON.stringify({ date, period }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Failed to push attendance to QLĐT'); } return res.json(); }; export interface LeaveRequestItem { id: number; date: string; note: string; reasonImage?: string; rejectReason?: string | null; period: number; status: string; // 'Đang chờ', 'Phê duyệt', 'Từ chối' approverId?: number | null; createdAt: string; student: { id: number; studentCode: string; fullName: string; phone?: string; email: string; avatar?: string; }; } export const apiFetchLeaveRequests = async ( classId: number, courseId: number, date: string ): Promise => { const res = await staffFetch(`/classes/${classId}/leave-requests?courseId=${courseId}&date=${date}`); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Failed to fetch leave requests'); } return res.json(); }; export const apiUpdateLeaveStatus = async ( classId: number, leaveId: number, payload: { status: string; studentRkId: number; date: string; period: number } ): Promise<{ ok: boolean; message: string }> => { const res = await staffFetch(`/classes/${classId}/leave-requests/${leaveId}/status`, { method: 'POST', body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Failed to update leave status'); } return res.json(); }; async function staffUpload(path: string, form: FormData): Promise { const headers = new Headers(); const token = getToken(); if (token) headers.set('Authorization', `Bearer ${token}`); return fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: form }); } export interface ExamRoomItem { id: number; name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string; gitRepoUrl?: string; gitBranch?: string; gitPublishUrl?: string; status: 'draft' | 'ready' | 'ended' | 'cancelled'; studentCount: number; paperCount: number; displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled'; } export interface ExamPaperResource { id: number; examPaperId: number; fileName: string; } export interface ExamPaper { id: number; examRoomId: number; title: string; pdfPath: string; sortOrder: number; resources: ExamPaperResource[]; } export interface ExamRoomStudent { id: number; examRoomId: number; studentRkId: number; assignedPaperId?: number; paperSentAt?: string; paperScheduledAt?: string; fullName: string; studentCode: string; email: string; avatar?: string; paperTitle?: string; submitted: boolean; } export interface ExamSubmission { id: number; examRoomId: number; studentRkId: number; fileName: string; createdAt: string; fullName: string; studentCode: string; gitRepoUrl?: string; gitPublishUrl?: string; } function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out.buffer; } export const apiExam = { list: async (): Promise<{ data: ExamRoomItem[] }> => { const res = await staffFetch('/exam-rooms'); if (!res.ok) await parseError(res, 'Failed'); return res.json(); }, create: async (payload: { name: string; startTime: string; endTime: string; allowedApps?: string; quizUrl?: string }) => { const res = await staffFetch('/exam-rooms', { method: 'POST', body: JSON.stringify(payload) }); if (!res.ok) await parseError(res, 'Tạo phòng thi thất bại'); return res.json(); }, get: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}`); if (!res.ok) await parseError(res, 'Không tìm thấy'); return res.json() as Promise<{ room: ExamRoomItem; papers: ExamPaper[]; students: ExamRoomStudent[]; displayStatus: string; editable: boolean; prepEditable: boolean; canPublish: boolean; canUnpublish: boolean; canCancel: boolean; }>; }, publish: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/publish`, { method: 'POST' }); if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string; issues?: string[] }; if (data.issues?.length) { throw new Error(data.issues.join('\n')); } throw new Error(data.error || 'Đẩy phòng thi thất bại'); } return res.json(); }, unpublish: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/unpublish`, { method: 'POST' }); if (!res.ok) await parseError(res, 'Thu hồi thất bại'); return res.json(); }, cancel: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/cancel`, { method: 'POST' }); if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại'); return res.json(); }, update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => { const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }); if (!res.ok) await parseError(res, 'Cập nhật thất bại'); return res.json(); }, remove: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa thất bại'); return res.json(); }, searchStudents: async (q: string) => { const res = await staffFetch(`/exam-rooms/search-students?q=${encodeURIComponent(q)}`); if (!res.ok) await parseError(res, 'Tìm kiếm thất bại'); return res.json() as Promise<{ data: { studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[] }>; }, addStudents: async (id: number, studentRkIds: number[]) => { const res = await staffFetch(`/exam-rooms/${id}/students`, { method: 'POST', body: JSON.stringify({ studentRkIds }) }); if (!res.ok) await parseError(res, 'Thêm sinh viên thất bại'); return res.json(); }, removeStudent: async (id: number, studentRkId: number) => { const res = await staffFetch(`/exam-rooms/${id}/students/${studentRkId}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa sinh viên thất bại'); return res.json(); }, fetchOnlineStudents: async (id: number): Promise<{ onlineStudentIds: number[] }> => { const res = await staffFetch(`/exam-rooms/${id}/online-students`); if (!res.ok) throw new Error('Không tải được trạng thái online'); return res.json(); }, uploadPackage: async (id: number, title: string, pdf: File, resources: File[] = []) => { const form = new FormData(); form.append('title', title); form.append('pdf', pdf); resources.forEach((f) => form.append('resources', f)); const res = await staffUpload(`/exam-rooms/${id}/papers`, form); if (!res.ok) await parseError(res, 'Tạo gói đề thất bại'); return res.json(); }, uploadPaper: async (id: number, title: string, pdf: File, resources: File[] = []) => { return apiExam.uploadPackage(id, title, pdf, resources); }, uploadPackageResources: async (id: number, paperId: number, files: File[]) => { const form = new FormData(); files.forEach((f) => form.append('resources', f)); const res = await staffUpload(`/exam-rooms/${id}/papers/${paperId}/resources`, form); if (!res.ok) await parseError(res, 'Thêm tài nguyên thất bại'); return res.json(); }, uploadResource: async (id: number, paperId: number, file: File) => { return apiExam.uploadPackageResources(id, paperId, [file]); }, deletePaper: async (id: number, paperId: number) => { const res = await staffFetch(`/exam-rooms/${id}/papers/${paperId}`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa gói đề thất bại'); return res.json(); }, fetchPaperPdfBytes: async (examId: number, paperId: number): Promise => { const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`); if (!res.ok) await parseError(res, 'Không mở được đề PDF'); const json = await res.json() as { data: string }; if (!json.data) throw new Error('Server không trả dữ liệu PDF'); return decodeBase64ToArrayBuffer(json.data); }, fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise => { const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`); if (!res.ok) await parseError(res, 'Không tải tài nguyên'); const json = await res.json() as { data: string }; if (!json.data) throw new Error('Server không trả dữ liệu file'); return decodeBase64ToArrayBuffer(json.data); }, downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => { const params = new URLSearchParams(); if (opts?.resourceId) { params.set('kind', 'resource'); params.set('fileId', String(opts.resourceId)); } else { params.set('kind', 'pdf'); } const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`); if (!res.ok) await parseError(res, 'Tải file thất bại'); const buf = await res.arrayBuffer(); const url = URL.createObjectURL(new Blob([buf])); const a = document.createElement('a'); a.href = url; a.download = opts?.fileName || 'de-thi.pdf'; a.click(); URL.revokeObjectURL(url); }, assignRandom: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' }); if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); return res.json() as Promise<{ assigned: number; packages: number }>; }, assignPapersBatch: async (id: number, assignments: { studentRkId: number; paperId: number }[]) => { const res = await staffFetch(`/exam-rooms/${id}/assign-papers-batch`, { method: 'POST', body: JSON.stringify({ assignments }), }); if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); return res.json() as Promise<{ assigned: number }>; }, sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => { const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) }); if (!res.ok) await parseError(res, 'Gửi gói đề thất bại'); return res.json(); }, listSubmissions: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/submissions`); if (!res.ok) await parseError(res, 'Failed'); return res.json() as Promise<{ data: ExamSubmission[] }>; }, downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`, downloadAllSubmissions: async (id: number, fallbackName: string) => { let res: Response; try { res = await staffFetch(`/exam-rooms/${id}/submissions/download-all`); } catch { throw new Error('Không kết nối được server (Failed to fetch). Kiểm tra server đang chạy và thử lại.'); } if (!res.ok) await parseError(res, 'Tải bài nộp gộp thất bại'); const blob = await res.blob(); if (!blob.size) throw new Error('File ZIP trống — có thể bài nộp trên server bị thiếu file'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${fallbackName.replace(/[<>:"/\\|?*]+/g, '_')}_bai_nop.zip`; a.click(); URL.revokeObjectURL(url); }, saveGitSettings: async (id: number, payload: { gitRepoUrl?: string; gitBranch?: string }) => { const res = await staffFetch(`/exam-rooms/${id}/git-settings`, { method: 'PATCH', body: JSON.stringify(payload) }); if (!res.ok) await parseError(res, 'Lưu cấu hình Git thất bại'); return res.json() as Promise<{ gitRepoUrl: string; gitBranch: string; gitPublishUrl?: string }>; }, publishSubmissionsGit: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] })); const msg = errData.error || 'Đẩy lên Git thất bại'; if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`); throw new Error(msg); } return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>; }, publishSubmissionsGitStudents: async (id: number) => { const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' }); if (!res.ok) { const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] })); const msg = errData.error || 'Đẩy repo từng SV thất bại'; if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`); throw new Error(msg); } return res.json() as Promise<{ ok: boolean; published: number; openUrl?: string; message: string; warnings?: string[]; studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[]; }>; }, }; export interface MyClassItem { rkId: number; name: string; classCode: string; } export const apiMyClasses = { list: async (): Promise => { const res = await staffFetch('/staff/my-classes'); if (!res.ok) return []; const json = await res.json() as { data: MyClassItem[] }; return json.data ?? []; }, add: async (classRkId: number): Promise => { await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' }); }, remove: async (classRkId: number): Promise => { await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' }); }, }; export const apiSeatingLayout = { getClass: async (classRkId: number): Promise => { const res = await staffFetch(`/classes/${classRkId}/seating-layout`); if (!res.ok) return null; const json = await res.json() as { layoutJson: string | null }; return json.layoutJson ?? null; }, saveClass: async (classRkId: number, layoutJson: string): Promise => { const res = await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'PUT', body: JSON.stringify({ layoutJson }), }); if (!res.ok) await parseError(res, 'Lưu sơ đồ lớp thất bại'); }, deleteClass: async (classRkId: number): Promise => { const res = await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa sơ đồ lớp thất bại'); }, getExam: async (examRoomId: number): Promise => { const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`); if (!res.ok) return null; const json = await res.json() as { layoutJson: string | null }; return json.layoutJson ?? null; }, saveExam: async (examRoomId: number, layoutJson: string): Promise => { const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'PUT', body: JSON.stringify({ layoutJson }), }); if (!res.ok) await parseError(res, 'Lưu sơ đồ phòng thi thất bại'); }, deleteExam: async (examRoomId: number): Promise => { const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' }); if (!res.ok) await parseError(res, 'Xóa sơ đồ phòng thi thất bại'); }, }; export const apiQldt = { getToken: async (): Promise<{ token: string }> => { const res = await staffFetch('/qldt-token'); if (!res.ok) await parseError(res, 'Không tải được token QLĐT'); return res.json(); }, saveToken: async (token: string): Promise<{ ok: boolean; message: string }> => { const res = await staffFetch('/qldt-token', { method: 'POST', body: JSON.stringify({ token }), }); if (!res.ok) await parseError(res, 'Lưu token thất bại'); return res.json(); }, };