import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { Activity, Car, CircleAlert, KeyRound, Loader2, Plus, RefreshCw, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { PageHeader } from '../../components/PageHeader'; import { PaginationBar } from '../../components/PaginationBar'; import { useToast } from '../../components/ToastProvider'; import { assignInstructorPlatform, createInstructorPlatform, deleteInstructorPlatform, listInstructorPlatformAgents, listInstructorPlatforms, issueInstructorPlatformRegistration, syncInstructorPlatformAccess, PlatformInUseError, updateInstructorPlatform, type InstructorPlatform, type InstructorPlatformsResponse, type InstructorPlatformsSummary, type MasterPlatform, type PlatformAlgorithms, type PlatformSettingItem, type TeachingAgent, } from '../../lib/platforms'; import { listInstructorGroups, type InstructorGroup } from '../../lib/groups'; import type { AuthSession } from '../../types/auth'; const emptySummary: InstructorPlatformsSummary = { totalPlatforms: 0, activePlatforms: 0, standbyPlatforms: 0, errorPlatforms: 0, }; const defaultSensors: PlatformSettingItem[] = [ { title: 'LiDAR', description: 'Robosense Helios 16채널', enabled: true }, { title: 'RGB-D 카메라', description: 'Intel Realsense D435', enabled: true }, { title: 'IMU 센서', description: '9축 관성측정장치', enabled: true }, ]; const defaultDrivers: PlatformSettingItem[] = [ { title: 'LiDAR 드라이버', description: 'robosense_driver v1.5.0' }, { title: 'Camera 드라이버', description: 'realsense2_camera v4.55.1' }, { title: 'IMU 드라이버', description: 'xsens_driver v2.2.0' }, ]; const defaultAlgorithms: PlatformAlgorithms = { slam: 'Cartographer', planning: 'A* + DWA', control: 'PID Control', }; export function PlatformsPage({ session }: { session: AuthSession }) { const { showToast } = useToast(); const [platforms, setPlatforms] = useState([]); const [masterPlatforms, setMasterPlatforms] = useState([]); const [agents, setAgents] = useState([]); const [summary, setSummary] = useState(emptySummary); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(''); const [error, setError] = useState(null); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [modalOpen, setModalOpen] = useState(false); const [registrationModalOpen, setRegistrationModalOpen] = useState(false); const [settingsTarget, setSettingsTarget] = useState(null); const [selectedMasterId, setSelectedMasterId] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState(''); const [platformName, setPlatformName] = useState(''); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState(null); const [registrationCode, setRegistrationCode] = useState(null); const [registrationExpiresAt, setRegistrationExpiresAt] = useState(null); const [settingsName, setSettingsName] = useState(''); const [settingsVehicleType, setSettingsVehicleType] = useState('MK-mini'); const [settingsWheelbaseMeters, setSettingsWheelbaseMeters] = useState(0.325); const [settingsMaxSpeedMetersPerSecond, setSettingsMaxSpeedMetersPerSecond] = useState(7); const [settingsMaxSteeringAngleDegrees, setSettingsMaxSteeringAngleDegrees] = useState(30); const [settingsSensors, setSettingsSensors] = useState([]); const [settingsDrivers, setSettingsDrivers] = useState([]); const [settingsAlgorithms, setSettingsAlgorithms] = useState({ slam: 'Cartographer', planning: 'A* + DWA', control: 'PID Control', }); const [settingsControlDurationMinutes, setSettingsControlDurationMinutes] = useState(30); const [settingsControlWarningMinutes, setSettingsControlWarningMinutes] = useState(5); const [settingsCameraUrl, setSettingsCameraUrl] = useState(''); const [settingsCameraPassword, setSettingsCameraPassword] = useState(''); const [saving, setSaving] = useState(false); const [deletingPlatformId, setDeletingPlatformId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [forceDeleteTarget, setForceDeleteTarget] = useState<{ platform: InstructorPlatform; session: PlatformInUseError['activeSession'] } | null>(null); const [assignmentTarget, setAssignmentTarget] = useState(null); const [assignmentGroups, setAssignmentGroups] = useState([]); const [assignmentGroupsLoading, setAssignmentGroupsLoading] = useState(false); const [assignmentGroupsError, setAssignmentGroupsError] = useState(null); const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState(''); const [assigningPlatformId, setAssigningPlatformId] = useState(null); const [syncingAccess, setSyncingAccess] = useState(false); const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null); const filteredPlatforms = useMemo(() => { const keyword = query.trim().toLowerCase(); if (!keyword) return platforms; return platforms.filter((platform) => [platform.name, platform.type, platform.group ?? '', platform.user ?? '', platform.status, platform.agent?.hostname ?? '', platform.agent?.ip ?? ''] .join(' ') .toLowerCase() .includes(keyword), ); }, [platforms, query]); const registeredAgentKeys = useMemo(() => { const keys = new Set(); platforms.forEach((platform) => { if (!platform.agent) return; [platform.agent.nodeId, platform.agent.hostname, platform.agent.ip] .map(normalizeAgentKey) .filter(Boolean) .forEach((key) => keys.add(key)); }); return keys; }, [platforms]); const pagedPlatforms = useMemo(() => filteredPlatforms.slice((page - 1) * pageSize, page * pageSize), [filteredPlatforms, page, pageSize]); const summaryItems = [ { label: '전체 플랫폼', value: `${summary.totalPlatforms}대`, icon: Car, tone: 'bg-[#eff6ff] text-[#2563eb]' }, { label: '활성 플랫폼', value: `${summary.activePlatforms}대`, icon: Activity, tone: 'bg-[#f0efff] text-[#4f46e5]' }, { label: '대기 플랫폼', value: `${summary.standbyPlatforms}대`, icon: Timer, tone: 'bg-[#f8fafc] text-[#64748b]' }, { label: '오류 발생', value: `${summary.errorPlatforms}대`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' }, ]; const selectedAssignmentGroup = assignmentGroups.find((group) => group.id === selectedAssignmentGroupId) ?? null; const assignmentUnassignedSelected = selectedAssignmentGroupId === 'unassigned'; const applyPayload = (payload: InstructorPlatformsResponse) => { setError(null); setPlatforms(payload.platforms); setMasterPlatforms(payload.masterPlatforms); setSummary(payload.summary); setSelectedMasterId((current) => current || payload.masterPlatforms[0]?.id || ''); }; useEffect(() => { let cancelled = false; setLoading(true); listInstructorPlatforms(session.accessToken) .then((payload) => { if (!cancelled) applyPayload(payload); }) .catch((reason: Error) => { if (cancelled) return; setPlatforms([]); setMasterPlatforms([]); setSummary(emptySummary); setError(reason.message); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [session.accessToken]); useEffect(() => { setPage(1); }, [query]); useEffect(() => { const totalPages = Math.max(1, Math.ceil(filteredPlatforms.length / pageSize)); if (page > totalPages) setPage(totalPages); }, [filteredPlatforms.length, page, pageSize]); useEffect(() => { if (!modalOpen && !registrationModalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; }, [assignmentTarget, deleteTarget, modalOpen, registrationModalOpen, settingsTarget]); useEffect(() => { if (!modalOpen) return; setPlatformName(getNextPlatformName(masterPlatforms, platforms, selectedMasterId)); }, [masterPlatforms, modalOpen, platforms, selectedMasterId]); useEffect(() => { setSettingsName(settingsTarget?.name ?? ''); setSettingsVehicleType(settingsTarget?.vehicleType ?? 'MK-mini'); setSettingsWheelbaseMeters(settingsTarget?.wheelbaseMeters ?? 0.325); setSettingsMaxSpeedMetersPerSecond(settingsTarget?.maxSpeedMetersPerSecond ?? 7); setSettingsMaxSteeringAngleDegrees(settingsTarget?.maxSteeringAngleDegrees ?? 30); setSettingsSensors(normalizeSensorItems(settingsTarget?.sensors?.length ? settingsTarget.sensors : defaultSensors)); setSettingsDrivers(settingsTarget?.drivers?.length ? settingsTarget.drivers : defaultDrivers); setSettingsAlgorithms(settingsTarget?.algorithms ?? defaultAlgorithms); setSettingsControlDurationMinutes(settingsTarget?.controlDurationMinutes ?? 30); setSettingsControlWarningMinutes(settingsTarget?.controlWarningMinutes ?? 5); setSettingsCameraUrl(settingsTarget?.camera?.url ?? ''); setSettingsCameraPassword(settingsTarget?.camera?.password ?? ''); }, [settingsTarget]); const handlePageSizeChange = (nextPageSize: number) => { setPageSize(nextPageSize); setPage(1); }; const handleOpenCreateModal = () => { setModalOpen(true); setSelectedAgentId(''); setAgents([]); setAgentsError(null); setAgentsLoading(true); listInstructorPlatformAgents(session.accessToken) .then((payload) => { setAgents(payload.agents ?? []); }) .catch((reason) => { setAgents([]); setAgentsError(reason instanceof Error ? reason.message : '에이전트 목록을 불러오지 못했습니다.'); }) .finally(() => { setAgentsLoading(false); }); }; const handleCreatePlatform = async () => { if (!selectedMasterId) return; if (!selectedAgentId) { setAgentsError('플랫폼에 연결할 에이전트를 선택해 주세요.'); return; } setSaving(true); setError(null); try { const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const payload = await createInstructorPlatform(session.accessToken, selectedMasterId, platformName, selectedAgent); const before = new Set(platforms.map((platform) => platform.id)); const created = payload.platforms.find((platform) => !before.has(platform.id)); applyPayload(payload); setModalOpen(false); setSelectedAgentId(''); setPage(1); showToast({ title: '플랫폼이 등록되었습니다.', message: created?.name ?? masterPlatforms.find((platform) => platform.id === selectedMasterId)?.name, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '플랫폼을 등록하지 못했습니다.'); } finally { setSaving(false); } }; 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 handleSyncPlatformAccess = async () => { setSyncingAccess(true); setError(null); try { const result = await syncInstructorPlatformAccess(session.accessToken); showToast({ title: '플랫폼 접근 설정을 동기화했습니다.', message: `현재 배정 기준 ${result.assignmentCount}개 플랫폼을 반영했습니다.`, }); } catch (reason) { setError(reason instanceof Error ? reason.message : '플랫폼 접근 설정을 동기화하지 못했습니다.'); } finally { setSyncingAccess(false); } }; const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => { setDeletingPlatformId(platform.id); setError(null); try { const payload = await deleteInstructorPlatform(session.accessToken, platform.id, { force }); applyPayload(payload); setDeleteTarget(null); setForceDeleteTarget(null); showToast({ title: '플랫폼이 삭제되었습니다.', message: platform.name, }); } catch (reason) { if (reason instanceof PlatformInUseError) { setDeleteTarget(null); setForceDeleteTarget({ platform, session: reason.activeSession }); return; } setError(reason instanceof Error ? reason.message : '플랫폼을 삭제하지 못했습니다.'); } finally { setDeletingPlatformId(null); } }; const handleOpenAssignmentModal = (platform: InstructorPlatform) => { setAssignmentTarget(platform); setSelectedAssignmentGroupId(''); setAssignmentGroups([]); setAssignmentGroupsError(null); setAssignmentGroupsLoading(true); listInstructorGroups(session.accessToken) .then((payload) => { setAssignmentGroups(payload.groups ?? []); }) .catch((reason) => { setAssignmentGroups([]); setAssignmentGroupsError(reason instanceof Error ? reason.message : '그룹 목록을 불러오지 못했습니다.'); }) .finally(() => { setAssignmentGroupsLoading(false); }); }; const handleAssignPlatform = async ( platform: InstructorPlatform, group: InstructorGroup | null, force = false, ) => { setAssigningPlatformId(platform.id); setError(null); try { const payload = await assignInstructorPlatform(session.accessToken, platform.id, group?.id ?? 'unassigned', { force }); applyPayload(payload); setAssignmentTarget(null); setForceAssignmentTarget(null); setSelectedAssignmentGroupId(''); showToast({ title: group ? '플랫폼이 배정되었습니다.' : '플랫폼 배정이 해제되었습니다.', message: group ? `${platform.name} · ${group.name}` : platform.name, }); } catch (reason) { if (reason instanceof PlatformInUseError) { setAssignmentTarget(null); setForceAssignmentTarget({ platform, group, session: reason.activeSession }); return; } setError(reason instanceof Error ? reason.message : '플랫폼을 배정하지 못했습니다.'); } finally { setAssigningPlatformId(null); } }; const handleSaveSettings = async () => { if (!settingsTarget) return; const nextName = settingsName.trim(); if (!nextName) { setError('플랫폼명을 입력해 주세요.'); return; } if (settingsControlWarningMinutes >= settingsControlDurationMinutes) { setError('자동 반납 경고 시간은 1회 제어 시간보다 짧아야 합니다.'); return; } setSaving(true); setError(null); try { const payload = await updateInstructorPlatform( session.accessToken, settingsTarget.id, nextName, settingsVehicleType, settingsWheelbaseMeters, settingsMaxSpeedMetersPerSecond, settingsMaxSteeringAngleDegrees, settingsSensors, settingsAlgorithms, settingsControlDurationMinutes, settingsControlWarningMinutes, settingsCameraUrl, settingsCameraPassword, ); applyPayload(payload); showToast({ title: '플랫폼 설정이 저장되었습니다.', message: nextName }); setSettingsTarget(null); } catch (reason) { setError(reason instanceof Error ? reason.message : '플랫폼 설정을 저장하지 못했습니다.'); } finally { setSaving(false); } }; return (
} />
{summaryItems.map((item) => ( ))}

플랫폼 리스트

가동 시간, 할당 그룹 상태 조회

No. 플랫폼 연결 상태 가동 시간 할당 관리
{loading ? (
) : error ? (

{error}

) : pagedPlatforms.length > 0 ? ( pagedPlatforms.map((platform, index) => (

번호

{filteredPlatforms.length - ((page - 1) * pageSize + index)}

{platform.name}

{platform.type}

연결

{platform.agent ? (
{platform.agent.connected ? '연결됨' : '끊김'}
) : ( 미연결 )}

상태

{platform.status}

할당

{platform.group ? ( {platform.group} ) : ( 미할당 )} {platform.user ?? '-'}
)) ) : (

등록된 플랫폼이 없습니다.

)}
{registrationModalOpen ? createPortal(

교보재 등록 키

에이전트 설치 화면에서 강사 계정 이메일과 함께 입력하세요.

{registrationCode ? (

{registrationCode}

{registrationExpiresAt ? `${new Date(registrationExpiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}까지 사용할 수 있습니다.` : '한 번만 사용할 수 있습니다.'}

) : (

키를 발급한 뒤 교보재 장비에 에이전트를 설치하세요. 설치가 완료되어 연결되면 플랫폼 추가에서 해당 에이전트를 선택할 수 있습니다.

)}
{!registrationCode ? ( ) : null}
, document.body, ) : null} {modalOpen ? createPortal(

플랫폼 추가

추가할 플랫폼 유형을 선택해 주세요.

등록된 에이전트 {agentsLoading ? '조회 중' : `${agents.filter((agent) => agent.connected && !agent.expired).length}/${agents.length} 연결`}
{agentsLoading ? (
) : agentsError ? (
{agentsError}
) : agents.length ? agents.map((agent) => { const selected = selectedAgentId === agent.id; const available = agent.connected && !agent.expired; const registered = isRegisteredAgent(agent, registeredAgentKeys); const selectable = available && !registered; return ( ); }) : (
발견된 에이전트가 없습니다.
)}
, document.body, ) : null} {assignmentTarget ? createPortal(

플랫폼 배정

{assignmentTarget.name}을 배정할 그룹을 선택해 주세요.

{assignmentTarget.name}

현재 배정: {assignmentTarget.group ?? '미배정'} · 상태: {assignmentTarget.status}

그룹 목록 미배정 그룹만 선택 가능
{assignmentGroupsLoading ? (
) : assignmentGroupsError ? (
{assignmentGroupsError}
) : <> {assignmentGroups.map((group) => { const selected = selectedAssignmentGroupId === group.id; const assigned = Boolean(group.instructorPlatformId); return ( ); })} }
, document.body, ) : null} {settingsTarget ? createPortal(

{settingsTarget.name}

차량 제원 및 제어 시간 설정

setSettingsName(event.target.value)} /> setSettingsWheelbaseMeters(Number(event.target.value))} /> setSettingsMaxSpeedMetersPerSecond(Number(event.target.value))} /> setSettingsMaxSteeringAngleDegrees(Number(event.target.value))} />
setSettingsCameraUrl(event.target.value)} placeholder="https://vdo.ninja/?scene&room=ROOM&view=STREAM" className="h-10 w-full min-w-0 rounded-md border border-[#d1d9e6] px-3 text-[13px] outline-none focus:border-[#2563eb]" /> setSettingsCameraPassword(event.target.value)} className="h-10 w-full min-w-0 rounded-md border border-[#d1d9e6] px-3 text-[13px] outline-none focus:border-[#2563eb]" />
setSettingsControlDurationMinutes(Number(event.target.value))} /> setSettingsControlWarningMinutes(Number(event.target.value))} />

제어 시간이 만료되면 자동으로 제어권이 반납되며, 대기 중인 다음 학생에게 제어권이 할당됩니다.

, document.body, ) : null}

{deleteTarget.name}

{deleteTarget.type}

) : null} confirmLabel="삭제" danger icon={Trash2} loading={Boolean(deleteTarget && deletingPlatformId === deleteTarget.id)} onCancel={() => { if (!deletingPlatformId) setDeleteTarget(null); }} onConfirm={() => { if (deleteTarget) void handleDeletePlatform(deleteTarget); }} />

{forceDeleteTarget.platform.name}

사용 중: {forceDeleteTarget.session.holderName ?? forceDeleteTarget.session.holderEmail ?? '알 수 없음'}

종료 예정: {new Date(forceDeleteTarget.session.endsAt).toLocaleString('ko-KR')}

) : null} confirmLabel="강제 종료 후 삭제" danger icon={ShieldAlert} loading={Boolean(forceDeleteTarget && deletingPlatformId === forceDeleteTarget.platform.id)} onCancel={() => { if (!deletingPlatformId) setForceDeleteTarget(null); }} onConfirm={() => { if (forceDeleteTarget) void handleDeletePlatform(forceDeleteTarget.platform, true); }} />

{forceAssignmentTarget.platform.name}

{forceAssignmentTarget.group ? `배정 대상: ${forceAssignmentTarget.group.name}` : '배정 해제'}

사용 중: {forceAssignmentTarget.session.holderName ?? forceAssignmentTarget.session.holderEmail ?? '알 수 없음'}

종료 예정: {new Date(forceAssignmentTarget.session.endsAt).toLocaleString('ko-KR')}

) : null} confirmLabel={forceAssignmentTarget?.group ? '강제 종료 후 배정' : '강제 종료 후 해제'} danger icon={ShieldAlert} loading={Boolean(forceAssignmentTarget && assigningPlatformId === forceAssignmentTarget.platform.id)} onCancel={() => { if (!assigningPlatformId) setForceAssignmentTarget(null); }} onConfirm={() => { if (forceAssignmentTarget) void handleAssignPlatform(forceAssignmentTarget.platform, forceAssignmentTarget.group, true); }} /> ); } function PlatformMeta({ icon: Icon, label, value, danger = false }: { icon: LucideIcon; label: string; value: string; danger?: boolean }) { return (

{value}

); } function getNextPlatformName(masterPlatforms: MasterPlatform[], platforms: InstructorPlatform[], selectedMasterId: string) { const master = masterPlatforms.find((platform) => platform.id === selectedMasterId); if (!master) return ''; const usedNumbers = platforms .filter((platform) => platform.masterPlatformId === selectedMasterId || platform.name.startsWith(master.name)) .map((platform) => { const match = platform.name.match(/#(\d+)$/); return match ? Number(match[1]) : 0; }); let nextNo = 1; while (usedNumbers.includes(nextNo)) nextNo += 1; return `${master.name} #${String(nextNo).padStart(2, '0')}`; } function normalizeAgentKey(value: string | null | undefined) { return value?.trim().toLowerCase().replace(/\s+/g, '-') ?? ''; } function isRegisteredAgent(agent: TeachingAgent, registeredAgentKeys: Set) { return [agent.id, agent.hostname, agent.name, agent.ip] .map(normalizeAgentKey) .filter(Boolean) .some((agentKey) => registeredAgentKeys.has(agentKey)); } function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) { return (

{label}

{value}

); } function statusTone(status: string) { if (status === '활성') return 'bg-[#f0efff] text-[#4f46e5]'; if (status === '오류') return 'bg-[#fff1f2] text-[#e11d48]'; return 'bg-[#f1f5f9] text-[#64748b]'; } function SettingsSection({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } function SettingsField({ label, children }: { label: string; children: ReactNode }) { return ( ); } function updateSettingItem( items: PlatformSettingItem[], index: number, key: keyof PlatformSettingItem, value: string | boolean, ) { return items.map((item, itemIndex) => itemIndex === index ? { ...item, [key]: value } : item); } function normalizeSensorItems(items: PlatformSettingItem[]) { return items.map((item) => ({ ...item, enabled: item.enabled !== false, })); } function SensorSettingRow({ title, description, enabled, onEnabledChange, }: { title: string; description: string; enabled: boolean; onEnabledChange: (value: boolean) => void; }) { return (
{enabled ? '활성' : '비활성'}
); } function ReadonlyInfoRow({ title, description, installed, }: { title: string; description: string; installed: boolean; }) { return (

{title}

{description}

{installed ? '설치됨' : '미설치'}
); } function AlgorithmSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) { return ( ); }