1165 lines
60 KiB
TypeScript
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>
|
|
);
|
|
}
|