Files
lms-adm-fe/src/pages/platforms/PlatformsPage.tsx
T

1165 lines
60 KiB
TypeScript

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<InstructorPlatform[]>([]);
const [masterPlatforms, setMasterPlatforms] = useState<MasterPlatform[]>([]);
const [agents, setAgents] = useState<TeachingAgent[]>([]);
const [summary, setSummary] = useState<InstructorPlatformsSummary>(emptySummary);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(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<InstructorPlatform | null>(null);
const [selectedMasterId, setSelectedMasterId] = useState('');
const [selectedAgentId, setSelectedAgentId] = useState('');
const [platformName, setPlatformName] = useState('');
const [agentsLoading, setAgentsLoading] = useState(false);
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 [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<PlatformSettingItem[]>([]);
const [settingsDrivers, setSettingsDrivers] = useState<PlatformSettingItem[]>([]);
const [settingsAlgorithms, setSettingsAlgorithms] = useState<PlatformAlgorithms>({
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<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<InstructorPlatform | null>(null);
const [forceDeleteTarget, setForceDeleteTarget] = useState<{ platform: InstructorPlatform; session: PlatformInUseError['activeSession'] } | null>(null);
const [assignmentTarget, setAssignmentTarget] = useState<InstructorPlatform | null>(null);
const [assignmentGroups, setAssignmentGroups] = useState<InstructorGroup[]>([]);
const [assignmentGroupsLoading, setAssignmentGroupsLoading] = useState(false);
const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null);
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
const [assigningPlatformId, setAssigningPlatformId] = useState<string | null>(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<string>();
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 (
<div className="mx-auto max-w-[1220px]">
<PageHeader
route="platforms"
action={
<div className="flex items-center gap-2">
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#475569] hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:opacity-60"
type="button"
onClick={handleSyncPlatformAccess}
disabled={syncingAccess}
>
{syncingAccess ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <RefreshCw size={15} aria-hidden="true" />}
접근 동기화
</button>
<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]"
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>
}
/>
<section className="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<SummaryCard key={item.label} {...item} />
))}
</section>
<section className="rounded-lg border border-[#c7d2e0] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#cbd5e1] px-4 py-3">
<div>
<h2 className="text-[15px] font-black text-[#111827]">플랫폼 리스트</h2>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">가동 시간, 할당 그룹 상태 조회</p>
</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]">
<Search size={15} aria-hidden="true" />
<input className="min-w-0 flex-1 bg-transparent text-[13px] font-bold text-[#111827] outline-none placeholder:text-[#94a3b8]" placeholder="플랫폼 검색" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
</div>
<div className="overflow-x-auto">
<div className="xl:min-w-[1020px]">
<div className="hidden select-none gap-4 grid-cols-[44px_200px_110px_92px_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>플랫폼</span>
<span>연결</span>
<span>상태</span>
<span>가동 시간</span>
<span>할당</span>
<span>관리</span>
</div>
<div className="divide-y divide-[#e2e8f0]">
{loading ? (
<div className="flex h-[220px] items-center justify-center gap-2 px-4 text-center text-[#64748b]">
<Loader2 className="animate-spin" size={18} aria-hidden="true" />
<span className="text-[13px] font-bold">플랫폼 목록을 불러오는 중입니다.</span>
</div>
) : error ? (
<div className="flex h-[220px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">{error}</p>
</div>
) : pagedPlatforms.length > 0 ? (
pagedPlatforms.map((platform, index) => (
<article className="grid select-none gap-4 px-4 py-4 xl:grid-cols-[44px_200px_110px_92px_110px_150px_184px] xl:items-center" key={platform.id}>
<div className="text-center">
<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>
</div>
<div className="flex min-w-0 items-center gap-3">
<span className={`grid h-8 w-8 shrink-0 place-items-center rounded-md ${platform.status === '오류' ? 'bg-[#fff1f2] text-[#e11d48]' : 'bg-[#f0efff] text-[#4f46e5]'}`}>
<Car size={15} aria-hidden="true" />
</span>
<div className="min-w-0">
<h3 className="truncate text-[14px] font-black text-[#111827]">{platform.name}</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{platform.type}</p>
</div>
</div>
<div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">연결</p>
{platform.agent ? (
<div className="flex items-center justify-center">
<span className={`inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[11px] font-black ${platform.agent.connected ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#f1f5f9] text-[#64748b]'}`}>
<span className={`h-1.5 w-1.5 rounded-full ${platform.agent.connected ? 'bg-[#22c55e]' : 'bg-[#94a3b8]'}`} />
{platform.agent.connected ? '연결됨' : '끊김'}
</span>
</div>
) : (
<span className="text-[12px] font-bold text-[#94a3b8]">미연결</span>
)}
</div>
<div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">상태</p>
<span className={`rounded px-1.5 py-0.5 text-[11px] font-bold ${statusTone(platform.status)}`}>{platform.status}</span>
</div>
<PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} />
<div className="text-center">
<p className="mb-2 text-[11px] font-bold text-[#94a3b8] xl:hidden">할당</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{platform.group ? (
<span className="inline-flex h-7 items-center rounded-md bg-[#f0efff] px-2.5 text-[12px] font-semibold text-[#4f46e5]">{platform.group}</span>
) : (
<span className="text-[12px] font-bold text-[#94a3b8]">미할당</span>
)}
<span className="text-[12px] font-semibold text-[#64748b]">{platform.user ?? '-'}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#bfdbfe] bg-[#eff6ff] text-[#2563eb] hover:bg-[#dbeafe]" type="button" onClick={() => handleOpenAssignmentModal(platform)} aria-label={`${platform.name} 배정`} title="배정">
<Users size={14} aria-hidden="true" />
</button>
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#d1d9e6] bg-white text-[#475569] hover:bg-[#f8fafc] hover:text-[#111827]" type="button" onClick={() => setSettingsTarget(platform)} aria-label={`${platform.name} 설정`} title="설정">
<Settings size={14} aria-hidden="true" />
</button>
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6]" type="button" aria-label={`${platform.name} 긴급 정지`} title="긴급 정지">
<ShieldAlert size={14} aria-hidden="true" />
</button>
<button className="grid h-8 w-8 place-items-center rounded-md border border-[#fecdd3] bg-[#fff1f2] text-[#e11d48] hover:bg-[#ffe4e6] disabled:cursor-not-allowed disabled:opacity-60" type="button" aria-label={`${platform.name} 삭제`} title="삭제" onClick={() => setDeleteTarget(platform)} disabled={deletingPlatformId === platform.id}>
{deletingPlatformId === platform.id ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Trash2 size={15} aria-hidden="true" />}
</button>
</div>
</article>
))
) : (
<div className="flex h-[220px] items-center justify-center px-4 text-center">
<p className="text-[13px] font-bold text-[#64748b]">등록된 플랫폼이 없습니다.</p>
</div>
)}
</div>
</div>
</div>
<PaginationBar
page={page}
pageSize={pageSize}
totalItems={filteredPlatforms.length}
totalLabel={`전체 ${filteredPlatforms.length}대`}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</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(
<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)]">
<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={() => setModalOpen(false)} disabled={saving} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="p-5">
<div>
<div className="flex items-center justify-between">
<span className="text-[12px] font-black text-[#475569]">등록된 에이전트</span>
<span className="text-[11px] font-bold text-[#94a3b8]">
{agentsLoading ? '조회 중' : `${agents.filter((agent) => agent.connected && !agent.expired).length}/${agents.length} 연결`}
</span>
</div>
<div className="mt-2 max-h-[146px] space-y-2 overflow-y-auto pr-1">
{agentsLoading ? (
<div className="flex items-center justify-center gap-2 rounded-md border border-dashed border-[#d1d9e6] px-3 py-8 text-center text-[12px] font-bold text-[#64748b]">
<Loader2 className="animate-spin" size={15} aria-hidden="true" />
에이전트를 불러오는 중입니다.
</div>
) : agentsError ? (
<div className="rounded-md border border-dashed border-[#fecdd3] bg-[#fff1f2] px-3 py-8 text-center text-[12px] font-bold text-[#e11d48]">{agentsError}</div>
) : 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 (
<button
key={agent.id}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left transition-colors ${selected ? 'border-[#2563eb] bg-[#eff6ff]' : 'border-[#d1d9e6] bg-white'} ${selectable ? 'cursor-pointer hover:bg-[#f8fafc]' : 'cursor-not-allowed opacity-60'}`}
disabled={!selectable}
onClick={() => {
if (!selectable) return;
setSelectedAgentId(selected ? '' : agent.id);
}}
>
<span className="min-w-0">
<span className="block truncate text-[12px] font-black text-[#111827]">{agent.hostname || agent.name}</span>
<span className="mt-0.5 block truncate text-[11px] font-bold text-[#64748b]">
{agent.user} · {agent.ip}{agent.sshUser ? ` · SSH ${agent.sshUser}` : ''}
</span>
</span>
<span className={`shrink-0 rounded px-2 py-1 text-[10px] font-black ${registered ? 'bg-[#ede9fe] text-[#6d28d9]' : available ? 'bg-[#dcfce7] text-[#15803d]' : 'bg-[#f1f5f9] text-[#64748b]'}`}>
{registered ? '등록됨' : available ? '연결됨' : '끊김'}
</span>
</button>
);
}) : (
<div className="rounded-md border border-dashed border-[#d1d9e6] px-3 py-8 text-center text-[12px] font-bold text-[#94a3b8]">발견된 에이전트가 없습니다.</div>
)}
</div>
</div>
<div className="my-5 border-t border-[#cbd5e1]" />
<label className="block">
<span className="text-[12px] font-black text-[#475569]">플랫폼 유형</span>
<select
className="mt-2 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={selectedMasterId}
onChange={(event) => setSelectedMasterId(event.target.value)}
>
{masterPlatforms.map((platform) => (
<option key={platform.id} value={platform.id}>
{platform.name}
</option>
))}
</select>
</label>
<label className="mt-4 block">
<span className="text-[12px] font-black text-[#475569]">플랫폼명</span>
<input
className="mt-2 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-bold text-[#111827] outline-none focus:border-[#2563eb]"
value={platformName}
onChange={(event) => setPlatformName(event.target.value)}
placeholder="예: F1Tenth #01"
/>
</label>
</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={() => setModalOpen(false)} disabled={saving}>취소</button>
<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 handleCreatePlatform()} disabled={saving || agentsLoading || !selectedAgentId || !selectedMasterId || !platformName.trim()}>
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Plus size={15} aria-hidden="true" />}
등록
</button>
</div>
</section>
</div>,
document.body,
) : null}
{assignmentTarget ? 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-[520px] 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 className="min-w-0">
<h3 className="truncate text-[16px] font-black text-[#111827]">플랫폼 배정</h3>
<p className="mt-1 text-[12px] font-bold text-[#64748b]">{assignmentTarget.name}을 배정할 그룹을 선택해 주세요.</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={() => setAssignmentTarget(null)} disabled={Boolean(assigningPlatformId)} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="p-5">
<div className="rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 py-2">
<p className="text-[12px] font-black text-[#111827]">{assignmentTarget.name}</p>
<p className="mt-1 text-[11px] font-bold text-[#64748b]">
현재 배정: {assignmentTarget.group ?? '미배정'} · 상태: {assignmentTarget.status}
</p>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-black text-[#475569]">그룹 목록</span>
<span className="text-[11px] font-bold text-[#94a3b8]">미배정 그룹만 선택 가능</span>
</div>
<div className="max-h-[280px] space-y-2 overflow-y-auto pr-1">
{assignmentGroupsLoading ? (
<div className="flex items-center justify-center gap-2 rounded-md border border-dashed border-[#d1d9e6] px-3 py-10 text-center text-[12px] font-bold text-[#64748b]">
<Loader2 className="animate-spin" size={15} aria-hidden="true" />
그룹을 불러오는 중입니다.
</div>
) : 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>
) : <>
<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 assigned = Boolean(group.instructorPlatformId);
return (
<button
key={group.id}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${selected ? 'border-[#2563eb] bg-[#eff6ff]' : 'border-[#d1d9e6] bg-white'} ${assigned ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-[#f8fafc]'}`}
disabled={assigned}
onClick={() => setSelectedAssignmentGroupId(selected ? '' : group.id)}
>
<span className="min-w-0">
<span className="block truncate text-[13px] font-black text-[#111827]">{group.name}</span>
<span className="mt-0.5 block truncate text-[11px] font-bold text-[#64748b]">
{group.members.length}명 · {group.week} · 진행률 {group.progress}%
</span>
</span>
<span className={`shrink-0 rounded px-2 py-1 text-[10px] font-black ${assigned ? 'bg-[#f1f5f9] text-[#64748b]' : 'bg-[#dcfce7] text-[#15803d]'}`}>
{assigned ? group.platform : '미배정'}
</span>
</button>
);
})}
</>}
</div>
</div>
</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={() => setAssignmentTarget(null)} disabled={Boolean(assigningPlatformId)}>취소</button>
<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={() => {
if (assignmentTarget && (selectedAssignmentGroup || assignmentUnassignedSelected)) {
void handleAssignPlatform(assignmentTarget, selectedAssignmentGroup);
}
}}
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" />}
{assignmentUnassignedSelected ? '배정 해제' : '배정'}
</button>
</div>
</section>
</div>,
document.body,
) : null}
{settingsTarget ? createPortal(
<div className="fixed inset-0 z-[70] grid place-items-center bg-[#0f172a]/24 px-4 py-8 backdrop-blur-[2px]">
<section className="flex max-h-[calc(100vh-64px)] w-full max-w-[720px] flex-col overflow-hidden rounded-lg border border-[#c7d2e0] bg-white shadow-[0_24px_70px_rgba(15,23,42,0.18)]">
<div className="flex items-start justify-between gap-4 border-b border-[#c7d2e0] bg-[#f8fbff] px-5 py-4">
<div>
<h3 className="text-[16px] font-black text-[#111827]">{settingsTarget.name}</h3>
<p className="mt-1 text-[13px] font-semibold 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={() => setSettingsTarget(null)} aria-label="닫기" title="닫기">
<X size={17} aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto bg-white px-6 py-6">
<SettingsSection title="차량 제원">
<div className="grid gap-3 md:grid-cols-2">
<SettingsField label="플랫폼명">
<input className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={settingsName} onChange={(event) => setSettingsName(event.target.value)} />
</SettingsField>
<SettingsField label="차량 타입">
<select className="h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={settingsVehicleType} onChange={(event) => setSettingsVehicleType(event.target.value)}>
<option>F1tenth</option>
<option>MK-mini</option>
<option>Simulator</option>
</select>
</SettingsField>
<SettingsField label="휠베이스 (m)">
<input type="number" step="0.001" min={0} max={10} className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={settingsWheelbaseMeters} onChange={(event) => setSettingsWheelbaseMeters(Number(event.target.value))} />
</SettingsField>
<SettingsField label="최대 속도 (m/s)">
<input type="number" step="0.1" min={0} max={100} className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={settingsMaxSpeedMetersPerSecond} onChange={(event) => setSettingsMaxSpeedMetersPerSecond(Number(event.target.value))} />
</SettingsField>
<SettingsField label="최대 조향각 (deg)">
<input type="number" step="0.1" min={0} max={90} className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={settingsMaxSteeringAngleDegrees} onChange={(event) => setSettingsMaxSteeringAngleDegrees(Number(event.target.value))} />
</SettingsField>
</div>
</SettingsSection>
<div hidden>
<SettingsSection title="부착 센서">
{settingsSensors.map((sensor, index) => (
<SensorSettingRow
key={`sensor-${index}`}
title={sensor.title}
description={sensor.description}
enabled={sensor.enabled !== false}
onEnabledChange={(enabled) => setSettingsSensors((items) => updateSettingItem(items, index, 'enabled', enabled))}
/>
))}
</SettingsSection>
<SettingsSection title="센서 드라이버">
{settingsDrivers.map((driver, index) => (
<ReadonlyInfoRow
key={`driver-${index}`}
title={driver.title}
description={driver.description}
installed={driver.installed !== false}
/>
))}
</SettingsSection>
<SettingsSection title="자율주행 알고리즘 조합">
<div className="grid gap-3">
<AlgorithmSelect label="SLAM 알고리즘" value={settingsAlgorithms.slam} options={['Cartographer', 'GMapping', 'Hector SLAM']} onChange={(value) => setSettingsAlgorithms((current) => ({ ...current, slam: value }))} />
<AlgorithmSelect label="경로 계획 알고리즘" value={settingsAlgorithms.planning} options={['A* + DWA', 'RRT', 'Pure Pursuit']} onChange={(value) => setSettingsAlgorithms((current) => ({ ...current, planning: value }))} />
<AlgorithmSelect label="제어 알고리즘" value={settingsAlgorithms.control} options={['PID Control', 'MPC', 'LQR']} onChange={(value) => setSettingsAlgorithms((current) => ({ ...current, control: value }))} />
</div>
</SettingsSection>
</div>
<SettingsSection title="주행장 카메라">
<div className="grid min-w-0 gap-3">
<SettingsField label="VDO.Ninja 시청 URL">
<input type="url" value={settingsCameraUrl} onChange={(event) => 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]" />
</SettingsField>
<SettingsField label="방 비밀번호">
<input type="password" autoComplete="new-password" value={settingsCameraPassword} onChange={(event) => 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]" />
</SettingsField>
</div>
</SettingsSection>
<SettingsSection title="제어 시간 설정">
<div className="grid gap-3 md:grid-cols-2">
<SettingsField label="1회 제어 시간 (분)">
<input
type="number"
min={1}
max={240}
className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
value={settingsControlDurationMinutes}
onChange={(event) => setSettingsControlDurationMinutes(Number(event.target.value))}
/>
</SettingsField>
<SettingsField label="자동 반납 경고 (분 전)">
<input
type="number"
min={0}
max={60}
className="h-10 w-full rounded-md border border-[#d1d9e6] px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
value={settingsControlWarningMinutes}
onChange={(event) => setSettingsControlWarningMinutes(Number(event.target.value))}
/>
</SettingsField>
</div>
<p className="mt-3 rounded-md border border-[#d1d9e6] bg-[#f8fafc] px-3 py-2 text-[12px] font-semibold leading-relaxed text-[#64748b]">
제어 시간이 만료되면 자동으로 제어권이 반납되며, 대기 중인 다음 학생에게 제어권이 할당됩니다.
</p>
</SettingsSection>
</div>
<div className="flex justify-end gap-2 border-t border-[#c7d2e0] bg-[#f8fbff] 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={() => setSettingsTarget(null)}>취소</button>
<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 handleSaveSettings()}
disabled={saving || !settingsName.trim()}
>
{saving ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : null}
저장
</button>
</div>
</section>
</div>,
document.body,
) : null}
<ConfirmDialog
open={Boolean(deleteTarget)}
title="플랫폼을 삭제할까요?"
message="삭제하면 해당 플랫폼은 강사 소속 플랫폼 목록에서 제거되고, 연결된 그룹은 미배정으로 변경됩니다."
detail={deleteTarget ? (
<div className="rounded-lg border border-[#fecdd3] bg-[#fff1f2] px-3 py-2">
<p className="text-[13px] font-black text-[#be123c]">{deleteTarget.name}</p>
<p className="mt-1 text-[12px] font-semibold text-[#e11d48]">{deleteTarget.type}</p>
</div>
) : null}
confirmLabel="삭제"
danger
icon={Trash2}
loading={Boolean(deleteTarget && deletingPlatformId === deleteTarget.id)}
onCancel={() => {
if (!deletingPlatformId) setDeleteTarget(null);
}}
onConfirm={() => {
if (deleteTarget) void handleDeletePlatform(deleteTarget);
}}
/>
<ConfirmDialog
open={Boolean(forceDeleteTarget)}
title="사용 중인 플랫폼입니다"
message="삭제하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속 삭제할까요?"
detail={forceDeleteTarget ? (
<div className="rounded-lg border border-[#fed7aa] bg-[#fff7ed] px-3 py-2">
<p className="text-[13px] font-black text-[#9a3412]">{forceDeleteTarget.platform.name}</p>
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
사용 중: {forceDeleteTarget.session.holderName ?? forceDeleteTarget.session.holderEmail ?? '알 수 없음'}
</p>
<p className="mt-1 text-[11px] font-semibold text-[#ea580c]">
종료 예정: {new Date(forceDeleteTarget.session.endsAt).toLocaleString('ko-KR')}
</p>
</div>
) : 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);
}}
/>
<ConfirmDialog
open={Boolean(forceAssignmentTarget)}
title="사용 중인 플랫폼입니다"
message={forceAssignmentTarget?.group ? '배정을 변경하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속 배정할까요?' : '배정을 해제하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속할까요?'}
detail={forceAssignmentTarget ? (
<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="mt-1 text-[12px] font-semibold text-[#c2410c]">
{forceAssignmentTarget.group ? `배정 대상: ${forceAssignmentTarget.group.name}` : '배정 해제'}
</p>
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
사용 중: {forceAssignmentTarget.session.holderName ?? forceAssignmentTarget.session.holderEmail ?? '알 수 없음'}
</p>
<p className="mt-1 text-[11px] font-semibold text-[#ea580c]">
종료 예정: {new Date(forceAssignmentTarget.session.endsAt).toLocaleString('ko-KR')}
</p>
</div>
) : 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);
}}
/>
</div>
);
}
function PlatformMeta({ icon: Icon, label, value, danger = false }: { icon: LucideIcon; label: string; value: string; danger?: boolean }) {
return (
<div className="min-w-0 text-center">
<div className="flex items-center justify-center gap-1.5 text-[11px] font-bold text-[#94a3b8] xl:hidden">
<Icon size={13} aria-hidden="true" />
{label}
</div>
<p className={`mt-1 truncate text-[13px] font-semibold xl:mt-0 ${danger ? 'text-[#e11d48]' : 'text-[#111827]'}`}>{value}</p>
</div>
);
}
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<string>) {
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 (
<article className="rounded-lg border border-[#c7d2e0] bg-white px-5 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12px] font-semibold text-[#64748b]">{label}</p>
<p className="mt-2 text-[20px] font-black leading-none text-[#111827]">{value}</p>
</div>
<span className={`grid h-11 w-11 shrink-0 place-items-center rounded-md ${tone}`}>
<Icon size={28} aria-hidden="true" />
</span>
</div>
</article>
);
}
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 (
<section className="mb-6 last:mb-0">
<h4 className="mb-3.5 text-[14px] font-black text-[#111827]">{title}</h4>
{children}
</section>
);
}
function SettingsField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-[12px] font-bold text-[#64748b]">{label}</span>
<span className="mt-1.5 block">{children}</span>
</label>
);
}
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 (
<div className="mb-2 flex items-center justify-between gap-3 rounded-lg border border-[#d1d9e6] bg-white px-3 py-3 last:mb-0">
<label className="flex min-w-0 items-center gap-3">
<input
className="h-4 w-4 shrink-0 accent-[#2563eb]"
type="checkbox"
checked={enabled}
onChange={(event) => onEnabledChange(event.target.checked)}
/>
<span className="min-w-0">
<span className="block text-[13px] font-black text-[#111827]">{title}</span>
<span className="mt-0.5 block truncate text-[12px] font-semibold text-[#64748b]">{description}</span>
</span>
</label>
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] font-bold ${enabled ? 'border-[#bfdbfe] bg-[#eff6ff] text-[#2563eb]' : 'border-[#cbd5e1] bg-[#f8fafc] text-[#94a3b8]'}`}>
{enabled ? '활성' : '비활성'}
</span>
</div>
);
}
function ReadonlyInfoRow({
title,
description,
installed,
}: {
title: string;
description: string;
installed: boolean;
}) {
return (
<div className="mb-2 flex items-center justify-between gap-3 rounded-lg border border-[#d1d9e6] bg-white px-3 py-3 last:mb-0">
<div className="min-w-0">
<p className="text-[13px] font-black text-[#111827]">{title}</p>
<p className="mt-0.5 truncate text-[12px] font-semibold text-[#64748b]">{description}</p>
</div>
<span className={`shrink-0 rounded-md border px-2 py-1 text-[11px] font-bold ${installed ? 'border-[#c7d2fe] bg-[#eef2ff] text-[#4f46e5]' : 'border-[#cbd5e1] bg-[#f8fafc] text-[#94a3b8]'}`}>
{installed ? '설치됨' : '미설치'}
</span>
</div>
);
}
function AlgorithmSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (value: string) => void }) {
return (
<label className="block rounded-lg border border-[#d1d9e6] bg-white p-3 hover:border-[#bfdbfe] hover:bg-[#f8fbff]">
<span className="text-[12px] font-bold text-[#64748b]">{label}</span>
<select className="mt-2 h-10 w-full rounded-md border border-[#d1d9e6] bg-white px-3 text-[13px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option}>{option}</option>
))}
</select>
</label>
);
}