chore: init lms adm fe
This commit is contained in:
+167
@@ -0,0 +1,167 @@
|
||||
import type { AuthAppRole, AuthSession } from '../types/auth';
|
||||
|
||||
const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'http://localhost:4100').replace(/\/+$/, '');
|
||||
const ADMIN_SESSION_STORAGE_KEY = 'atnmy.lms.admin.auth.session';
|
||||
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
|
||||
|
||||
export const isInstructorSession = (session: AuthSession): boolean => {
|
||||
const roles = new Set(getSessionRoles(session));
|
||||
return [...INSTRUCTOR_ROLES].some((role) => roles.has(role));
|
||||
};
|
||||
|
||||
const getSessionRoles = (session: AuthSession): string[] => {
|
||||
return [
|
||||
session.user.role,
|
||||
...(session.user.roles ?? []),
|
||||
...(session.roles ?? []),
|
||||
...getAppRoleCodes(session.user.appRoles),
|
||||
...getAppRoleCodes(session.user.app_roles),
|
||||
...getAppRoleCodes(session.appRoles),
|
||||
...getAppRoleCodes(session.app_roles),
|
||||
]
|
||||
.filter((role): role is string => Boolean(role))
|
||||
.map((role) => role.toUpperCase());
|
||||
};
|
||||
|
||||
const getAppRoleCodes = (roles: AuthAppRole[] | undefined): string[] => {
|
||||
if (!Array.isArray(roles)) return [];
|
||||
|
||||
return roles
|
||||
.filter((role) => {
|
||||
const appKey = role.appKey ?? role.app_key;
|
||||
return !appKey || appKey.toUpperCase() === 'LMS';
|
||||
})
|
||||
.map((role) => role.roleCode ?? role.role_code ?? role.role ?? role.code)
|
||||
.filter((role): role is string => Boolean(role));
|
||||
};
|
||||
|
||||
export const loadStoredSession = (): AuthSession | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const session = JSON.parse(raw) as AuthSession;
|
||||
if (!isInstructorSession(session) || isStoredSessionExpired(session)) {
|
||||
clearStoredSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const storeSession = (session: AuthSession): void => {
|
||||
window.localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify({
|
||||
...session,
|
||||
obtainedAt: session.obtainedAt ?? Date.now(),
|
||||
}));
|
||||
};
|
||||
|
||||
export const clearStoredSession = (): void => {
|
||||
window.localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
|
||||
};
|
||||
|
||||
export const isStoredSessionExpired = (session: AuthSession): boolean => {
|
||||
const obtainedAt = session.obtainedAt ?? 0;
|
||||
if (!obtainedAt) return false;
|
||||
|
||||
if (session.refreshToken && session.refreshTokenExpiresIn) {
|
||||
return Date.now() >= obtainedAt + session.refreshTokenExpiresIn * 1000;
|
||||
}
|
||||
|
||||
return Date.now() >= obtainedAt + (session.accessTokenExpiresIn ?? 0) * 1000;
|
||||
};
|
||||
|
||||
export const loginInstructor = async (email: string, password: string): Promise<AuthSession> => {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status === 429 ? '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.' : '이메일 또는 비밀번호를 확인해주세요.');
|
||||
}
|
||||
|
||||
const session = {
|
||||
...(await response.json()) as AuthSession,
|
||||
obtainedAt: Date.now(),
|
||||
};
|
||||
if (!isInstructorSession(session)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(session);
|
||||
return session;
|
||||
};
|
||||
|
||||
export const refreshInstructorSession = async (session: AuthSession): Promise<AuthSession> => {
|
||||
if (!session.refreshToken) {
|
||||
throw new Error('리프레시 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ refreshToken: session.refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('세션을 갱신하지 못했습니다.');
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
...(await response.json()) as AuthSession,
|
||||
obtainedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (!isInstructorSession(nextSession)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(nextSession);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
export const refreshCurrentUser = async (session: AuthSession): Promise<AuthSession> => {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('사용자 정보를 다시 가져오지 못했습니다.');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { user?: AuthSession['user'] };
|
||||
const nextSession = {
|
||||
...session,
|
||||
user: payload.user ?? session.user
|
||||
};
|
||||
|
||||
if (!isInstructorSession(nextSession)) {
|
||||
clearStoredSession();
|
||||
throw new Error('강사 계정만 로그인할 수 있습니다.');
|
||||
}
|
||||
|
||||
storeSession(nextSession);
|
||||
return nextSession;
|
||||
};
|
||||
|
||||
export const logoutInstructor = async (): Promise<void> => {
|
||||
try {
|
||||
await fetch(`${AUTH_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
} finally {
|
||||
clearStoredSession();
|
||||
}
|
||||
};
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
export type ChatSenderType = 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' | string;
|
||||
|
||||
export type InstructorChatMessage = {
|
||||
id: string;
|
||||
senderType: ChatSenderType;
|
||||
senderName?: string;
|
||||
senderAvatarUrl?: string | null;
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type InstructorChatThread = {
|
||||
id: string;
|
||||
title: string;
|
||||
studentName: string;
|
||||
studentAvatarUrl?: string | null;
|
||||
groupName?: string;
|
||||
week?: number | string;
|
||||
status: string;
|
||||
lastMessage: string;
|
||||
lastMessageAt?: string;
|
||||
messages: InstructorChatMessage[];
|
||||
};
|
||||
|
||||
const CHAT_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
export const CHAT_SOCKET_BASE_URL = CHAT_API_BASE_URL.replace(/\/api$/, '');
|
||||
|
||||
type ChatThreadsPayload = {
|
||||
threads?: unknown[];
|
||||
data?: unknown[];
|
||||
};
|
||||
|
||||
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (week) params.set('week', String(week));
|
||||
const queryString = params.toString();
|
||||
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads${queryString ? `?${queryString}` : ''}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 채팅 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ChatThreadsPayload | unknown[];
|
||||
const items = Array.isArray(payload) ? payload : Array.isArray(payload?.threads) ? payload.threads : Array.isArray(payload?.data) ? payload.data : [];
|
||||
return items.map(normalizeThread).filter((thread): thread is InstructorChatThread => Boolean(thread));
|
||||
}
|
||||
|
||||
export async function getInstructorChatThread(accessToken: string, threadId: string): Promise<InstructorChatThread> {
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 채팅방을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const thread = normalizeThread(payload?.thread ?? payload);
|
||||
if (!thread) throw new Error('강사 채팅방 응답이 올바르지 않습니다.');
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
export async function createInstructorChatMessage(accessToken: string, threadId: string, content: string): Promise<InstructorChatThread> {
|
||||
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`강사 답변을 저장하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const thread = normalizeThread(payload?.thread ?? payload);
|
||||
if (!thread) throw new Error('강사 답변 응답이 올바르지 않습니다.');
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
function normalizeThread(raw: unknown): InstructorChatThread | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, any>;
|
||||
|
||||
const messages = Array.isArray(item.messages) ? item.messages.map(normalizeMessage).filter((message): message is InstructorChatMessage => Boolean(message)) : [];
|
||||
const lastMessageFromList = messages.length > 0 ? messages[messages.length - 1]?.content : undefined;
|
||||
const rawLastMessage = item.lastMessage?.content ?? item.lastMessageContent ?? item.lastMessage;
|
||||
const studentName = item.student?.name ?? item.studentName ?? item.student?.email ?? item.studentEmail ?? item.studentId ?? '수강생';
|
||||
const title = item.title ?? item.threadTitle ?? studentName;
|
||||
const weekValue = typeof item.week === 'object' && item.week ? item.week.week : item.week;
|
||||
|
||||
return {
|
||||
id: String(item.id ?? item.threadId ?? title),
|
||||
title: String(title),
|
||||
studentName: String(studentName),
|
||||
studentAvatarUrl: typeof item.studentAvatarUrl === 'string' ? item.studentAvatarUrl : null,
|
||||
groupName: item.group?.name ?? item.groupName ?? item.metadata?.groupName,
|
||||
week: weekValue ?? item.weekId ?? item.metadata?.week,
|
||||
status: String(item.status ?? 'WAITING_INSTRUCTOR'),
|
||||
lastMessage: String(rawLastMessage ?? lastMessageFromList ?? '저장된 문의 내용을 확인해 주세요.'),
|
||||
lastMessageAt: item.lastMessageAt ?? item.updatedAt ?? item.createdAt,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: unknown): InstructorChatMessage | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const item = raw as Record<string, any>;
|
||||
|
||||
return {
|
||||
id: String(item.id ?? `${item.senderType ?? 'MESSAGE'}-${item.createdAt ?? item.content ?? 'unknown'}`),
|
||||
senderType: String(item.senderType ?? 'STUDENT'),
|
||||
senderName: item.senderName ?? item.sender?.name,
|
||||
senderAvatarUrl: typeof item.senderAvatarUrl === 'string' ? item.senderAvatarUrl : null,
|
||||
content: String(item.content ?? ''),
|
||||
createdAt: item.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type InstructorCourse = {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
organizationName: string;
|
||||
teacher?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorCourses(accessToken: string): Promise<{ courses: InstructorCourse[] }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
return response.json() as Promise<{ courses: InstructorCourse[] }>;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
export type CurriculumWeek = {
|
||||
week: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
progress: number;
|
||||
state: string;
|
||||
topics: string[];
|
||||
duration: string;
|
||||
github: string;
|
||||
detail: {
|
||||
overview: string;
|
||||
learningSteps?: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
videoEnabled?: boolean;
|
||||
videoUrl?: string;
|
||||
concepts?: string[];
|
||||
}>;
|
||||
topicDetails: string[];
|
||||
topicVideoUrls?: string[];
|
||||
practiceContents?: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
references?: CurriculumReference[];
|
||||
exampleCodes?: CurriculumReference[];
|
||||
deliverable: string;
|
||||
requiresSourceSubmission: boolean;
|
||||
};
|
||||
modules?: CurriculumModule[];
|
||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||
};
|
||||
|
||||
export type CurriculumModule = {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
category: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
templateRepositoryUrl?: string | null;
|
||||
dockerImage?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type CurriculumReference = {
|
||||
id: string;
|
||||
kind?: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
fileName: string;
|
||||
contentType?: string | null;
|
||||
fileSizeBytes?: number | string | null;
|
||||
week?: number | string | null;
|
||||
weekNumber?: number | string | null;
|
||||
downloadUrl: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type CurriculumVisualizationPlatform = {
|
||||
key: string;
|
||||
type: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
config?: unknown;
|
||||
};
|
||||
|
||||
export type CurriculumTagOption = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type InstructorCurriculumResponse = {
|
||||
course: {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
organizationName: string;
|
||||
} | null;
|
||||
weeks: CurriculumWeek[];
|
||||
setting: {
|
||||
groupId?: string | null;
|
||||
activeWeek: number;
|
||||
activeLearningStep?: string | null;
|
||||
} | null;
|
||||
tagOptions?: CurriculumTagOption[];
|
||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function getInstructorCurriculum(accessToken: string, groupId?: string | null, courseId?: string | null): Promise<InstructorCurriculumResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (groupId) params.set('groupId', groupId);
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`커리큘럼을 불러오지 못했습니다. (${response.status})`);
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function updateInstructorCurriculum(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
groupId?: string | null;
|
||||
activeWeek: number;
|
||||
activeLearningStep?: string | null;
|
||||
applyToStudents?: boolean;
|
||||
completed?: boolean;
|
||||
tagLabel?: string;
|
||||
title?: string;
|
||||
duration?: string;
|
||||
overview?: string;
|
||||
deliverable?: string;
|
||||
requiresSourceSubmission?: boolean;
|
||||
topics?: string[];
|
||||
learningSteps?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
videoEnabled?: boolean;
|
||||
videoUrl?: string;
|
||||
concepts?: string[];
|
||||
}>;
|
||||
topicDetails?: string[];
|
||||
topicVideoUrls?: string[];
|
||||
practiceContents?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
disabledModuleIds?: string[];
|
||||
visualizationPlatformKeys?: string[];
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `커리큘럼을 적용하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorCurriculumWeek(
|
||||
accessToken: string,
|
||||
week: number,
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `주차를 삭제하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function uploadCurriculumReference(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
week: number;
|
||||
title?: string;
|
||||
file: File;
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', input.file);
|
||||
formData.set('courseId', input.courseId ?? '');
|
||||
formData.set('title', input.title?.trim() || input.file.name);
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/references`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `참고자료를 업로드하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function uploadCurriculumExampleCode(
|
||||
accessToken: string,
|
||||
input: {
|
||||
courseId?: string | null;
|
||||
week: number;
|
||||
title?: string;
|
||||
file: File;
|
||||
},
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', input.file);
|
||||
formData.set('courseId', input.courseId ?? '');
|
||||
formData.set('title', input.title?.trim() || input.file.name);
|
||||
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/example-codes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `예제 코드를 업로드하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCurriculumReference(
|
||||
accessToken: string,
|
||||
referenceId: string,
|
||||
courseId?: string | null,
|
||||
): Promise<InstructorCurriculumResponse> {
|
||||
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/references/${referenceId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `참고자료를 삭제하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export type GroupStudentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type InstructorGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
week: string;
|
||||
status: string;
|
||||
members: string[];
|
||||
membersDetailed?: GroupStudentOption[];
|
||||
instructorPlatformId?: string | null;
|
||||
platform: string;
|
||||
progress: number;
|
||||
completedMissions: number;
|
||||
activeUsers: number;
|
||||
};
|
||||
|
||||
export type InstructorGroupsSummary = {
|
||||
totalGroups: number;
|
||||
assignedPlatforms: number;
|
||||
totalStudents: number;
|
||||
averageProgress: number;
|
||||
};
|
||||
|
||||
export type InstructorGroupsResponse = {
|
||||
summary: InstructorGroupsSummary;
|
||||
groups: InstructorGroup[];
|
||||
unassignedStudents?: GroupStudentOption[];
|
||||
platformOptions?: GroupPlatformOption[];
|
||||
};
|
||||
|
||||
export type GroupPlatformOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SaveInstructorGroupInput = {
|
||||
courseId?: string | null;
|
||||
name: string;
|
||||
instructorPlatformId?: string | null;
|
||||
platformName?: string | null;
|
||||
memberIds: string[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorGroups(accessToken: string, courseId?: string | null): Promise<InstructorGroupsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`그룹 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function createInstructorGroup(
|
||||
accessToken: string,
|
||||
input: SaveInstructorGroupInput,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 추가하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function updateInstructorGroup(
|
||||
accessToken: string,
|
||||
groupId: string,
|
||||
input: SaveInstructorGroupInput,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 수정하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorGroup(
|
||||
accessToken: string,
|
||||
groupId: string,
|
||||
courseId?: string | null,
|
||||
): Promise<InstructorGroupsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (courseId) params.set('courseId', courseId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 삭제하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorGroupsResponse>;
|
||||
}
|
||||
|
||||
async function groupErrorMessage(response: Response, fallback: string) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (payload && typeof payload === 'object' && 'message' in payload) {
|
||||
return String(payload.message);
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
export type InstructorPlatform = {
|
||||
id: string;
|
||||
masterPlatformId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
battery: number;
|
||||
uptime: string;
|
||||
speed: string;
|
||||
errors: number;
|
||||
group: string | null;
|
||||
user: string | null;
|
||||
emergencyStop: boolean;
|
||||
vehicleType: string;
|
||||
wheelbaseMeters: number;
|
||||
maxSpeedMetersPerSecond: number;
|
||||
maxSteeringAngleDegrees: number;
|
||||
sensors: PlatformSettingItem[];
|
||||
drivers: PlatformSettingItem[];
|
||||
algorithms: PlatformAlgorithms;
|
||||
controlDurationMinutes: number;
|
||||
controlWarningMinutes: number;
|
||||
agent: InstructorPlatformAgentBinding | null;
|
||||
};
|
||||
|
||||
export type InstructorPlatformAgentBinding = {
|
||||
nodeId: string | null;
|
||||
hostname: string | null;
|
||||
ownerEmail: string | null;
|
||||
ip: string | null;
|
||||
connected: boolean;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
export type PlatformInUseSession = {
|
||||
id: string;
|
||||
holderUserId: string;
|
||||
holderName: string | null;
|
||||
holderEmail: string | null;
|
||||
startedAt: string;
|
||||
endsAt: string;
|
||||
};
|
||||
|
||||
export class PlatformInUseError extends Error {
|
||||
activeSession: PlatformInUseSession;
|
||||
|
||||
constructor(message: string, activeSession: PlatformInUseSession) {
|
||||
super(message);
|
||||
this.name = 'PlatformInUseError';
|
||||
this.activeSession = activeSession;
|
||||
}
|
||||
}
|
||||
|
||||
export type TeachingAgent = {
|
||||
id: string;
|
||||
hostname: string;
|
||||
name: string;
|
||||
user: string;
|
||||
tags: string[];
|
||||
ip: string;
|
||||
sshUser?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
connected: boolean;
|
||||
expired: boolean;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
export type PlatformSettingItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled?: boolean;
|
||||
installed?: boolean;
|
||||
};
|
||||
|
||||
export type PlatformAlgorithms = {
|
||||
slam: string;
|
||||
planning: string;
|
||||
control: string;
|
||||
};
|
||||
|
||||
export type MasterPlatform = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
iconKey: string;
|
||||
};
|
||||
|
||||
export type InstructorPlatformsSummary = {
|
||||
totalPlatforms: number;
|
||||
activePlatforms: number;
|
||||
standbyPlatforms: number;
|
||||
errorPlatforms: number;
|
||||
};
|
||||
|
||||
export type InstructorPlatformsResponse = {
|
||||
summary: InstructorPlatformsSummary;
|
||||
platforms: InstructorPlatform[];
|
||||
masterPlatforms: MasterPlatform[];
|
||||
agents: TeachingAgent[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`플랫폼 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function listInstructorPlatformAgents(accessToken: string): Promise<{ agents: TeachingAgent[] }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/agents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`에이전트 목록을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ agents: TeachingAgent[] }>;
|
||||
}
|
||||
|
||||
export async function createInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
displayName?: string,
|
||||
agent?: TeachingAgent | null,
|
||||
ssh?: { user?: string; port?: string },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platformId,
|
||||
displayName,
|
||||
agentNodeId: agent?.id,
|
||||
agentHostname: agent?.hostname || agent?.name,
|
||||
agentOwnerEmail: agent?.user,
|
||||
sshUser: ssh?.user ?? agent?.sshUser ?? undefined,
|
||||
sshPort: ssh?.port,
|
||||
workspaceRoot: agent?.workspaceRoot ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼을 등록하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function updateInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
displayName: string,
|
||||
vehicleType: string,
|
||||
wheelbaseMeters: number,
|
||||
maxSpeedMetersPerSecond: number,
|
||||
maxSteeringAngleDegrees: number,
|
||||
sensors: PlatformSettingItem[],
|
||||
algorithms: PlatformAlgorithms,
|
||||
controlDurationMinutes: number,
|
||||
controlWarningMinutes: number,
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
displayName,
|
||||
vehicleType,
|
||||
wheelbaseMeters,
|
||||
maxSpeedMetersPerSecond,
|
||||
maxSteeringAngleDegrees,
|
||||
sensors,
|
||||
algorithms,
|
||||
controlDurationMinutes,
|
||||
controlWarningMinutes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼 설정을 저장하지 못했습니다.'));
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function deleteInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const query = options?.force ? '?force=true' : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}${query}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
|
||||
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
|
||||
if (activeSession) {
|
||||
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
|
||||
}
|
||||
}
|
||||
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 삭제하지 못했습니다.'));
|
||||
}
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
export async function assignInstructorPlatform(
|
||||
accessToken: string,
|
||||
platformId: string,
|
||||
groupId: string,
|
||||
options?: { force?: boolean },
|
||||
): Promise<InstructorPlatformsResponse> {
|
||||
const query = options?.force ? '?force=true' : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}/assignment${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ groupId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
|
||||
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
|
||||
if (activeSession) {
|
||||
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
|
||||
}
|
||||
}
|
||||
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 배정하지 못했습니다.'));
|
||||
}
|
||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||
}
|
||||
|
||||
async function platformErrorMessage(response: Response, fallback: string) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
return platformErrorMessageFromPayload(response, payload, fallback);
|
||||
}
|
||||
|
||||
function platformErrorMessageFromPayload(response: Response, payload: unknown, fallback: string) {
|
||||
if (payload && typeof payload === 'object' && 'message' in payload) {
|
||||
return String((payload as { message?: unknown }).message);
|
||||
}
|
||||
return `${fallback} (${response.status})`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const PROFILE_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'http://localhost:4200/api').replace(/\/+$/, '');
|
||||
const AVATAR_MAX_BYTES = 80 * 1024;
|
||||
|
||||
export async function fetchProfileAvatar(accessToken: string, version: number): Promise<Blob | null> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar?v=${version}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error('avatar_load_failed');
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function uploadProfileAvatar(accessToken: string, imageBlob: Blob): Promise<void> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': imageBlob.type,
|
||||
},
|
||||
credentials: 'include',
|
||||
body: imageBlob,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('avatar_upload_failed');
|
||||
}
|
||||
|
||||
export async function deleteProfileAvatar(accessToken: string): Promise<void> {
|
||||
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('avatar_delete_failed');
|
||||
}
|
||||
|
||||
export async function resizeImageToWebp(file: File, size = 128): Promise<Blob> {
|
||||
if (!file.type.startsWith('image/')) throw new Error('이미지 파일만 등록할 수 있습니다.');
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('이미지를 불러올 수 없습니다.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const sourceSize = Math.min(image.naturalWidth, image.naturalHeight);
|
||||
const sourceX = Math.max(0, (image.naturalWidth - sourceSize) / 2);
|
||||
const sourceY = Math.max(0, (image.naturalHeight - sourceSize) / 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('이미지를 처리할 수 없습니다.');
|
||||
|
||||
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/webp', 0.82);
|
||||
});
|
||||
|
||||
if (!blob) throw new Error('이미지를 압축할 수 없습니다.');
|
||||
if (blob.size > AVATAR_MAX_BYTES) throw new Error('이미지 용량이 너무 큽니다.');
|
||||
return blob;
|
||||
}
|
||||
|
||||
export function notifyProfileAvatarUpdated(): void {
|
||||
window.dispatchEvent(new Event('profile-avatar-updated'));
|
||||
}
|
||||
|
||||
export function notifyProfileAvatarDeleted(): void {
|
||||
window.dispatchEvent(new Event('profile-avatar-deleted'));
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
export type InstructorStudentIssue = {
|
||||
type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE';
|
||||
title: string;
|
||||
detail: string;
|
||||
week?: number;
|
||||
};
|
||||
|
||||
export type InstructorStudent = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
week: string;
|
||||
progress: number;
|
||||
commits: number;
|
||||
issues: number;
|
||||
issueItems: InstructorStudentIssue[];
|
||||
lastActive: string;
|
||||
online: boolean;
|
||||
courseTitle?: string;
|
||||
};
|
||||
|
||||
export type InstructorStudentsSummary = {
|
||||
totalStudents: number;
|
||||
onlineStudents: number;
|
||||
averageProgress: number;
|
||||
unresolvedIssues: number;
|
||||
};
|
||||
|
||||
export type InstructorStudentsResponse = {
|
||||
organization: {
|
||||
id?: string;
|
||||
name: string;
|
||||
} | null;
|
||||
summary: InstructorStudentsSummary;
|
||||
students: InstructorStudent[];
|
||||
};
|
||||
|
||||
export type CreateInstructorStudentInput = {
|
||||
courseId?: string | null;
|
||||
email: string;
|
||||
name: string;
|
||||
groupId?: string | null;
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorStudents(accessToken: string, courseId?: string | null): Promise<InstructorStudentsResponse> {
|
||||
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students${query}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`수강생 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
|
||||
export async function createInstructorStudent(
|
||||
accessToken: string,
|
||||
input: CreateInstructorStudentInput,
|
||||
): Promise<InstructorStudentsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `수강생을 등록하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
|
||||
export async function removeInstructorStudent(
|
||||
accessToken: string,
|
||||
studentId: string,
|
||||
): Promise<InstructorStudentsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch((): null => null);
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload
|
||||
? String(payload.message)
|
||||
: `수강생을 제외하지 못했습니다. (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorStudentsResponse>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
export type InstructorSubmissionCommitFile = {
|
||||
path: string;
|
||||
status: string;
|
||||
patch: string;
|
||||
};
|
||||
|
||||
export type InstructorSubmissionCommit = {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
authorEmail: string;
|
||||
date: string | null;
|
||||
files: InstructorSubmissionCommitFile[];
|
||||
};
|
||||
|
||||
export type InstructorSubmissionItem = {
|
||||
id: string;
|
||||
status: 'NOT_INSTALLED' | 'PENDING' | 'SUBMITTED';
|
||||
submitted: boolean;
|
||||
submittedAt: string | null;
|
||||
commitCount: number;
|
||||
score: number;
|
||||
course: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
week: {
|
||||
id: string;
|
||||
week: number;
|
||||
title: string;
|
||||
};
|
||||
module: {
|
||||
id: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
student: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
};
|
||||
repository: {
|
||||
id: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
url: string;
|
||||
} | null;
|
||||
commits: InstructorSubmissionCommit[];
|
||||
finalFiles: InstructorSubmissionCommitFile[];
|
||||
};
|
||||
|
||||
export type InstructorSubmissionsResponse = {
|
||||
organization: {
|
||||
id?: string;
|
||||
name: string;
|
||||
} | null;
|
||||
summary: {
|
||||
totalItems: number;
|
||||
submittedItems: number;
|
||||
pendingItems: number;
|
||||
notInstalledItems: number;
|
||||
totalCommits: number;
|
||||
};
|
||||
courses: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
weeks: Array<{
|
||||
id: string;
|
||||
week: number;
|
||||
title: string;
|
||||
modules: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
folderName: string;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
students: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
group: string;
|
||||
}>;
|
||||
items: InstructorSubmissionItem[];
|
||||
};
|
||||
|
||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
|
||||
|
||||
export async function listInstructorSubmissions(accessToken: string): Promise<InstructorSubmissionsResponse> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`제출 현황을 불러오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<InstructorSubmissionsResponse>;
|
||||
}
|
||||
|
||||
export async function saveInstructorSubmissionScore(
|
||||
accessToken: string,
|
||||
input: { studentId: string; weekId: string; score: number },
|
||||
): Promise<{ score: number }> {
|
||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/score`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`점수를 저장하지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ score: number }>;
|
||||
}
|
||||
Reference in New Issue
Block a user