patch..
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>오토노미스튜디오 LMS | 강사로그인</title>
|
<title>오토노미스튜디오 LMS | 강사로그인</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -28,7 +28,19 @@ export type CurriculumWeek = {
|
|||||||
requiresSourceSubmission: boolean;
|
requiresSourceSubmission: boolean;
|
||||||
};
|
};
|
||||||
modules?: CurriculumModule[];
|
modules?: CurriculumModule[];
|
||||||
|
environmentProfiles?: CurriculumEnvironmentProfile[];
|
||||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||||
|
mapKeys?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurriculumMap = {
|
||||||
|
key: string;
|
||||||
|
platformKey: 'NAV2';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
worldFile: string;
|
||||||
|
viewerModel: 'turtlebot3_world' | 'turtlebot3_house';
|
||||||
|
custom?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CurriculumModule = {
|
export type CurriculumModule = {
|
||||||
@@ -39,9 +51,18 @@ export type CurriculumModule = {
|
|||||||
folderName: string;
|
folderName: string;
|
||||||
templateRepositoryUrl?: string | null;
|
templateRepositoryUrl?: string | null;
|
||||||
dockerImage?: string | null;
|
dockerImage?: string | null;
|
||||||
|
defaultParameters?: string[];
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CurriculumEnvironmentProfile = {
|
||||||
|
id: string;
|
||||||
|
platformKey: string;
|
||||||
|
profileType: string;
|
||||||
|
name: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type CurriculumReference = {
|
export type CurriculumReference = {
|
||||||
id: string;
|
id: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -85,6 +106,7 @@ export type InstructorCurriculumResponse = {
|
|||||||
} | null;
|
} | null;
|
||||||
tagOptions?: CurriculumTagOption[];
|
tagOptions?: CurriculumTagOption[];
|
||||||
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
visualizationPlatforms?: CurriculumVisualizationPlatform[];
|
||||||
|
mapOptions?: CurriculumMap[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||||
@@ -139,7 +161,21 @@ export async function updateInstructorCurriculum(
|
|||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
disabledModuleIds?: string[];
|
disabledModuleIds?: string[];
|
||||||
|
moduleDefaultParameters?: Array<{
|
||||||
|
moduleId: string;
|
||||||
|
parameters: string[];
|
||||||
|
}>;
|
||||||
|
environmentModuleSets?: Array<{
|
||||||
|
platformKey: string;
|
||||||
|
moduleIds: string[];
|
||||||
|
modules?: Array<{
|
||||||
|
id: string;
|
||||||
|
defaultParameters: string[];
|
||||||
|
dockerImage?: string | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
visualizationPlatformKeys?: string[];
|
visualizationPlatformKeys?: string[];
|
||||||
|
mapKeys?: string[];
|
||||||
},
|
},
|
||||||
): Promise<InstructorCurriculumResponse> {
|
): Promise<InstructorCurriculumResponse> {
|
||||||
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
|
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
|
||||||
@@ -163,6 +199,27 @@ export async function updateInstructorCurriculum(
|
|||||||
return response.json() as Promise<InstructorCurriculumResponse>;
|
return response.json() as Promise<InstructorCurriculumResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createInstructorCurriculumMap(
|
||||||
|
accessToken: string,
|
||||||
|
input: { courseId?: string | null; name: string; description?: string },
|
||||||
|
): Promise<CurriculumMap> {
|
||||||
|
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/maps`, {
|
||||||
|
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);
|
||||||
|
throw new Error(payload && typeof payload === 'object' && 'message' in payload
|
||||||
|
? String(payload.message)
|
||||||
|
: `학습 맵을 등록하지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<CurriculumMap>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteInstructorCurriculumWeek(
|
export async function deleteInstructorCurriculumWeek(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
week: number,
|
week: number,
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ export type InstructorPlatformsResponse = {
|
|||||||
agents: TeachingAgent[];
|
agents: TeachingAgent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InstructorPlatformRegistrationResponse = {
|
||||||
|
code: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, '');
|
||||||
|
|
||||||
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
|
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
|
||||||
@@ -158,6 +163,21 @@ export async function createInstructorPlatform(
|
|||||||
return response.json() as Promise<InstructorPlatformsResponse>;
|
return response.json() as Promise<InstructorPlatformsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function issueInstructorPlatformRegistration(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<InstructorPlatformRegistrationResponse> {
|
||||||
|
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platform-registration-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await platformErrorMessage(response, '등록 키를 발급하지 못했습니다.'));
|
||||||
|
return response.json() as Promise<InstructorPlatformRegistrationResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateInstructorPlatform(
|
export async function updateInstructorPlatform(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
|
|||||||
@@ -1,14 +1,77 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react';
|
import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react';
|
||||||
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||||
import { PageHeader } from '../../components/PageHeader';
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
import { useToast } from '../../components/ToastProvider';
|
import { useToast } from '../../components/ToastProvider';
|
||||||
import { deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
|
import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum';
|
||||||
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
import { listInstructorGroups, type InstructorGroup } from '../../lib/groups';
|
||||||
import type { AuthSession } from '../../types/auth';
|
import type { AuthSession } from '../../types/auth';
|
||||||
|
|
||||||
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
|
const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id';
|
||||||
|
|
||||||
|
type ModuleParameterDraft = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
||||||
|
const separatorIndex = parameter.indexOf(':=');
|
||||||
|
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
|
||||||
|
return {
|
||||||
|
key: parameter.slice(0, separatorIndex).trim(),
|
||||||
|
value: parameter.slice(separatorIndex + 2).trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvironmentModuleIds(profile: CurriculumEnvironmentProfile, modules: NonNullable<CurriculumWeek['modules']>): string[] {
|
||||||
|
const config = profile.config;
|
||||||
|
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
|
||||||
|
? config.moduleSet as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!moduleSet || moduleSet.includeUnlisted !== false || !Array.isArray(moduleSet.modules)) {
|
||||||
|
return modules.map((module) => module.id);
|
||||||
|
}
|
||||||
|
return moduleSet.modules.flatMap((item) => (
|
||||||
|
item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string'
|
||||||
|
? [(item as Record<string, unknown>).id as string]
|
||||||
|
: []
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvironmentModuleParameters(profile: CurriculumEnvironmentProfile): Record<string, ModuleParameterDraft[]> {
|
||||||
|
const config = profile.config;
|
||||||
|
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
|
||||||
|
? config.moduleSet as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!moduleSet || !Array.isArray(moduleSet.modules)) return {};
|
||||||
|
|
||||||
|
return Object.fromEntries(moduleSet.modules.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return [];
|
||||||
|
const module = item as Record<string, unknown>;
|
||||||
|
if (typeof module.id !== 'string') return [];
|
||||||
|
const parameters = Array.isArray(module.defaultParameters)
|
||||||
|
? module.defaultParameters.filter((value): value is string => typeof value === 'string').map(parseModuleParameter)
|
||||||
|
: [];
|
||||||
|
return [[module.id, parameters]];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvironmentModuleDockerImages(profile: CurriculumEnvironmentProfile): Record<string, string> {
|
||||||
|
const config = profile.config;
|
||||||
|
const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet)
|
||||||
|
? config.moduleSet as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!moduleSet || !Array.isArray(moduleSet.modules)) return {};
|
||||||
|
|
||||||
|
return Object.fromEntries(moduleSet.modules.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return [];
|
||||||
|
const module = item as Record<string, unknown>;
|
||||||
|
return typeof module.id === 'string' && typeof module.dockerImage === 'string'
|
||||||
|
? [[module.id, module.dockerImage]]
|
||||||
|
: [];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [weeks, setWeeks] = useState<CurriculumWeek[]>([]);
|
const [weeks, setWeeks] = useState<CurriculumWeek[]>([]);
|
||||||
@@ -31,9 +94,19 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const [draftTopicConcepts, setDraftTopicConcepts] = useState<string[]>([]);
|
const [draftTopicConcepts, setDraftTopicConcepts] = useState<string[]>([]);
|
||||||
const [draftPracticeContents, setDraftPracticeContents] = useState<Array<{ title: string; description: string }>>([]);
|
const [draftPracticeContents, setDraftPracticeContents] = useState<Array<{ title: string; description: string }>>([]);
|
||||||
const [moduleEnabledById, setModuleEnabledById] = useState<Record<string, boolean>>({});
|
const [moduleEnabledById, setModuleEnabledById] = useState<Record<string, boolean>>({});
|
||||||
|
const [moduleDefaultParametersById, setModuleDefaultParametersById] = useState<Record<string, ModuleParameterDraft[]>>({});
|
||||||
|
const [environmentModuleIdsByPlatformKey, setEnvironmentModuleIdsByPlatformKey] = useState<Record<string, string[]>>({});
|
||||||
|
const [environmentModuleParametersByPlatformKey, setEnvironmentModuleParametersByPlatformKey] = useState<Record<string, Record<string, ModuleParameterDraft[]>>>({});
|
||||||
|
const [environmentModuleDockerImagesByPlatformKey, setEnvironmentModuleDockerImagesByPlatformKey] = useState<Record<string, Record<string, string>>>({});
|
||||||
const [tagOptions, setTagOptions] = useState<NonNullable<InstructorCurriculumResponse['tagOptions']>>([]);
|
const [tagOptions, setTagOptions] = useState<NonNullable<InstructorCurriculumResponse['tagOptions']>>([]);
|
||||||
const [visualizationPlatforms, setVisualizationPlatforms] = useState<NonNullable<InstructorCurriculumResponse['visualizationPlatforms']>>([]);
|
const [visualizationPlatforms, setVisualizationPlatforms] = useState<NonNullable<InstructorCurriculumResponse['visualizationPlatforms']>>([]);
|
||||||
const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState<Record<string, boolean>>({});
|
const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState<Record<string, boolean>>({});
|
||||||
|
const [mapOptions, setMapOptions] = useState<NonNullable<InstructorCurriculumResponse['mapOptions']>>([]);
|
||||||
|
const [mapEnabledByKey, setMapEnabledByKey] = useState<Record<string, boolean>>({});
|
||||||
|
const [mapCreateOpen, setMapCreateOpen] = useState(false);
|
||||||
|
const [mapName, setMapName] = useState('');
|
||||||
|
const [mapDescription, setMapDescription] = useState('');
|
||||||
|
const [creatingMap, setCreatingMap] = useState(false);
|
||||||
const [referenceTitle, setReferenceTitle] = useState('');
|
const [referenceTitle, setReferenceTitle] = useState('');
|
||||||
const [referenceFile, setReferenceFile] = useState<File | null>(null);
|
const [referenceFile, setReferenceFile] = useState<File | null>(null);
|
||||||
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
|
const [exampleCodeTitle, setExampleCodeTitle] = useState('');
|
||||||
@@ -90,6 +163,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
setWeeks(payload.weeks);
|
setWeeks(payload.weeks);
|
||||||
setTagOptions(payload.tagOptions ?? []);
|
setTagOptions(payload.tagOptions ?? []);
|
||||||
setVisualizationPlatforms(payload.visualizationPlatforms ?? []);
|
setVisualizationPlatforms(payload.visualizationPlatforms ?? []);
|
||||||
|
setMapOptions(payload.mapOptions ?? []);
|
||||||
const progress = resolveProgressSelection(payload.weeks, payload.setting);
|
const progress = resolveProgressSelection(payload.weeks, payload.setting);
|
||||||
setCurrentProgressWeek(progress.week);
|
setCurrentProgressWeek(progress.week);
|
||||||
setCurrentProgressLearningStep(progress.learningStep);
|
setCurrentProgressLearningStep(progress.learningStep);
|
||||||
@@ -164,7 +238,21 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? ''));
|
setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? ''));
|
||||||
setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []);
|
setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []);
|
||||||
setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false])));
|
setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false])));
|
||||||
|
setModuleDefaultParametersById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, (module.defaultParameters ?? []).map(parseModuleParameter)])));
|
||||||
|
setEnvironmentModuleIdsByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
|
||||||
|
profile.platformKey,
|
||||||
|
readEnvironmentModuleIds(profile, selectedWeek.modules ?? []),
|
||||||
|
])));
|
||||||
|
setEnvironmentModuleParametersByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
|
||||||
|
profile.platformKey,
|
||||||
|
readEnvironmentModuleParameters(profile),
|
||||||
|
])));
|
||||||
|
setEnvironmentModuleDockerImagesByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [
|
||||||
|
profile.platformKey,
|
||||||
|
readEnvironmentModuleDockerImages(profile),
|
||||||
|
])));
|
||||||
setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true])));
|
setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true])));
|
||||||
|
setMapEnabledByKey(Object.fromEntries((selectedWeek.mapKeys ?? []).map((key) => [key, true])));
|
||||||
setReferenceTitle('');
|
setReferenceTitle('');
|
||||||
setReferenceFile(null);
|
setReferenceFile(null);
|
||||||
setExampleCodeTitle('');
|
setExampleCodeTitle('');
|
||||||
@@ -330,6 +418,23 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
const disabledModuleIds = (selectedWeek.modules ?? [])
|
const disabledModuleIds = (selectedWeek.modules ?? [])
|
||||||
.filter((module) => moduleEnabledById[module.id] === false)
|
.filter((module) => moduleEnabledById[module.id] === false)
|
||||||
.map((module) => module.id);
|
.map((module) => module.id);
|
||||||
|
const moduleDefaultParameters = (selectedWeek.modules ?? []).map((module) => ({
|
||||||
|
moduleId: module.id,
|
||||||
|
parameters: (moduleDefaultParametersById[module.id] ?? [])
|
||||||
|
.filter((parameter) => parameter.key.trim())
|
||||||
|
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
|
||||||
|
}));
|
||||||
|
const environmentModuleSets = (selectedWeek.environmentProfiles ?? []).map((profile) => ({
|
||||||
|
platformKey: profile.platformKey,
|
||||||
|
moduleIds: environmentModuleIdsByPlatformKey[profile.platformKey] ?? [],
|
||||||
|
modules: (environmentModuleIdsByPlatformKey[profile.platformKey] ?? []).map((id) => ({
|
||||||
|
id,
|
||||||
|
defaultParameters: (environmentModuleParametersByPlatformKey[profile.platformKey]?.[id] ?? [])
|
||||||
|
.filter((parameter) => parameter.key.trim())
|
||||||
|
.map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`),
|
||||||
|
dockerImage: environmentModuleDockerImagesByPlatformKey[profile.platformKey]?.[id]?.trim() || undefined,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
const payload = await updateInstructorCurriculum(session.accessToken, {
|
const payload = await updateInstructorCurriculum(session.accessToken, {
|
||||||
courseId: selectedCourseId || null,
|
courseId: selectedCourseId || null,
|
||||||
groupId: selectedGroupId || null,
|
groupId: selectedGroupId || null,
|
||||||
@@ -337,9 +442,12 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
activeLearningStep,
|
activeLearningStep,
|
||||||
applyToStudents: false,
|
applyToStudents: false,
|
||||||
disabledModuleIds,
|
disabledModuleIds,
|
||||||
|
moduleDefaultParameters,
|
||||||
|
environmentModuleSets,
|
||||||
visualizationPlatformKeys: visualizationPlatforms
|
visualizationPlatformKeys: visualizationPlatforms
|
||||||
.filter((platform) => visualizationEnabledByKey[platform.key])
|
.filter((platform) => visualizationEnabledByKey[platform.key])
|
||||||
.map((platform) => platform.key),
|
.map((platform) => platform.key),
|
||||||
|
mapKeys: mapOptions.filter((map) => mapEnabledByKey[map.key]).map((map) => map.key),
|
||||||
});
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setActiveWeek(selectedWeek.week);
|
setActiveWeek(selectedWeek.week);
|
||||||
@@ -354,6 +462,29 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateMap = async () => {
|
||||||
|
const name = mapName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
setCreatingMap(true);
|
||||||
|
try {
|
||||||
|
const map = await createInstructorCurriculumMap(session.accessToken, {
|
||||||
|
courseId: selectedCourseId || null,
|
||||||
|
name,
|
||||||
|
description: mapDescription.trim(),
|
||||||
|
});
|
||||||
|
setMapOptions((current) => [...current, map]);
|
||||||
|
setMapEnabledByKey((current) => ({ ...current, [map.key]: true }));
|
||||||
|
setMapCreateOpen(false);
|
||||||
|
setMapName('');
|
||||||
|
setMapDescription('');
|
||||||
|
showToast({ title: '학습 맵이 추가되었습니다.', message: '현재 주차에 선택된 상태입니다.' });
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '학습 맵을 등록하지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setCreatingMap(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddWeek = async () => {
|
const handleAddWeek = async () => {
|
||||||
const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1;
|
const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -767,34 +898,236 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
{selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => {
|
{selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => {
|
||||||
const enabled = moduleEnabledById[module.id] !== false;
|
const enabled = moduleEnabledById[module.id] !== false;
|
||||||
return (
|
return (
|
||||||
<label
|
<div
|
||||||
key={module.id}
|
key={module.id}
|
||||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-70 hover:bg-[#f1f5f9]'}`}
|
className={`rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-70 hover:bg-[#f1f5f9]'}`}
|
||||||
>
|
>
|
||||||
<input
|
<div className="flex items-center gap-3">
|
||||||
type="checkbox"
|
<input
|
||||||
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
|
type="checkbox"
|
||||||
checked={enabled}
|
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
|
||||||
onChange={(event) => {
|
checked={enabled}
|
||||||
const checked = event.target.checked;
|
onChange={(event) => {
|
||||||
setModuleEnabledById((current) => ({ ...current, [module.id]: checked }));
|
const checked = event.target.checked;
|
||||||
}}
|
setModuleEnabledById((current) => ({ ...current, [module.id]: checked }));
|
||||||
/>
|
}}
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{module.sortOrder}</span>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#ede9ff] text-[12px] font-black text-[#4f46e5]">{module.sortOrder}</span>
|
||||||
<span className="block truncate text-[14px] font-black text-[#111827]">{module.name}</span>
|
<span className="min-w-0 flex-1">
|
||||||
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{module.category} · {module.folderName}</span>
|
<span className="block truncate text-[14px] font-black text-[#111827]">{module.name}</span>
|
||||||
</span>
|
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{module.category} · {module.folderName}</span>
|
||||||
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
|
</span>
|
||||||
{enabled ? '사용' : '미사용'}
|
<span className={`shrink-0 rounded-md px-2.5 py-1 text-[11px] font-black ${enabled ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#e2e8f0] text-[#64748b]'}`}>
|
||||||
</span>
|
{enabled ? '사용' : '미사용'}
|
||||||
</label>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 border-t border-[#e2e8f0] pt-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[12px] font-bold text-[#475569]">기본 파라미터</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border border-[#bfdbfe] px-2 text-[12px] font-bold text-[#2563eb] transition hover:bg-[#eff6ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => setModuleDefaultParametersById((current) => ({
|
||||||
|
...current,
|
||||||
|
[module.id]: [...(current[module.id] ?? []), { key: '', value: '' }],
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
행 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-2">
|
||||||
|
{(moduleDefaultParametersById[module.id] ?? []).map((parameter, parameterIndex) => (
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_28px] items-center gap-2" key={`${module.id}-${parameterIndex}`}>
|
||||||
|
<input
|
||||||
|
className="h-8 min-w-0 rounded-md border border-[#cbd5e1] bg-white px-2.5 font-mono text-[12px] text-[#111827] outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:cursor-not-allowed disabled:bg-[#f1f5f9]"
|
||||||
|
value={parameter.key}
|
||||||
|
placeholder="키"
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(event) => setModuleDefaultParametersById((current) => ({
|
||||||
|
...current,
|
||||||
|
[module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, key: event.target.value } : item),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-8 min-w-0 rounded-md border border-[#cbd5e1] bg-white px-2.5 font-mono text-[12px] text-[#111827] outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe] disabled:cursor-not-allowed disabled:bg-[#f1f5f9]"
|
||||||
|
value={parameter.value}
|
||||||
|
placeholder="값"
|
||||||
|
disabled={!enabled}
|
||||||
|
onChange={(event) => setModuleDefaultParametersById((current) => ({
|
||||||
|
...current,
|
||||||
|
[module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, value: event.target.value } : item),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="행 삭제"
|
||||||
|
aria-label="행 삭제"
|
||||||
|
className="grid h-7 w-7 cursor-pointer place-items-center rounded-md text-[#94a3b8] transition hover:bg-[#fef2f2] hover:text-[#dc2626] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => setModuleDefaultParametersById((current) => ({
|
||||||
|
...current,
|
||||||
|
[module.id]: (current[module.id] ?? []).filter((_, index) => index !== parameterIndex),
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}) : (
|
}) : (
|
||||||
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 모듈이 없습니다.</p>
|
<p className="rounded-lg border border-dashed border-[#d1d9e6] px-4 py-10 text-center text-[13px] font-bold text-[#64748b]">등록된 모듈이 없습니다.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{(selectedWeek?.environmentProfiles?.length ?? 0) > 0 ? (
|
||||||
|
<section className="border-t border-[#cbd5e1] pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[15px] font-black text-[#111827]">환경별 모듈 구성</h3>
|
||||||
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">소스 저장소는 공통으로 유지하고, 로컬과 플랫폼에서 실행할 모듈만 다르게 선택합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-3">
|
||||||
|
{(selectedWeek.environmentProfiles ?? []).map((profile) => {
|
||||||
|
const selectedModuleIds = new Set(environmentModuleIdsByPlatformKey[profile.platformKey] ?? []);
|
||||||
|
const selectedCount = (selectedWeek.modules ?? []).filter((module) => selectedModuleIds.has(module.id)).length;
|
||||||
|
const targetLabel = profile.platformKey === 'futuredrive-local-pc' ? '로컬' : '플랫폼';
|
||||||
|
return (
|
||||||
|
<div key={profile.id} className="rounded-lg border border-[#d1d9e6] bg-[#f8fafc] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-[13px] font-black text-[#111827]">{profile.name}</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] font-bold text-[#64748b]">{targetLabel} · {profile.profileType}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-md bg-white px-2.5 py-1 text-[11px] font-black text-[#475569]">{selectedCount}/{selectedWeek.modules?.length ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{(selectedWeek.modules ?? []).map((module) => {
|
||||||
|
const globallyEnabled = moduleEnabledById[module.id] !== false;
|
||||||
|
const checked = selectedModuleIds.has(module.id);
|
||||||
|
const parameterRows = environmentModuleParametersByPlatformKey[profile.platformKey]?.[module.id] ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={module.id}
|
||||||
|
className={`min-w-0 rounded-md border px-2.5 py-2 text-[12px] font-bold transition ${globallyEnabled ? 'border-[#dbe3ee] bg-white hover:border-[#93c5fd]' : 'cursor-not-allowed border-[#e2e8f0] bg-[#f1f5f9] text-[#94a3b8]'}`}
|
||||||
|
>
|
||||||
|
<label className={`flex min-w-0 items-center gap-2 ${globallyEnabled ? 'cursor-pointer' : 'cursor-not-allowed'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3.5 w-3.5 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb] disabled:cursor-not-allowed"
|
||||||
|
checked={checked}
|
||||||
|
disabled={!globallyEnabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const enabled = event.target.checked;
|
||||||
|
setEnvironmentModuleIdsByPlatformKey((current) => {
|
||||||
|
const currentIds = new Set(current[profile.platformKey] ?? []);
|
||||||
|
if (enabled) currentIds.add(module.id);
|
||||||
|
else currentIds.delete(module.id);
|
||||||
|
return { ...current, [profile.platformKey]: [...currentIds] };
|
||||||
|
});
|
||||||
|
if (!enabled) {
|
||||||
|
setEnvironmentModuleParametersByPlatformKey((current) => {
|
||||||
|
const profileParameters = { ...(current[profile.platformKey] ?? {}) };
|
||||||
|
delete profileParameters[module.id];
|
||||||
|
return { ...current, [profile.platformKey]: profileParameters };
|
||||||
|
});
|
||||||
|
setEnvironmentModuleDockerImagesByPlatformKey((current) => {
|
||||||
|
const profileImages = { ...(current[profile.platformKey] ?? {}) };
|
||||||
|
delete profileImages[module.id];
|
||||||
|
return { ...current, [profile.platformKey]: profileImages };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">{module.name}</span>
|
||||||
|
</label>
|
||||||
|
{checked ? (
|
||||||
|
<div className="mt-2 border-t border-[#e2e8f0] pt-2">
|
||||||
|
<label className="mb-2 block text-[10px] text-[#64748b]">
|
||||||
|
<span className="mb-1 block">실행 이미지</span>
|
||||||
|
<input
|
||||||
|
value={environmentModuleDockerImagesByPlatformKey[profile.platformKey]?.[module.id] ?? ''}
|
||||||
|
placeholder={module.dockerImage ?? '기본 이미지 사용'}
|
||||||
|
className="h-7 w-full rounded border border-[#dbe3ee] px-1.5 font-mono text-[11px] font-medium outline-none focus:border-[#60a5fa]"
|
||||||
|
onChange={(event) => setEnvironmentModuleDockerImagesByPlatformKey((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.platformKey]: {
|
||||||
|
...(current[profile.platformKey] ?? {}),
|
||||||
|
[module.id]: event.target.value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2 text-[10px] text-[#64748b]">
|
||||||
|
<span>실행 파라미터</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded text-[#2563eb] hover:bg-[#eff6ff]"
|
||||||
|
aria-label={`${module.name} 파라미터 추가`}
|
||||||
|
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.platformKey]: {
|
||||||
|
...(current[profile.platformKey] ?? {}),
|
||||||
|
[module.id]: [...(current[profile.platformKey]?.[module.id] ?? []), { key: '', value: '' }],
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{parameterRows.map((parameter, parameterIndex) => (
|
||||||
|
<div key={`${module.id}-${parameterIndex}`} className="mb-1 flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={parameter.key}
|
||||||
|
placeholder="키"
|
||||||
|
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
|
||||||
|
onChange={(event) => setEnvironmentModuleParametersByPlatformKey((current) => {
|
||||||
|
const rows = [...(current[profile.platformKey]?.[module.id] ?? [])];
|
||||||
|
rows[parameterIndex] = { ...rows[parameterIndex], key: event.target.value };
|
||||||
|
return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={parameter.value}
|
||||||
|
placeholder="값"
|
||||||
|
className="min-w-0 flex-1 rounded border border-[#dbe3ee] px-1.5 py-1 text-[11px] font-medium outline-none focus:border-[#60a5fa]"
|
||||||
|
onChange={(event) => setEnvironmentModuleParametersByPlatformKey((current) => {
|
||||||
|
const rows = [...(current[profile.platformKey]?.[module.id] ?? [])];
|
||||||
|
rows[parameterIndex] = { ...rows[parameterIndex], value: event.target.value };
|
||||||
|
return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-[#94a3b8] hover:bg-[#f1f5f9] hover:text-[#dc2626]"
|
||||||
|
aria-label={`${module.name} 파라미터 삭제`}
|
||||||
|
onClick={() => setEnvironmentModuleParametersByPlatformKey((current) => ({
|
||||||
|
...current,
|
||||||
|
[profile.platformKey]: {
|
||||||
|
...(current[profile.platformKey] ?? {}),
|
||||||
|
[module.id]: (current[profile.platformKey]?.[module.id] ?? []).filter((_, index) => index !== parameterIndex),
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
<section className="border-t border-[#cbd5e1] pt-4">
|
<section className="border-t border-[#cbd5e1] pt-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -838,6 +1171,43 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="border-t border-[#cbd5e1] pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[15px] font-black text-[#111827]">학습 맵</h3>
|
||||||
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">학생이 맵 선택 화면에서 사용할 NAV2 학습 환경을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMapCreateOpen(true)}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[#d1d9e6] bg-white px-3 text-[12px] font-black text-[#475569] transition hover:border-[#2563eb] hover:bg-[#eff6ff] hover:text-[#2563eb]"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
맵 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2">
|
||||||
|
{mapOptions.map((map) => {
|
||||||
|
const enabled = Boolean(mapEnabledByKey[map.key]);
|
||||||
|
return (
|
||||||
|
<label key={map.key} className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 transition ${enabled ? 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]' : 'border-[#cbd5e1] bg-[#f8fafc] opacity-75 hover:bg-[#f1f5f9]'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 shrink-0 cursor-pointer rounded border-[#cbd5e1] text-[#2563eb] focus:ring-[#2563eb]"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(event) => setMapEnabledByKey((current) => ({ ...current, [map.key]: event.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-[#eff6ff] text-[#2563eb]"><Map size={15} /></span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[14px] font-black text-[#111827]">{map.name}</span>
|
||||||
|
<span className="mt-1 block truncate text-[12px] font-bold text-[#64748b]">{map.description}</span>
|
||||||
|
</span>
|
||||||
|
{map.custom ? <span className="shrink-0 rounded-md bg-[#ede9ff] px-2.5 py-1 text-[11px] font-black text-[#6d28d9]">커스텀</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'examples' ? (
|
) : activeTab === 'examples' ? (
|
||||||
<div className="grid gap-4 p-5">
|
<div className="grid gap-4 p-5">
|
||||||
@@ -1218,6 +1588,34 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{mapCreateOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/45 p-5" role="dialog" aria-modal="true" aria-label="학습 맵 추가">
|
||||||
|
<section className="w-full max-w-[420px] rounded-lg border border-[#cbd5e1] bg-white shadow-2xl">
|
||||||
|
<header className="border-b border-[#e2e8f0] px-5 py-4">
|
||||||
|
<h2 className="text-[16px] font-black text-[#111827]">학습 맵 추가</h2>
|
||||||
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">등록 후 현재 주차에 선택된 상태로 추가됩니다.</p>
|
||||||
|
</header>
|
||||||
|
<div className="grid gap-4 px-5 py-4">
|
||||||
|
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
|
||||||
|
맵 이름
|
||||||
|
<input autoFocus value={mapName} onChange={(event) => setMapName(event.target.value)} placeholder="예: 실습 장애물 맵" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
|
||||||
|
</label>
|
||||||
|
<label className="grid gap-1.5 text-[12px] font-black text-[#334155]">
|
||||||
|
설명
|
||||||
|
<input value={mapDescription} onChange={(event) => setMapDescription(event.target.value)} placeholder="학생에게 보여줄 맵 설명" className="h-10 rounded-md border border-[#cbd5e1] px-3 text-[13px] font-semibold outline-none transition focus:border-[#2563eb] focus:ring-2 focus:ring-[#bfdbfe]" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer className="flex justify-end gap-2 border-t border-[#e2e8f0] px-5 py-3">
|
||||||
|
<button type="button" disabled={creatingMap} onClick={() => setMapCreateOpen(false)} className="h-8 rounded-md border border-[#d1d9e6] px-3 text-[12px] font-black text-[#475569] hover:bg-[#f8fafc] disabled:opacity-50">취소</button>
|
||||||
|
<button type="button" disabled={creatingMap || !mapName.trim()} onClick={() => void handleCreateMap()} className="inline-flex h-8 items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-black text-white hover:bg-[#1d4ed8] disabled:opacity-50">
|
||||||
|
{creatingMap ? <Loader2 className="animate-spin" size={13} /> : <Plus size={13} />}
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteConfirmOpen && Boolean(selectedWeek)}
|
open={deleteConfirmOpen && Boolean(selectedWeek)}
|
||||||
title={selectedWeek ? `${selectedWeek.week}주차를 삭제할까요?` : '주차를 삭제할까요?'}
|
title={selectedWeek ? `${selectedWeek.week}주차를 삭제할까요?` : '주차를 삭제할까요?'}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Activity, Battery, Car, CircleAlert, Loader2, Plus, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react';
|
import { Activity, Battery, Car, CircleAlert, KeyRound, Loader2, Plus, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react';
|
||||||
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
import { ConfirmDialog } from '../../components/ConfirmDialog';
|
||||||
import { PageHeader } from '../../components/PageHeader';
|
import { PageHeader } from '../../components/PageHeader';
|
||||||
import { PaginationBar } from '../../components/PaginationBar';
|
import { PaginationBar } from '../../components/PaginationBar';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
deleteInstructorPlatform,
|
deleteInstructorPlatform,
|
||||||
listInstructorPlatformAgents,
|
listInstructorPlatformAgents,
|
||||||
listInstructorPlatforms,
|
listInstructorPlatforms,
|
||||||
|
issueInstructorPlatformRegistration,
|
||||||
PlatformInUseError,
|
PlatformInUseError,
|
||||||
updateInstructorPlatform,
|
updateInstructorPlatform,
|
||||||
type InstructorPlatform,
|
type InstructorPlatform,
|
||||||
@@ -60,12 +61,15 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [registrationModalOpen, setRegistrationModalOpen] = useState(false);
|
||||||
const [settingsTarget, setSettingsTarget] = useState<InstructorPlatform | null>(null);
|
const [settingsTarget, setSettingsTarget] = useState<InstructorPlatform | null>(null);
|
||||||
const [selectedMasterId, setSelectedMasterId] = useState('');
|
const [selectedMasterId, setSelectedMasterId] = useState('');
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState('');
|
const [selectedAgentId, setSelectedAgentId] = useState('');
|
||||||
const [platformName, setPlatformName] = useState('');
|
const [platformName, setPlatformName] = useState('');
|
||||||
const [agentsLoading, setAgentsLoading] = useState(false);
|
const [agentsLoading, setAgentsLoading] = useState(false);
|
||||||
const [agentsError, setAgentsError] = useState<string | null>(null);
|
const [agentsError, setAgentsError] = useState<string | null>(null);
|
||||||
|
const [registrationCode, setRegistrationCode] = useState<string | null>(null);
|
||||||
|
const [registrationExpiresAt, setRegistrationExpiresAt] = useState<string | null>(null);
|
||||||
const [settingsName, setSettingsName] = useState('');
|
const [settingsName, setSettingsName] = useState('');
|
||||||
const [settingsVehicleType, setSettingsVehicleType] = useState('MK-mini');
|
const [settingsVehicleType, setSettingsVehicleType] = useState('MK-mini');
|
||||||
const [settingsWheelbaseMeters, setSettingsWheelbaseMeters] = useState(0.325);
|
const [settingsWheelbaseMeters, setSettingsWheelbaseMeters] = useState(0.325);
|
||||||
@@ -90,7 +94,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null);
|
const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null);
|
||||||
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
|
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
|
||||||
const [assigningPlatformId, setAssigningPlatformId] = useState<string | null>(null);
|
const [assigningPlatformId, setAssigningPlatformId] = useState<string | null>(null);
|
||||||
const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup; session: PlatformInUseError['activeSession'] } | null>(null);
|
const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null);
|
||||||
|
|
||||||
const filteredPlatforms = useMemo(() => {
|
const filteredPlatforms = useMemo(() => {
|
||||||
const keyword = query.trim().toLowerCase();
|
const keyword = query.trim().toLowerCase();
|
||||||
@@ -106,7 +110,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
platforms.forEach((platform) => {
|
platforms.forEach((platform) => {
|
||||||
if (!platform.agent) return;
|
if (!platform.agent) return;
|
||||||
[platform.agent.nodeId, platform.agent.hostname, platform.agent.ip, platform.agent.ownerEmail]
|
[platform.agent.nodeId, platform.agent.hostname, platform.agent.ip]
|
||||||
.map(normalizeAgentKey)
|
.map(normalizeAgentKey)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((key) => keys.add(key));
|
.forEach((key) => keys.add(key));
|
||||||
@@ -121,6 +125,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
{ label: '오류 발생', value: `${summary.errorPlatforms}대`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' },
|
{ label: '오류 발생', value: `${summary.errorPlatforms}대`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' },
|
||||||
];
|
];
|
||||||
const selectedAssignmentGroup = assignmentGroups.find((group) => group.id === selectedAssignmentGroupId) ?? null;
|
const selectedAssignmentGroup = assignmentGroups.find((group) => group.id === selectedAssignmentGroupId) ?? null;
|
||||||
|
const assignmentUnassignedSelected = selectedAssignmentGroupId === 'unassigned';
|
||||||
|
|
||||||
const applyPayload = (payload: InstructorPlatformsResponse) => {
|
const applyPayload = (payload: InstructorPlatformsResponse) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -159,13 +164,13 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
}, [filteredPlatforms.length, page, pageSize]);
|
}, [filteredPlatforms.length, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return;
|
if (!modalOpen && !registrationModalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return;
|
||||||
const originalOverflow = document.body.style.overflow;
|
const originalOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = originalOverflow;
|
document.body.style.overflow = originalOverflow;
|
||||||
};
|
};
|
||||||
}, [assignmentTarget, modalOpen, settingsTarget, deleteTarget]);
|
}, [assignmentTarget, deleteTarget, modalOpen, registrationModalOpen, settingsTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen) return;
|
if (!modalOpen) return;
|
||||||
@@ -238,6 +243,20 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleIssueRegistrationCode = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = await issueInstructorPlatformRegistration(session.accessToken);
|
||||||
|
setRegistrationCode(payload.code);
|
||||||
|
setRegistrationExpiresAt(payload.expiresAt);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '등록 키를 발급하지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => {
|
const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => {
|
||||||
setDeletingPlatformId(platform.id);
|
setDeletingPlatformId(platform.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -283,20 +302,20 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
|
|
||||||
const handleAssignPlatform = async (
|
const handleAssignPlatform = async (
|
||||||
platform: InstructorPlatform,
|
platform: InstructorPlatform,
|
||||||
group: InstructorGroup,
|
group: InstructorGroup | null,
|
||||||
force = false,
|
force = false,
|
||||||
) => {
|
) => {
|
||||||
setAssigningPlatformId(platform.id);
|
setAssigningPlatformId(platform.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await assignInstructorPlatform(session.accessToken, platform.id, group.id, { force });
|
const payload = await assignInstructorPlatform(session.accessToken, platform.id, group?.id ?? 'unassigned', { force });
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setAssignmentTarget(null);
|
setAssignmentTarget(null);
|
||||||
setForceAssignmentTarget(null);
|
setForceAssignmentTarget(null);
|
||||||
setSelectedAssignmentGroupId('');
|
setSelectedAssignmentGroupId('');
|
||||||
showToast({
|
showToast({
|
||||||
title: '플랫폼이 배정되었습니다.',
|
title: group ? '플랫폼이 배정되었습니다.' : '플랫폼 배정이 해제되었습니다.',
|
||||||
message: `${platform.name} · ${group.name}`,
|
message: group ? `${platform.name} · ${group.name}` : platform.name,
|
||||||
});
|
});
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (reason instanceof PlatformInUseError) {
|
if (reason instanceof PlatformInUseError) {
|
||||||
@@ -353,10 +372,24 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
route="platforms"
|
route="platforms"
|
||||||
action={
|
action={
|
||||||
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={handleOpenCreateModal}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus size={15} aria-hidden="true" />
|
<button
|
||||||
플랫폼 추가
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#2563eb] bg-white px-3 text-[13px] font-bold text-[#2563eb] hover:bg-[#eff6ff]"
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRegistrationCode(null);
|
||||||
|
setRegistrationExpiresAt(null);
|
||||||
|
setRegistrationModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KeyRound size={15} aria-hidden="true" />
|
||||||
|
키 발급
|
||||||
|
</button>
|
||||||
|
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[13px] font-bold text-white hover:bg-[#1d4ed8]" type="button" onClick={handleOpenCreateModal}>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
플랫폼 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -370,7 +403,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 리스트</h2>
|
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 리스트</h2>
|
||||||
<p className="mt-1 text-[12px] font-bold text-[#64748b]">배터리, 가동 시간, 할당 그룹, 오류 상태 조회</p>
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">배터리, 가동 시간, 할당 그룹 상태 조회</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
<label className="flex h-9 w-full max-w-[260px] items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[#94a3b8]">
|
||||||
@@ -380,15 +413,14 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="xl:min-w-[1100px]">
|
<div className="xl:min-w-[1020px]">
|
||||||
<div className="hidden select-none gap-4 grid-cols-[44px_200px_110px_92px_90px_110px_80px_150px_184px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
|
<div className="hidden select-none gap-4 grid-cols-[44px_200px_110px_92px_90px_110px_150px_184px] items-center border-b border-[#cbd5e1] bg-[#f8fafc] px-4 py-3 text-center text-[11px] font-bold text-[#64748b] xl:grid">
|
||||||
<span>No.</span>
|
<span>No.</span>
|
||||||
<span>플랫폼</span>
|
<span>플랫폼</span>
|
||||||
<span>연결</span>
|
<span>연결</span>
|
||||||
<span>상태</span>
|
<span>상태</span>
|
||||||
<span>배터리</span>
|
<span>배터리</span>
|
||||||
<span>가동 시간</span>
|
<span>가동 시간</span>
|
||||||
<span>오류</span>
|
|
||||||
<span>할당</span>
|
<span>할당</span>
|
||||||
<span>관리</span>
|
<span>관리</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +432,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</div>
|
</div>
|
||||||
) : pagedPlatforms.length > 0 ? (
|
) : pagedPlatforms.length > 0 ? (
|
||||||
pagedPlatforms.map((platform, index) => (
|
pagedPlatforms.map((platform, index) => (
|
||||||
<article className="grid select-none gap-4 px-4 py-4 xl:grid-cols-[44px_200px_110px_92px_90px_110px_80px_150px_184px] xl:items-center" key={platform.id}>
|
<article className="grid select-none gap-4 px-4 py-4 xl:grid-cols-[44px_200px_110px_92px_90px_110px_150px_184px] xl:items-center" key={platform.id}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
|
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">번호</p>
|
||||||
<span className="text-[13px] font-bold text-[#64748b]">{filteredPlatforms.length - ((page - 1) * pageSize + index)}</span>
|
<span className="text-[13px] font-bold text-[#64748b]">{filteredPlatforms.length - ((page - 1) * pageSize + index)}</span>
|
||||||
@@ -449,8 +481,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} />
|
<PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} />
|
||||||
<PlatformMeta icon={CircleAlert} label="오류" value={`${platform.errors}건`} danger={platform.errors > 0} />
|
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">할당</p>
|
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">할당</p>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
@@ -498,6 +528,42 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{registrationModalOpen ? createPortal(
|
||||||
|
<div className="fixed inset-0 z-[70] grid place-items-center bg-[#0f172a]/24 px-4 backdrop-blur-[2px]">
|
||||||
|
<section className="w-full max-w-[400px] rounded-lg border border-[#dbe3ef] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.22)]">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b border-[#cbd5e1] px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[16px] font-black text-[#111827]">교보재 등록 키</h3>
|
||||||
|
<p className="mt-1 text-[12px] font-bold text-[#64748b]">에이전트 설치 화면에서 강사 계정 이메일과 함께 입력하세요.</p>
|
||||||
|
</div>
|
||||||
|
<button className="grid h-8 w-8 place-items-center rounded-md text-[#64748b] hover:bg-[#f1f5f9] hover:text-[#111827]" type="button" onClick={() => setRegistrationModalOpen(false)} disabled={saving} aria-label="닫기" title="닫기">
|
||||||
|
<X size={17} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{registrationCode ? (
|
||||||
|
<div className="rounded-md border border-[#bfdbfe] bg-[#eff6ff] px-4 py-4 text-center">
|
||||||
|
<p className="font-mono text-[26px] font-black tracking-[0.18em] text-[#1d4ed8]">{registrationCode}</p>
|
||||||
|
<p className="mt-2 text-[11px] font-bold text-[#476581]">{registrationExpiresAt ? `${new Date(registrationExpiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}까지 사용할 수 있습니다.` : '한 번만 사용할 수 있습니다.'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] font-bold leading-6 text-[#475569]">키를 발급한 뒤 교보재 장비에 에이전트를 설치하세요. 설치가 완료되어 연결되면 플랫폼 추가에서 해당 에이전트를 선택할 수 있습니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 border-t border-[#cbd5e1] px-5 py-4">
|
||||||
|
<button className="h-9 rounded-md border border-[#d1d9e6] bg-white px-4 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc]" type="button" onClick={() => setRegistrationModalOpen(false)} disabled={saving}>닫기</button>
|
||||||
|
{!registrationCode ? (
|
||||||
|
<button className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60" type="button" onClick={() => void handleIssueRegistrationCode()} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <KeyRound size={15} aria-hidden="true" />}
|
||||||
|
키 발급
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
) : null}
|
||||||
|
|
||||||
{modalOpen ? createPortal(
|
{modalOpen ? createPortal(
|
||||||
<div className="fixed inset-0 z-[70] grid place-items-center bg-[#0f172a]/24 px-4 backdrop-blur-[2px]">
|
<div className="fixed inset-0 z-[70] grid place-items-center bg-[#0f172a]/24 px-4 backdrop-blur-[2px]">
|
||||||
<section className="w-full max-w-[420px] rounded-lg border border-[#dbe3ef] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.22)]">
|
<section className="w-full max-w-[420px] rounded-lg border border-[#dbe3ef] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.22)]">
|
||||||
@@ -633,7 +699,19 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</div>
|
</div>
|
||||||
) : assignmentGroupsError ? (
|
) : assignmentGroupsError ? (
|
||||||
<div className="rounded-md border border-dashed border-[#fecdd3] bg-[#fff1f2] px-3 py-10 text-center text-[12px] font-bold text-[#e11d48]">{assignmentGroupsError}</div>
|
<div className="rounded-md border border-dashed border-[#fecdd3] bg-[#fff1f2] px-3 py-10 text-center text-[12px] font-bold text-[#e11d48]">{assignmentGroupsError}</div>
|
||||||
) : assignmentGroups.length ? assignmentGroups.map((group) => {
|
) : <>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${assignmentUnassignedSelected ? 'border-[#2563eb] bg-[#eff6ff]' : 'border-[#d1d9e6] bg-white hover:bg-[#f8fafc]'}`}
|
||||||
|
onClick={() => setSelectedAssignmentGroupId(assignmentUnassignedSelected ? '' : 'unassigned')}
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-[13px] font-black text-[#111827]">미배정</span>
|
||||||
|
<span className="mt-0.5 block truncate text-[11px] font-bold text-[#64748b]">현재 플랫폼 배정을 해제합니다.</span>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 rounded bg-[#f1f5f9] px-2 py-1 text-[10px] font-black text-[#64748b]">배정 해제</span>
|
||||||
|
</button>
|
||||||
|
{assignmentGroups.map((group) => {
|
||||||
const selected = selectedAssignmentGroupId === group.id;
|
const selected = selectedAssignmentGroupId === group.id;
|
||||||
const assigned = Boolean(group.instructorPlatformId);
|
const assigned = Boolean(group.instructorPlatformId);
|
||||||
return (
|
return (
|
||||||
@@ -655,9 +733,8 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}) : (
|
})}
|
||||||
<div className="rounded-md border border-dashed border-[#d1d9e6] px-3 py-10 text-center text-[12px] font-bold text-[#94a3b8]">등록된 그룹이 없습니다.</div>
|
</>}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,12 +745,14 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#2563eb] px-4 text-[13px] font-bold text-white hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (assignmentTarget && selectedAssignmentGroup) void handleAssignPlatform(assignmentTarget, selectedAssignmentGroup);
|
if (assignmentTarget && (selectedAssignmentGroup || assignmentUnassignedSelected)) {
|
||||||
|
void handleAssignPlatform(assignmentTarget, selectedAssignmentGroup);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={Boolean(assigningPlatformId) || !assignmentTarget || !selectedAssignmentGroup || Boolean(selectedAssignmentGroup.instructorPlatformId)}
|
disabled={Boolean(assigningPlatformId) || !assignmentTarget || (!selectedAssignmentGroup && !assignmentUnassignedSelected) || Boolean(selectedAssignmentGroup?.instructorPlatformId)}
|
||||||
>
|
>
|
||||||
{assigningPlatformId === assignmentTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Users size={15} aria-hidden="true" />}
|
{assigningPlatformId === assignmentTarget.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Users size={15} aria-hidden="true" />}
|
||||||
배정
|
{assignmentUnassignedSelected ? '배정 해제' : '배정'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -848,12 +927,12 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(forceAssignmentTarget)}
|
open={Boolean(forceAssignmentTarget)}
|
||||||
title="사용 중인 플랫폼입니다"
|
title="사용 중인 플랫폼입니다"
|
||||||
message="배정을 변경하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속 배정할까요?"
|
message={forceAssignmentTarget?.group ? '배정을 변경하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속 배정할까요?' : '배정을 해제하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속할까요?'}
|
||||||
detail={forceAssignmentTarget ? (
|
detail={forceAssignmentTarget ? (
|
||||||
<div className="rounded-lg border border-[#fed7aa] bg-[#fff7ed] px-3 py-2">
|
<div className="rounded-lg border border-[#fed7aa] bg-[#fff7ed] px-3 py-2">
|
||||||
<p className="text-[13px] font-black text-[#9a3412]">{forceAssignmentTarget.platform.name}</p>
|
<p className="text-[13px] font-black text-[#9a3412]">{forceAssignmentTarget.platform.name}</p>
|
||||||
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
|
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
|
||||||
배정 대상: {forceAssignmentTarget.group.name}
|
{forceAssignmentTarget.group ? `배정 대상: ${forceAssignmentTarget.group.name}` : '배정 해제'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
|
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
|
||||||
사용 중: {forceAssignmentTarget.session.holderName ?? forceAssignmentTarget.session.holderEmail ?? '알 수 없음'}
|
사용 중: {forceAssignmentTarget.session.holderName ?? forceAssignmentTarget.session.holderEmail ?? '알 수 없음'}
|
||||||
@@ -863,7 +942,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
confirmLabel="강제 종료 후 배정"
|
confirmLabel={forceAssignmentTarget?.group ? '강제 종료 후 배정' : '강제 종료 후 해제'}
|
||||||
danger
|
danger
|
||||||
icon={ShieldAlert}
|
icon={ShieldAlert}
|
||||||
loading={Boolean(forceAssignmentTarget && assigningPlatformId === forceAssignmentTarget.platform.id)}
|
loading={Boolean(forceAssignmentTarget && assigningPlatformId === forceAssignmentTarget.platform.id)}
|
||||||
@@ -910,15 +989,10 @@ function normalizeAgentKey(value: string | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRegisteredAgent(agent: TeachingAgent, registeredAgentKeys: Set<string>) {
|
function isRegisteredAgent(agent: TeachingAgent, registeredAgentKeys: Set<string>) {
|
||||||
const registeredKeys = Array.from(registeredAgentKeys);
|
return [agent.id, agent.hostname, agent.name, agent.ip]
|
||||||
return [agent.id, agent.hostname, agent.name, agent.ip, agent.user]
|
|
||||||
.map(normalizeAgentKey)
|
.map(normalizeAgentKey)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((agentKey) => registeredKeys.some((registeredKey) => (
|
.some((agentKey) => registeredAgentKeys.has(agentKey));
|
||||||
agentKey === registeredKey ||
|
|
||||||
agentKey.includes(registeredKey) ||
|
|
||||||
registeredKey.includes(agentKey)
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user