Files
rikkei_simple_care/management/src/api.ts
PhuocNTB 232f9873a0
Some checks failed
Deploy on Master Change / deploy (push) Failing after 1m23s
up
2026-07-20 12:38:02 +07:00

1282 lines
44 KiB
TypeScript

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<Response> {
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<never> {
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<GitHubStatus> => {
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<T> {
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<StatsResponse> => {
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<PaginatedResponse<ClassItem>> => {
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<any> => {
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<ClassItem> => {
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<PaginatedResponse<StudentItem>> => {
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<SyncStatus> => {
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<SyncStatus> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<LeaveRequestItem[]> => {
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<Response> {
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';
createdByStaffId?: number;
}
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 (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
const q = opts?.mine ? '?mine=1' : '';
const res = await staffFetch(`/exam-rooms${q}`);
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();
},
extend: async (id: number, minutes: number) => {
const res = await staffFetch(`/exam-rooms/${id}/extend`, {
method: 'POST',
body: JSON.stringify({ minutes }),
});
if (!res.ok) await parseError(res, 'Gia hạn thất bại');
return res.json() as Promise<{ ok: boolean; endTime: string; addedMinutes: number; displayStatus: string }>;
},
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<ArrayBuffer> => {
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<ArrayBuffer> => {
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<MyClassItem[]> => {
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<void> => {
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' });
},
remove: async (classRkId: number): Promise<void> => {
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' });
},
};
export const apiSeatingLayout = {
getClass: async (classRkId: number): Promise<string | null> => {
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<void> => {
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<void> => {
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<string | null> => {
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<void> => {
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<void> => {
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();
},
};