This commit is contained in:
2026-08-03 13:58:20 +09:00
parent ab40abf5b3
commit def692a28b
8 changed files with 609 additions and 59 deletions
+111 -37
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
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 { PageHeader } from '../../components/PageHeader';
import { PaginationBar } from '../../components/PaginationBar';
@@ -11,6 +11,7 @@ import {
deleteInstructorPlatform,
listInstructorPlatformAgents,
listInstructorPlatforms,
issueInstructorPlatformRegistration,
PlatformInUseError,
updateInstructorPlatform,
type InstructorPlatform,
@@ -60,12 +61,15 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
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);
@@ -90,7 +94,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const [assignmentGroupsError, setAssignmentGroupsError] = useState<string | null>(null);
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
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 keyword = query.trim().toLowerCase();
@@ -106,7 +110,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const keys = new Set<string>();
platforms.forEach((platform) => {
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)
.filter(Boolean)
.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]' },
];
const selectedAssignmentGroup = assignmentGroups.find((group) => group.id === selectedAssignmentGroupId) ?? null;
const assignmentUnassignedSelected = selectedAssignmentGroupId === 'unassigned';
const applyPayload = (payload: InstructorPlatformsResponse) => {
setError(null);
@@ -159,13 +164,13 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
}, [filteredPlatforms.length, page, pageSize]);
useEffect(() => {
if (!modalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return;
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, modalOpen, settingsTarget, deleteTarget]);
}, [assignmentTarget, deleteTarget, modalOpen, registrationModalOpen, settingsTarget]);
useEffect(() => {
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) => {
setDeletingPlatformId(platform.id);
setError(null);
@@ -283,20 +302,20 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const handleAssignPlatform = async (
platform: InstructorPlatform,
group: InstructorGroup,
group: InstructorGroup | null,
force = false,
) => {
setAssigningPlatformId(platform.id);
setError(null);
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);
setAssignmentTarget(null);
setForceAssignmentTarget(null);
setSelectedAssignmentGroupId('');
showToast({
title: '플랫폼이 배정되었습니다.',
message: `${platform.name} · ${group.name}`,
title: group ? '플랫폼이 배정되었습니다.' : '플랫폼 배정이 해제되었습니다.',
message: group ? `${platform.name} · ${group.name}` : platform.name,
});
} catch (reason) {
if (reason instanceof PlatformInUseError) {
@@ -353,10 +372,24 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<PageHeader
route="platforms"
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}>
<Plus size={15} aria-hidden="true" />
플랫폼 추가
</button>
<div className="flex items-center gap-2">
<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>
}
/>
@@ -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>
<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>
<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 className="overflow-x-auto">
<div className="xl:min-w-[1100px]">
<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="xl:min-w-[1020px]">
<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>플랫폼</span>
<span>연결</span>
<span>상태</span>
<span>배터리</span>
<span>가동 시간</span>
<span>오류</span>
<span>할당</span>
<span>관리</span>
</div>
@@ -400,7 +432,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</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_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">
<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>
@@ -449,8 +481,6 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</div>
<PlatformMeta icon={Timer} label="가동 시간" value={platform.uptime} />
<PlatformMeta icon={CircleAlert} label="오류" value={`${platform.errors}건`} danger={platform.errors > 0} />
<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">
@@ -498,6 +528,42 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
/>
</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)]">
@@ -633,7 +699,19 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</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>
) : 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 assigned = Boolean(group.instructorPlatformId);
return (
@@ -655,9 +733,8 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</span>
</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>
@@ -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"
type="button"
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" />}
배정
{assignmentUnassignedSelected ? '배정 해제' : '배정'}
</button>
</div>
</section>
@@ -848,12 +927,12 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
<ConfirmDialog
open={Boolean(forceAssignmentTarget)}
title="사용 중인 플랫폼입니다"
message="배정을 변경하면 학생의 제어권과 실행 중인 플랫폼 세션이 강제로 종료됩니다. 계속 배정할까요?"
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.name}
{forceAssignmentTarget.group ? `배정 대상: ${forceAssignmentTarget.group.name}` : '배정 해제'}
</p>
<p className="mt-1 text-[12px] font-semibold text-[#c2410c]">
사용 중: {forceAssignmentTarget.session.holderName ?? forceAssignmentTarget.session.holderEmail ?? '알 수 없음'}
@@ -863,7 +942,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
</p>
</div>
) : null}
confirmLabel="강제 종료 후 배정"
confirmLabel={forceAssignmentTarget?.group ? '강제 종료 후 배정' : '강제 종료 후 해제'}
danger
icon={ShieldAlert}
loading={Boolean(forceAssignmentTarget && assigningPlatformId === forceAssignmentTarget.platform.id)}
@@ -910,15 +989,10 @@ function normalizeAgentKey(value: string | null | undefined) {
}
function isRegisteredAgent(agent: TeachingAgent, registeredAgentKeys: Set<string>) {
const registeredKeys = Array.from(registeredAgentKeys);
return [agent.id, agent.hostname, agent.name, agent.ip, agent.user]
return [agent.id, agent.hostname, agent.name, agent.ip]
.map(normalizeAgentKey)
.filter(Boolean)
.some((agentKey) => registeredKeys.some((registeredKey) => (
agentKey === registeredKey ||
agentKey.includes(registeredKey) ||
registeredKey.includes(agentKey)
)));
.some((agentKey) => registeredAgentKeys.has(agentKey));
}
function SummaryCard({ label, value, icon: Icon, tone }: { label: string; value: string; icon: LucideIcon; tone: string }) {