platform/lms-adm-fe release promotion snapshot
This commit is contained in:
+59
-1
@@ -3,7 +3,7 @@ import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { ToastProvider } from './components/ToastProvider';
|
import { ToastProvider } from './components/ToastProvider';
|
||||||
import { InstructorLayout } from './layouts/InstructorLayout';
|
import { InstructorLayout } from './layouts/InstructorLayout';
|
||||||
import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth';
|
import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth';
|
||||||
import { listInstructorCourses, type InstructorCourse } from './lib/courses';
|
import { createInitialInstructorCourse, listInstructorCourses, type InstructorCourse } from './lib/courses';
|
||||||
import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
|
import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
|
||||||
import { CurriculumPage } from './pages/curriculum/CurriculumPage';
|
import { CurriculumPage } from './pages/curriculum/CurriculumPage';
|
||||||
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||||
@@ -22,6 +22,10 @@ function App() {
|
|||||||
const [courses, setCourses] = useState<InstructorCourse[]>([]);
|
const [courses, setCourses] = useState<InstructorCourse[]>([]);
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
|
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
|
||||||
const [sessionExpired, setSessionExpired] = useState(false);
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
|
const [initialCourseOpen, setInitialCourseOpen] = useState(false);
|
||||||
|
const [initialCourseTitle, setInitialCourseTitle] = useState('');
|
||||||
|
const [initialCourseError, setInitialCourseError] = useState<string | null>(null);
|
||||||
|
const [creatingInitialCourse, setCreatingInitialCourse] = useState(false);
|
||||||
const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null);
|
const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,6 +100,7 @@ function App() {
|
|||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCourses(payload.courses);
|
setCourses(payload.courses);
|
||||||
|
setInitialCourseOpen(payload.courses.length === 0);
|
||||||
setSelectedCourseId((current) => {
|
setSelectedCourseId((current) => {
|
||||||
const next = payload.courses.some((course) => course.id === current)
|
const next = payload.courses.some((course) => course.id === current)
|
||||||
? current
|
? current
|
||||||
@@ -117,6 +122,28 @@ function App() {
|
|||||||
if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId);
|
if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateInitialCourse = async () => {
|
||||||
|
const title = initialCourseTitle.trim();
|
||||||
|
if (!title) {
|
||||||
|
setInitialCourseError('강좌명을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreatingInitialCourse(true);
|
||||||
|
setInitialCourseError(null);
|
||||||
|
try {
|
||||||
|
const { course } = await createInitialInstructorCourse(session!.accessToken, title);
|
||||||
|
setCourses([course]);
|
||||||
|
setSelectedCourseId(course.id);
|
||||||
|
window.localStorage.setItem('lms-admin-selected-course-id', course.id);
|
||||||
|
setInitialCourseOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
setInitialCourseError(error instanceof Error ? error.message : '강좌를 생성하지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setCreatingInitialCourse(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.accessToken || !session.refreshToken) return;
|
if (!session?.accessToken || !session.refreshToken) return;
|
||||||
|
|
||||||
@@ -203,6 +230,37 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</InstructorLayout>
|
</InstructorLayout>
|
||||||
|
{initialCourseOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] grid place-items-center bg-[#111827]/35 px-5" role="dialog" aria-modal="true" aria-labelledby="initial-course-title">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-[400px] rounded-lg border border-[#d9e0ea] bg-white p-6 shadow-[0_22px_70px_rgba(15,23,42,0.22)]"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleCreateInitialCourse();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 id="initial-course-title" className="text-[18px] font-black text-[#111827]">강좌명을 입력하세요</h1>
|
||||||
|
<p className="mt-2 text-[13px] leading-relaxed text-[#64748b]">NAV2 기반 자율주행 교육 과정 구성을 새 강좌로 복제합니다.</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={initialCourseTitle}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={(event) => setInitialCourseTitle(event.target.value)}
|
||||||
|
className="mt-5 h-10 w-full rounded-md border border-[#cbd5e1] px-3 text-[14px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
|
||||||
|
placeholder="예: 2026 하반기 자율주행 교육"
|
||||||
|
disabled={creatingInitialCourse}
|
||||||
|
/>
|
||||||
|
{initialCourseError && <p className="mt-2 text-[12px] font-semibold text-[#dc2626]">{initialCourseError}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creatingInitialCourse}
|
||||||
|
className="mt-5 h-10 w-full rounded-md bg-[#2563eb] text-[14px] font-black text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{creatingInitialCourse ? '생성 중...' : '확인'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { AuthSession } from '../types/auth';
|
|||||||
|
|
||||||
type InstructorChatPanelProps = {
|
type InstructorChatPanelProps = {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
courseId: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
initialThreadId?: string | null;
|
initialThreadId?: string | null;
|
||||||
initialThreadRequestId?: number;
|
initialThreadRequestId?: number;
|
||||||
@@ -30,7 +31,7 @@ type ChatNotificationPayload = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
|
export function InstructorChatPanel({ session, courseId, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
|
||||||
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
const [threads, setThreads] = useState<InstructorChatThread[]>([]);
|
||||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -45,7 +46,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
|||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const request = listInstructorChatThreads(session.accessToken)
|
const request = listInstructorChatThreads(session.accessToken, courseId)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (!cancelled) setThreads(items);
|
if (!cancelled) setThreads(items);
|
||||||
})
|
})
|
||||||
@@ -91,7 +92,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
|||||||
return () => {
|
return () => {
|
||||||
refresh.cancel();
|
refresh.cancel();
|
||||||
};
|
};
|
||||||
}, [open, session.accessToken]);
|
}, [courseId, open, session.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !initialThreadId) return;
|
if (!open || !initialThreadId) return;
|
||||||
@@ -124,7 +125,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
|
|||||||
socket.off('chat.message.created', handleMessageCreated);
|
socket.off('chat.message.created', handleMessageCreated);
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
};
|
};
|
||||||
}, [open, selectedThreadId, session.accessToken]);
|
}, [courseId, open, selectedThreadId, session.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) setSelectedThreadId(null);
|
if (!open) setSelectedThreadId(null);
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
|
|||||||
|
|
||||||
<InstructorChatPanel
|
<InstructorChatPanel
|
||||||
session={session}
|
session={session}
|
||||||
|
courseId={selectedCourseId}
|
||||||
open={chatOpen}
|
open={chatOpen}
|
||||||
initialThreadId={chatThreadId}
|
initialThreadId={chatThreadId}
|
||||||
initialThreadRequestId={chatThreadRequestId}
|
initialThreadRequestId={chatThreadRequestId}
|
||||||
|
|||||||
+2
-1
@@ -49,8 +49,9 @@ type ChatThreadsPayload = {
|
|||||||
data?: unknown[];
|
data?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> {
|
export async function listInstructorChatThreads(accessToken: string, courseId: string, week?: number): Promise<InstructorChatThread[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
if (courseId) params.set('courseId', courseId);
|
||||||
if (week) params.set('week', String(week));
|
if (week) params.set('week', String(week));
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ export async function listInstructorCourses(accessToken: string): Promise<{ cour
|
|||||||
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
|
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
|
||||||
return response.json() as Promise<{ courses: InstructorCourse[] }>;
|
return response.json() as Promise<{ courses: InstructorCourse[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createInitialInstructorCourse(accessToken: string, title: string): Promise<{ course: InstructorCourse }> {
|
||||||
|
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/initial`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as { message?: string } | null;
|
||||||
|
throw new Error(payload?.message || `강좌를 생성하지 못했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{ course: InstructorCourse }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ function isPlatformEnvironment(config: unknown): boolean {
|
|||||||
return target === 'remote-jetson' || target === 'platform';
|
return target === 'remote-jetson' || target === 'platform';
|
||||||
}
|
}
|
||||||
|
|
||||||
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
|
||||||
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
function parseModuleParameter(parameter: string): ModuleParameterDraft {
|
||||||
const separatorIndex = parameter.indexOf(':=');
|
const separatorIndex = parameter.indexOf(':=');
|
||||||
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
|
if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
|
||||||
@@ -1584,3 +1580,6 @@ function formatFileSize(bytes: number): string {
|
|||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
|
||||||
|
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenCha
|
|||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
listInstructorPlatforms(session.accessToken),
|
listInstructorPlatforms(session.accessToken),
|
||||||
listInstructorGroups(session.accessToken, selectedCourseId || null),
|
listInstructorGroups(session.accessToken, selectedCourseId || null),
|
||||||
listInstructorChatThreads(session.accessToken),
|
listInstructorChatThreads(session.accessToken, selectedCourseId),
|
||||||
])
|
])
|
||||||
.then(([platformResult, groupResult, chatResult]) => {
|
.then(([platformResult, groupResult, chatResult]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user