From c727afd1baca62ceeced6a19892cecfdc2c56f6b Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 16:19:22 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(frontend):=20=EB=8C=80=ED=98=95=20?= =?UTF-8?q?View=20=EC=84=9C=EB=B8=8C=ED=83=AD=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=20+=20FEATURE=5FID=20=EC=B2=B4=EA=B3=84=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 +- frontend/src/common/constants/featureIds.ts | 72 + .../src/common/hooks/useFeatureTracking.ts | 22 + .../src/tabs/admin/components/AdminView.tsx | 1293 +-------- .../src/tabs/admin/components/MenusPanel.tsx | 198 ++ .../admin/components/PermissionsPanel.tsx | 330 +++ .../tabs/admin/components/SettingsPanel.tsx | 237 ++ .../admin/components/SortableMenuItem.tsx | 161 ++ .../src/tabs/admin/components/UsersPanel.tsx | 350 +++ .../tabs/admin/components/adminConstants.ts | 36 + .../src/tabs/aerial/components/AerialView.tsx | 2498 +---------------- .../src/tabs/aerial/components/CctvView.tsx | 343 +++ .../aerial/components/MediaManagement.tsx | 335 +++ .../aerial/components/OilAreaAnalysis.tsx | 212 ++ .../tabs/aerial/components/RealtimeDrone.tsx | 252 ++ .../aerial/components/SatelliteRequest.tsx | 787 ++++++ .../tabs/aerial/components/SensorAnalysis.tsx | 497 ++++ .../assets/components/AssetManagement.tsx | 332 +++ .../src/tabs/assets/components/AssetMap.tsx | 161 ++ .../tabs/assets/components/AssetTheory.tsx | 255 ++ .../tabs/assets/components/AssetUpload.tsx | 124 + .../src/tabs/assets/components/AssetsView.tsx | 2014 +------------ .../tabs/assets/components/ShipInsurance.tsx | 319 +++ .../tabs/assets/components/assetMockData.ts | 755 +++++ .../src/tabs/assets/components/assetTypes.ts | 65 + .../components/InfoLayerSection.tsx | 196 ++ .../tabs/prediction/components/LeftPanel.tsx | 1149 +------- .../prediction/components/OilBoomSection.tsx | 548 ++++ .../components/PredictionInputSection.tsx | 426 +++ .../prediction/components/leftPanelTypes.ts | 49 + .../reports/components/ReportGenerator.tsx | 532 ++++ .../tabs/reports/components/ReportsView.tsx | 1233 +------- .../reports/components/TemplateFormEditor.tsx | 301 ++ .../tabs/reports/components/reportTypes.ts | 331 +++ .../tabs/reports/components/reportUtils.ts | 89 + .../src/tabs/scat/components/PreScatView.tsx | 1316 +-------- .../tabs/scat/components/ScatLeftPanel.tsx | 155 + frontend/src/tabs/scat/components/ScatMap.tsx | 276 ++ .../src/tabs/scat/components/ScatPopup.tsx | 326 +++ .../src/tabs/scat/components/ScatTimeline.tsx | 144 + .../src/tabs/scat/components/scatConstants.ts | 387 +++ .../src/tabs/scat/components/scatTypes.ts | 37 + frontend/src/tabs/scat/index.ts | 1 + 43 files changed, 9743 insertions(+), 9403 deletions(-) create mode 100644 frontend/src/common/constants/featureIds.ts create mode 100644 frontend/src/common/hooks/useFeatureTracking.ts create mode 100644 frontend/src/tabs/admin/components/MenusPanel.tsx create mode 100644 frontend/src/tabs/admin/components/PermissionsPanel.tsx create mode 100644 frontend/src/tabs/admin/components/SettingsPanel.tsx create mode 100644 frontend/src/tabs/admin/components/SortableMenuItem.tsx create mode 100644 frontend/src/tabs/admin/components/UsersPanel.tsx create mode 100644 frontend/src/tabs/admin/components/adminConstants.ts create mode 100644 frontend/src/tabs/aerial/components/CctvView.tsx create mode 100644 frontend/src/tabs/aerial/components/MediaManagement.tsx create mode 100644 frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx create mode 100644 frontend/src/tabs/aerial/components/RealtimeDrone.tsx create mode 100644 frontend/src/tabs/aerial/components/SatelliteRequest.tsx create mode 100644 frontend/src/tabs/aerial/components/SensorAnalysis.tsx create mode 100644 frontend/src/tabs/assets/components/AssetManagement.tsx create mode 100644 frontend/src/tabs/assets/components/AssetMap.tsx create mode 100644 frontend/src/tabs/assets/components/AssetTheory.tsx create mode 100644 frontend/src/tabs/assets/components/AssetUpload.tsx create mode 100644 frontend/src/tabs/assets/components/ShipInsurance.tsx create mode 100644 frontend/src/tabs/assets/components/assetMockData.ts create mode 100644 frontend/src/tabs/assets/components/assetTypes.ts create mode 100644 frontend/src/tabs/prediction/components/InfoLayerSection.tsx create mode 100644 frontend/src/tabs/prediction/components/OilBoomSection.tsx create mode 100644 frontend/src/tabs/prediction/components/PredictionInputSection.tsx create mode 100644 frontend/src/tabs/prediction/components/leftPanelTypes.ts create mode 100644 frontend/src/tabs/reports/components/ReportGenerator.tsx create mode 100644 frontend/src/tabs/reports/components/TemplateFormEditor.tsx create mode 100644 frontend/src/tabs/reports/components/reportTypes.ts create mode 100644 frontend/src/tabs/reports/components/reportUtils.ts create mode 100644 frontend/src/tabs/scat/components/ScatLeftPanel.tsx create mode 100644 frontend/src/tabs/scat/components/ScatMap.tsx create mode 100644 frontend/src/tabs/scat/components/ScatPopup.tsx create mode 100644 frontend/src/tabs/scat/components/ScatTimeline.tsx create mode 100644 frontend/src/tabs/scat/components/scatConstants.ts create mode 100644 frontend/src/tabs/scat/components/scatTypes.ts create mode 100644 frontend/src/tabs/scat/index.ts diff --git a/.gitignore b/.gitignore index 43468a6..a3652b2 100755 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,7 @@ backend/data/*.db-wal # Large reference data (keep locally, do not commit) _reference/ -scat/ +/scat/ 참고용/ 논문/ diff --git a/frontend/src/common/constants/featureIds.ts b/frontend/src/common/constants/featureIds.ts new file mode 100644 index 0000000..9fee631 --- /dev/null +++ b/frontend/src/common/constants/featureIds.ts @@ -0,0 +1,72 @@ +/** + * FEATURE_ID 레지스트리 + * + * 서브탭 단위의 기능 식별자. AUTH_PERM.RSRC_CD 및 감사로그 ACTION_DTL과 동기화. + * 형식: '{메인탭}:{서브탭}' + */ +export const FEATURE_IDS = { + // prediction + 'prediction:analysis': '확산 분석', + 'prediction:list': '시뮬레이션 목록', + 'prediction:theory': '확산 이론', + 'prediction:boom-theory': '오일펜스 배치 이론', + + // hns + 'hns:analysis': 'HNS 분석', + 'hns:list': 'HNS 시뮬레이션 목록', + 'hns:scenario': 'HNS 시나리오', + 'hns:manual': 'HNS 매뉴얼', + 'hns:theory': 'HNS 이론', + 'hns:substance': 'HNS 물질정보', + + // rescue + 'rescue:rescue': '구난 메인', + 'rescue:list': '구난 목록', + 'rescue:scenario': '구난 시나리오', + 'rescue:theory': '구난 이론', + + // aerial + 'aerial:media': '영상 관리', + 'aerial:analysis': '유출 면적 분석', + 'aerial:realtime': '실시간 드론', + 'aerial:sensor': '센서 분석', + 'aerial:satellite': '위성 요청', + 'aerial:cctv': 'CCTV 모니터링', + 'aerial:theory': '항공탐색 이론', + + // reports + 'reports:report-list': '보고서 목록', + 'reports:template': '보고서 템플릿', + 'reports:generate': '보고서 생성', + + // board + 'board:all': '전체 게시판', + 'board:notice': '공지사항', + 'board:data': '자료실', + 'board:qna': '질의응답', + 'board:manual': '매뉴얼', + + // assets + 'assets:management': '자산 관리', + 'assets:upload': '자산 현행화', + 'assets:theory': '방제자원 이론', + 'assets:insurance': '선박 보험정보', + + // scat + 'scat:survey': 'SCAT 조사', + + // weather + 'weather:current': '현재 기상', + 'weather:forecast': '기상 예보', + + // incidents + 'incidents:list': '사고 목록', + + // admin + 'admin:users': '사용자 관리', + 'admin:permissions': '권한 매트릭스', + 'admin:menus': '메뉴 관리', + 'admin:settings': '시스템 설정', +} as const; + +export type FeatureId = keyof typeof FEATURE_IDS; diff --git a/frontend/src/common/hooks/useFeatureTracking.ts b/frontend/src/common/hooks/useFeatureTracking.ts new file mode 100644 index 0000000..b12b8a9 --- /dev/null +++ b/frontend/src/common/hooks/useFeatureTracking.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useAuthStore } from '@common/store/authStore'; +import type { FeatureId } from '@common/constants/featureIds'; + +/** + * 서브탭 진입 시 감사 로그를 기록하는 훅. + * App.tsx의 탭 레벨 TAB_VIEW와 함께, 서브탭 레벨 SUBTAB_VIEW를 기록한다. + * + * @param featureId - FEATURE_ID (예: 'aerial:media', 'admin:users') + */ +export function useFeatureTracking(featureId: FeatureId) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + useEffect(() => { + if (!isAuthenticated) return; + const blob = new Blob( + [JSON.stringify({ action: 'SUBTAB_VIEW', detail: featureId })], + { type: 'text/plain' }, + ); + navigator.sendBeacon('/api/audit/log', blob); + }, [featureId, isAuthenticated]); +} diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index 06b0f67..3633998 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -1,1293 +1,8 @@ -import { useState, useEffect, useCallback, useRef } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' -import data from '@emoji-mart/data' -import EmojiPicker from '@emoji-mart/react' -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragOverlay, - type DragEndEvent, -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { - fetchUsers, - fetchRoles, - updatePermissionsApi, - updateUserApi, - updateRoleDefaultApi, - approveUserApi, - rejectUserApi, - assignRolesApi, - createRoleApi, - updateRoleApi, - deleteRoleApi, - fetchRegistrationSettings, - updateRegistrationSettingsApi, - fetchOAuthSettings, - updateOAuthSettingsApi, - fetchMenuConfig, - updateMenuConfigApi, - type UserListItem, - type RoleWithPermissions, - type RegistrationSettings, - type OAuthSettings, - type MenuConfigItem, -} from '@common/services/authApi' -import { useMenuStore } from '@common/store/menuStore' - -const DEFAULT_ROLE_COLORS: Record = { - ADMIN: 'var(--red)', - MANAGER: 'var(--orange)', - USER: 'var(--cyan)', - VIEWER: 'var(--t3)', -} - -const CUSTOM_ROLE_COLORS = [ - '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', -] - -function getRoleColor(code: string, index: number): string { - return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] -} - -const statusLabels: Record = { - PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, - ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, - LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, - INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, - REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, -} - - -// ─── 사용자 관리 패널 ───────────────────────────────────────── -function UsersPanel() { - const [searchTerm, setSearchTerm] = useState('') - const [statusFilter, setStatusFilter] = useState('') - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) - const [allRoles, setAllRoles] = useState([]) - const [roleEditUserId, setRoleEditUserId] = useState(null) - const [selectedRoleSns, setSelectedRoleSns] = useState([]) - const roleDropdownRef = useRef(null) - - const loadUsers = useCallback(async () => { - setLoading(true) - try { - const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) - setUsers(data) - } catch (err) { - console.error('사용자 목록 조회 실패:', err) - } finally { - setLoading(false) - } - }, [searchTerm, statusFilter]) - - useEffect(() => { - loadUsers() - }, [loadUsers]) - - useEffect(() => { - fetchRoles().then(setAllRoles).catch(console.error) - }, []) - - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { - setRoleEditUserId(null) - } - } - if (roleEditUserId) { - document.addEventListener('mousedown', handleClickOutside) - } - return () => document.removeEventListener('mousedown', handleClickOutside) - }, [roleEditUserId]) - - const handleUnlock = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('계정 잠금 해제 실패:', err) - } - } - - const handleApprove = async (userId: string) => { - try { - await approveUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 승인 실패:', err) - } - } - - const handleReject = async (userId: string) => { - try { - await rejectUserApi(userId) - await loadUsers() - } catch (err) { - console.error('사용자 거절 실패:', err) - } - } - - const handleDeactivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'INACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 비활성화 실패:', err) - } - } - - const handleActivate = async (userId: string) => { - try { - await updateUserApi(userId, { status: 'ACTIVE' }) - await loadUsers() - } catch (err) { - console.error('사용자 활성화 실패:', err) - } - } - - const handleOpenRoleEdit = (user: UserListItem) => { - setRoleEditUserId(user.id) - setSelectedRoleSns(user.roleSns || []) - } - - const toggleRoleSelection = (roleSn: number) => { - setSelectedRoleSns(prev => - prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn] - ) - } - - const handleSaveRoles = async (userId: string) => { - try { - await assignRolesApi(userId, selectedRoleSns) - await loadUsers() - setRoleEditUserId(null) - } catch (err) { - console.error('역할 할당 실패:', err) - } - } - - const formatDate = (dateStr: string | null) => { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleString('ko-KR', { - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - }) - } - - const pendingCount = users.filter(u => u.status === 'PENDING').length - - return ( -
-
-
-
-

사용자 관리

-

총 {users.length}명

-
- {pendingCount > 0 && ( - - 승인대기 {pendingCount}명 - - )} -
-
- - setSearchTerm(e.target.value)} - className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> - -
-
- -
- {loading ? ( -
불러오는 중...
- ) : ( - - - - - - - - - - - - - - - {users.map((user) => { - const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE - return ( - - - - - - - - - - - ) - })} - -
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} -
-
handleOpenRoleEdit(user)} - title="클릭하여 역할 변경" - > - {user.roles.length > 0 ? user.roles.map((roleCode, idx) => { - const color = getRoleColor(roleCode, idx) - const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode - return ( - - {roleName} - - ) - }) : ( - 역할 없음 - )} - - - -
- {roleEditUserId === user.id && ( -
-
역할 선택
- {allRoles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} -
- - -
-
- )} -
-
- {user.oauthProvider ? ( - - - Google - - ) : ( - - - ID/PW - - )} - - - - {statusInfo.label} - - {formatDate(user.lastLogin)} -
- {user.status === 'PENDING' && ( - <> - - - - )} - {user.status === 'LOCKED' && ( - - )} - {user.status === 'ACTIVE' && ( - - )} - {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( - - )} -
-
- )} -
-
- ) -} - -// ─── 권한 관리 패널 ───────────────────────────────────────── -const PERM_RESOURCES = [ - { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, - { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, - { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, - { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, - { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, - { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, - { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, - { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, - { id: 'board', label: '게시판', desc: '게시판 접근' }, - { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, - { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, -] - -function PermissionsPanel() { - const [roles, setRoles] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [dirty, setDirty] = useState(false) - const [showCreateForm, setShowCreateForm] = useState(false) - const [newRoleCode, setNewRoleCode] = useState('') - const [newRoleName, setNewRoleName] = useState('') - const [newRoleDesc, setNewRoleDesc] = useState('') - const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState('') - const [editingRoleSn, setEditingRoleSn] = useState(null) - const [editRoleName, setEditRoleName] = useState('') - - useEffect(() => { - loadRoles() - }, []) - - const loadRoles = async () => { - setLoading(true) - try { - const data = await fetchRoles() - setRoles(data) - setDirty(false) - } catch (err) { - console.error('역할 목록 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const getPermGranted = (roleSn: number, resourceCode: string): boolean => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return false - const perm = role.permissions.find(p => p.resourceCode === resourceCode) - return perm?.granted ?? false - } - - const togglePerm = (roleSn: number, resourceCode: string) => { - setRoles(prev => prev.map(role => { - if (role.sn !== roleSn) return role - const perms = role.permissions.map(p => - p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p - ) - if (!perms.find(p => p.resourceCode === resourceCode)) { - perms.push({ sn: 0, resourceCode, granted: true }) - } - return { ...role, permissions: perms } - })) - setDirty(true) - } - - const toggleDefault = async (roleSn: number) => { - const role = roles.find(r => r.sn === roleSn) - if (!role) return - const newValue = !role.isDefault - try { - await updateRoleDefaultApi(roleSn, newValue) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, isDefault: newValue } : r - )) - } catch (err) { - console.error('기본 역할 변경 실패:', err) - } - } - - const handleSave = async () => { - setSaving(true) - try { - for (const role of roles) { - const permissions = PERM_RESOURCES.map(r => ({ - resourceCode: r.id, - granted: getPermGranted(role.sn, r.id), - })) - await updatePermissionsApi(role.sn, permissions) - } - setDirty(false) - } catch (err) { - console.error('권한 저장 실패:', err) - } finally { - setSaving(false) - } - } - - const handleCreateRole = async () => { - setCreating(true) - setCreateError('') - try { - await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined }) - await loadRoles() - setShowCreateForm(false) - setNewRoleCode('') - setNewRoleName('') - setNewRoleDesc('') - } catch (err) { - const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.' - setCreateError(message) - } finally { - setCreating(false) - } - } - - const handleDeleteRole = async (roleSn: number, roleName: string) => { - if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) { - return - } - try { - await deleteRoleApi(roleSn) - await loadRoles() - } catch (err) { - console.error('역할 삭제 실패:', err) - } - } - - const handleStartEditName = (role: RoleWithPermissions) => { - setEditingRoleSn(role.sn) - setEditRoleName(role.name) - } - - const handleSaveRoleName = async (roleSn: number) => { - if (!editRoleName.trim()) return - try { - await updateRoleApi(roleSn, { name: editRoleName.trim() }) - setRoles(prev => prev.map(r => - r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r - )) - setEditingRoleSn(null) - } catch (err) { - console.error('역할 이름 수정 실패:', err) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-
-

사용자 권한 관리

-

역할별 메뉴 접근 권한을 설정합니다

-
-
- - -
-
- -
- - - - - {roles.map((role, idx) => { - const color = getRoleColor(role.code, idx) - return ( - - ) - })} - - - - {PERM_RESOURCES.map((perm) => ( - - - {roles.map(role => ( - - ))} - - ))} - -
기능 -
- {editingRoleSn === role.sn ? ( - setEditRoleName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveRoleName(role.sn) - if (e.key === 'Escape') setEditingRoleSn(null) - }} - onBlur={() => handleSaveRoleName(role.sn)} - autoFocus - className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" - /> - ) : ( - handleStartEditName(role)} - title="클릭하여 이름 수정" - > - {role.name} - - )} - {role.code !== 'ADMIN' && ( - - )} -
-
{role.code}
- -
-
{perm.label}
-
{perm.desc}
-
- -
-
- - {/* 역할 생성 모달 */} - {showCreateForm && ( -
-
-
-

새 역할 추가

-
-
-
- - setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} - placeholder="CUSTOM_ROLE" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> -

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

-
-
- - setNewRoleName(e.target.value)} - placeholder="사용자 정의 역할" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
-
- - setNewRoleDesc(e.target.value)} - placeholder="역할에 대한 설명" - className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" - /> -
- {createError && ( -
- {createError} -
- )} -
-
- - -
-
-
- )} -
- ) -} - -// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── -interface SortableMenuItemProps { - menu: MenuConfigItem - idx: number - totalCount: number - isEditing: boolean - emojiPickerId: string | null - emojiPickerRef: React.RefObject - onToggle: (id: string) => void - onMove: (idx: number, direction: -1 | 1) => void - onEditStart: (id: string) => void - onEditEnd: () => void - onEmojiPickerToggle: (id: string | null) => void - onLabelChange: (id: string, value: string) => void - onEmojiSelect: (emoji: { native: string }) => void -} - -function SortableMenuItem({ - menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, - onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, -}: SortableMenuItemProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: menu.id }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.4 : 1, - zIndex: isDragging ? 50 : undefined, - } - - return ( -
-
- - {idx + 1} - {isEditing ? ( - <> -
- - {emojiPickerId === menu.id && ( -
- -
- )} -
-
- onLabelChange(menu.id, e.target.value)} - className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" - /> -
{menu.id}
-
- - - ) : ( - <> - {menu.icon} -
-
- {menu.label} -
-
{menu.id}
-
- - - )} -
-
- -
- - -
-
-
- ) -} - -// ─── 메뉴 관리 패널 ───────────────────────────────────────── -function MenusPanel() { - const [menus, setMenus] = useState([]) - const [originalMenus, setOriginalMenus] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [editingId, setEditingId] = useState(null) - const [emojiPickerId, setEmojiPickerId] = useState(null) - const [activeId, setActiveId] = useState(null) - const emojiPickerRef = useRef(null) - const { setMenuConfig } = useMenuStore() - - const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) - - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) - ) - - const loadMenus = useCallback(async () => { - setLoading(true) - try { - const config = await fetchMenuConfig() - setMenus(config) - setOriginalMenus(config) - } catch (err) { - console.error('메뉴 설정 조회 실패:', err) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - loadMenus() - }, [loadMenus]) - - useEffect(() => { - if (!emojiPickerId) return - const handler = (e: MouseEvent) => { - if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { - setEmojiPickerId(null) - } - } - document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [emojiPickerId]) - - const toggleMenu = (id: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) - } - - const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { - setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) - } - - const handleEmojiSelect = (emoji: { native: string }) => { - if (emojiPickerId) { - updateMenuField(emojiPickerId, 'icon', emoji.native) - setEmojiPickerId(null) - } - } - - const moveMenu = (idx: number, direction: -1 | 1) => { - const targetIdx = idx + direction - if (targetIdx < 0 || targetIdx >= menus.length) return - setMenus(prev => { - const arr = [...prev] - ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] - return arr.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event - setActiveId(null) - if (!over || active.id === over.id) return - setMenus(prev => { - const oldIndex = prev.findIndex(m => m.id === active.id) - const newIndex = prev.findIndex(m => m.id === over.id) - const reordered = arrayMove(prev, oldIndex, newIndex) - return reordered.map((m, i) => ({ ...m, order: i + 1 })) - }) - } - - const handleSave = async () => { - setSaving(true) - try { - const updated = await updateMenuConfigApi(menus) - setMenus(updated) - setOriginalMenus(updated) - setMenuConfig(updated) - } catch (err) { - console.error('메뉴 설정 저장 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return ( -
-
메뉴 설정을 불러오는 중...
-
- ) - } - - const activeMenu = activeId ? menus.find(m => m.id === activeId) : null - - return ( -
-
-
-

메뉴 관리

-

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

-
- -
- -
- setActiveId(event.active.id as string)} - onDragEnd={handleDragEnd} - > - m.id)} strategy={verticalListSortingStrategy}> -
- {menus.map((menu, idx) => ( - { setEditingId(null); setEmojiPickerId(null) }} - onEmojiPickerToggle={setEmojiPickerId} - onLabelChange={(id, value) => updateMenuField(id, 'label', value)} - onEmojiSelect={handleEmojiSelect} - /> - ))} -
-
- - {activeMenu ? ( -
- - {activeMenu.icon} - {activeMenu.label} -
- ) : null} -
-
-
-
- ) -} - -// ─── 시스템 설정 패널 ──────────────────────────────────────── -function SettingsPanel() { - const [settings, setSettings] = useState(null) - const [oauthSettings, setOauthSettings] = useState(null) - const [oauthDomainInput, setOauthDomainInput] = useState('') - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [savingOAuth, setSavingOAuth] = useState(false) - - useEffect(() => { - loadSettings() - }, []) - - const loadSettings = async () => { - setLoading(true) - try { - const [regData, oauthData] = await Promise.all([ - fetchRegistrationSettings(), - fetchOAuthSettings(), - ]) - setSettings(regData) - setOauthSettings(oauthData) - setOauthDomainInput(oauthData.autoApproveDomains) - } catch (err) { - console.error('설정 조회 실패:', err) - } finally { - setLoading(false) - } - } - - const handleToggle = async (key: keyof RegistrationSettings) => { - if (!settings) return - const newValue = !settings[key] - setSaving(true) - try { - const updated = await updateRegistrationSettingsApi({ [key]: newValue }) - setSettings(updated) - } catch (err) { - console.error('설정 변경 실패:', err) - } finally { - setSaving(false) - } - } - - if (loading) { - return
불러오는 중...
- } - - return ( -
-
-

시스템 설정

-

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

-
- -
-
- {/* 사용자 등록 설정 */} -
-
-

사용자 등록 설정

-

신규 사용자 등록 시 적용되는 정책을 설정합니다

-
- -
- {/* 자동 승인 */} -
-
-
자동 승인
-

- 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. - 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. -

-
- -
- - {/* 기본 역할 자동 할당 */} -
-
-
기본 역할 자동 할당
-

- 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. - 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. -

-
- -
-
-
- - {/* OAuth 설정 */} -
-
-

Google OAuth 설정

-

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

-
-
-
-
자동 승인 도메인
-

- 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. - 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. - 여러 도메인은 쉼표(,)로 구분합니다. -

-
- setOauthDomainInput(e.target.value)} - placeholder="gcsc.co.kr, example.com" - className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" - /> - -
-
- {oauthSettings?.autoApproveDomains && ( -
- {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( - - @{domain} - - ))} -
- )} -
-
- - {/* 현재 설정 상태 요약 */} -
-
-

설정 상태 요약

-
-
-
-
- - - 신규 사용자 등록 시{' '} - {settings?.autoApprove ? ( - 즉시 활성화 - ) : ( - 관리자 승인 필요 - )} - -
-
- - - 기본 역할 자동 할당{' '} - {settings?.defaultRole ? ( - 활성 - ) : ( - 비활성 - )} - -
-
- - - Google OAuth 자동 승인 도메인{' '} - {oauthSettings?.autoApproveDomains ? ( - {oauthSettings.autoApproveDomains} - ) : ( - 미설정 - )} - -
-
-
-
-
-
-
- ) -} +import UsersPanel from './UsersPanel' +import PermissionsPanel from './PermissionsPanel' +import MenusPanel from './MenusPanel' +import SettingsPanel from './SettingsPanel' // ─── AdminView ──────────────────────────────────────────── export function AdminView() { diff --git a/frontend/src/tabs/admin/components/MenusPanel.tsx b/frontend/src/tabs/admin/components/MenusPanel.tsx new file mode 100644 index 0000000..80a4ffd --- /dev/null +++ b/frontend/src/tabs/admin/components/MenusPanel.tsx @@ -0,0 +1,198 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragOverlay, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { + fetchMenuConfig, + updateMenuConfigApi, + type MenuConfigItem, +} from '@common/services/authApi' +import { useMenuStore } from '@common/store/menuStore' +import SortableMenuItem from './SortableMenuItem' + +// ─── 메뉴 관리 패널 ───────────────────────────────────────── +function MenusPanel() { + const [menus, setMenus] = useState([]) + const [originalMenus, setOriginalMenus] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [editingId, setEditingId] = useState(null) + const [emojiPickerId, setEmojiPickerId] = useState(null) + const [activeId, setActiveId] = useState(null) + const emojiPickerRef = useRef(null) + const { setMenuConfig } = useMenuStore() + + const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const loadMenus = useCallback(async () => { + setLoading(true) + try { + const config = await fetchMenuConfig() + setMenus(config) + setOriginalMenus(config) + } catch (err) { + console.error('메뉴 설정 조회 실패:', err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadMenus() + }, [loadMenus]) + + useEffect(() => { + if (!emojiPickerId) return + const handler = (e: MouseEvent) => { + if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) { + setEmojiPickerId(null) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [emojiPickerId]) + + const toggleMenu = (id: string) => { + setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m)) + } + + const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => { + setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m)) + } + + const handleEmojiSelect = (emoji: { native: string }) => { + if (emojiPickerId) { + updateMenuField(emojiPickerId, 'icon', emoji.native) + setEmojiPickerId(null) + } + } + + const moveMenu = (idx: number, direction: -1 | 1) => { + const targetIdx = idx + direction + if (targetIdx < 0 || targetIdx >= menus.length) return + setMenus(prev => { + const arr = [...prev] + ;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]] + return arr.map((m, i) => ({ ...m, order: i + 1 })) + }) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + if (!over || active.id === over.id) return + setMenus(prev => { + const oldIndex = prev.findIndex(m => m.id === active.id) + const newIndex = prev.findIndex(m => m.id === over.id) + const reordered = arrayMove(prev, oldIndex, newIndex) + return reordered.map((m, i) => ({ ...m, order: i + 1 })) + }) + } + + const handleSave = async () => { + setSaving(true) + try { + const updated = await updateMenuConfigApi(menus) + setMenus(updated) + setOriginalMenus(updated) + setMenuConfig(updated) + } catch (err) { + console.error('메뉴 설정 저장 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+
메뉴 설정을 불러오는 중...
+
+ ) + } + + const activeMenu = activeId ? menus.find(m => m.id === activeId) : null + + return ( +
+
+
+

메뉴 관리

+

메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다

+
+ +
+ +
+ setActiveId(event.active.id as string)} + onDragEnd={handleDragEnd} + > + m.id)} strategy={verticalListSortingStrategy}> +
+ {menus.map((menu, idx) => ( + { setEditingId(null); setEmojiPickerId(null) }} + onEmojiPickerToggle={setEmojiPickerId} + onLabelChange={(id, value) => updateMenuField(id, 'label', value)} + onEmojiSelect={handleEmojiSelect} + /> + ))} +
+
+ + {activeMenu ? ( +
+ + {activeMenu.icon} + {activeMenu.label} +
+ ) : null} +
+
+
+
+ ) +} + +export default MenusPanel diff --git a/frontend/src/tabs/admin/components/PermissionsPanel.tsx b/frontend/src/tabs/admin/components/PermissionsPanel.tsx new file mode 100644 index 0000000..01ea644 --- /dev/null +++ b/frontend/src/tabs/admin/components/PermissionsPanel.tsx @@ -0,0 +1,330 @@ +import { useState, useEffect } from 'react' +import { + fetchRoles, + updatePermissionsApi, + createRoleApi, + updateRoleApi, + deleteRoleApi, + updateRoleDefaultApi, + type RoleWithPermissions, +} from '@common/services/authApi' +import { getRoleColor, PERM_RESOURCES } from './adminConstants' + +// ─── 권한 관리 패널 ───────────────────────────────────────── +function PermissionsPanel() { + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [showCreateForm, setShowCreateForm] = useState(false) + const [newRoleCode, setNewRoleCode] = useState('') + const [newRoleName, setNewRoleName] = useState('') + const [newRoleDesc, setNewRoleDesc] = useState('') + const [creating, setCreating] = useState(false) + const [createError, setCreateError] = useState('') + const [editingRoleSn, setEditingRoleSn] = useState(null) + const [editRoleName, setEditRoleName] = useState('') + + useEffect(() => { + loadRoles() + }, []) + + const loadRoles = async () => { + setLoading(true) + try { + const data = await fetchRoles() + setRoles(data) + setDirty(false) + } catch (err) { + console.error('역할 목록 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const getPermGranted = (roleSn: number, resourceCode: string): boolean => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return false + const perm = role.permissions.find(p => p.resourceCode === resourceCode) + return perm?.granted ?? false + } + + const togglePerm = (roleSn: number, resourceCode: string) => { + setRoles(prev => prev.map(role => { + if (role.sn !== roleSn) return role + const perms = role.permissions.map(p => + p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p + ) + if (!perms.find(p => p.resourceCode === resourceCode)) { + perms.push({ sn: 0, resourceCode, granted: true }) + } + return { ...role, permissions: perms } + })) + setDirty(true) + } + + const toggleDefault = async (roleSn: number) => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return + const newValue = !role.isDefault + try { + await updateRoleDefaultApi(roleSn, newValue) + setRoles(prev => prev.map(r => + r.sn === roleSn ? { ...r, isDefault: newValue } : r + )) + } catch (err) { + console.error('기본 역할 변경 실패:', err) + } + } + + const handleSave = async () => { + setSaving(true) + try { + for (const role of roles) { + const permissions = PERM_RESOURCES.map(r => ({ + resourceCode: r.id, + granted: getPermGranted(role.sn, r.id), + })) + await updatePermissionsApi(role.sn, permissions) + } + setDirty(false) + } catch (err) { + console.error('권한 저장 실패:', err) + } finally { + setSaving(false) + } + } + + const handleCreateRole = async () => { + setCreating(true) + setCreateError('') + try { + await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined }) + await loadRoles() + setShowCreateForm(false) + setNewRoleCode('') + setNewRoleName('') + setNewRoleDesc('') + } catch (err) { + const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.' + setCreateError(message) + } finally { + setCreating(false) + } + } + + const handleDeleteRole = async (roleSn: number, roleName: string) => { + if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) { + return + } + try { + await deleteRoleApi(roleSn) + await loadRoles() + } catch (err) { + console.error('역할 삭제 실패:', err) + } + } + + const handleStartEditName = (role: RoleWithPermissions) => { + setEditingRoleSn(role.sn) + setEditRoleName(role.name) + } + + const handleSaveRoleName = async (roleSn: number) => { + if (!editRoleName.trim()) return + try { + await updateRoleApi(roleSn, { name: editRoleName.trim() }) + setRoles(prev => prev.map(r => + r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r + )) + setEditingRoleSn(null) + } catch (err) { + console.error('역할 이름 수정 실패:', err) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+
+
+

사용자 권한 관리

+

역할별 메뉴 접근 권한을 설정합니다

+
+
+ + +
+
+ +
+ + + + + {roles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} + + + + {PERM_RESOURCES.map((perm) => ( + + + {roles.map(role => ( + + ))} + + ))} + +
기능 +
+ {editingRoleSn === role.sn ? ( + setEditRoleName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveRoleName(role.sn) + if (e.key === 'Escape') setEditingRoleSn(null) + }} + onBlur={() => handleSaveRoleName(role.sn)} + autoFocus + className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean" + /> + ) : ( + handleStartEditName(role)} + title="클릭하여 이름 수정" + > + {role.name} + + )} + {role.code !== 'ADMIN' && ( + + )} +
+
{role.code}
+ +
+
{perm.label}
+
{perm.desc}
+
+ +
+
+ + {/* 역할 생성 모달 */} + {showCreateForm && ( +
+
+
+

새 역할 추가

+
+
+
+ + setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))} + placeholder="CUSTOM_ROLE" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" + /> +

영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)

+
+
+ + setNewRoleName(e.target.value)} + placeholder="사용자 정의 역할" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+
+ + setNewRoleDesc(e.target.value)} + placeholder="역할에 대한 설명" + className="w-full px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> +
+ {createError && ( +
+ {createError} +
+ )} +
+
+ + +
+
+
+ )} +
+ ) +} + +export default PermissionsPanel diff --git a/frontend/src/tabs/admin/components/SettingsPanel.tsx b/frontend/src/tabs/admin/components/SettingsPanel.tsx new file mode 100644 index 0000000..fd30cc9 --- /dev/null +++ b/frontend/src/tabs/admin/components/SettingsPanel.tsx @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react' +import { + fetchRegistrationSettings, + updateRegistrationSettingsApi, + fetchOAuthSettings, + updateOAuthSettingsApi, + type RegistrationSettings, + type OAuthSettings, +} from '@common/services/authApi' + +// ─── 시스템 설정 패널 ──────────────────────────────────────── +function SettingsPanel() { + const [settings, setSettings] = useState(null) + const [oauthSettings, setOauthSettings] = useState(null) + const [oauthDomainInput, setOauthDomainInput] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [savingOAuth, setSavingOAuth] = useState(false) + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + setLoading(true) + try { + const [regData, oauthData] = await Promise.all([ + fetchRegistrationSettings(), + fetchOAuthSettings(), + ]) + setSettings(regData) + setOauthSettings(oauthData) + setOauthDomainInput(oauthData.autoApproveDomains) + } catch (err) { + console.error('설정 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const handleToggle = async (key: keyof RegistrationSettings) => { + if (!settings) return + const newValue = !settings[key] + setSaving(true) + try { + const updated = await updateRegistrationSettingsApi({ [key]: newValue }) + setSettings(updated) + } catch (err) { + console.error('설정 변경 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+
+

시스템 설정

+

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

+
+ +
+
+ {/* 사용자 등록 설정 */} +
+
+

사용자 등록 설정

+

신규 사용자 등록 시 적용되는 정책을 설정합니다

+
+ +
+ {/* 자동 승인 */} +
+
+
자동 승인
+

+ 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. + 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. +

+
+ +
+ + {/* 기본 역할 자동 할당 */} +
+
+
기본 역할 자동 할당
+

+ 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. + 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. +

+
+ +
+
+
+ + {/* OAuth 설정 */} +
+
+

Google OAuth 설정

+

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

+
+
+
+
자동 승인 도메인
+

+ 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. + 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. + 여러 도메인은 쉼표(,)로 구분합니다. +

+
+ setOauthDomainInput(e.target.value)} + placeholder="gcsc.co.kr, example.com" + className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono" + /> + +
+
+ {oauthSettings?.autoApproveDomains && ( +
+ {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( + + @{domain} + + ))} +
+ )} +
+
+ + {/* 현재 설정 상태 요약 */} +
+
+

설정 상태 요약

+
+
+
+
+ + + 신규 사용자 등록 시{' '} + {settings?.autoApprove ? ( + 즉시 활성화 + ) : ( + 관리자 승인 필요 + )} + +
+
+ + + 기본 역할 자동 할당{' '} + {settings?.defaultRole ? ( + 활성 + ) : ( + 비활성 + )} + +
+
+ + + Google OAuth 자동 승인 도메인{' '} + {oauthSettings?.autoApproveDomains ? ( + {oauthSettings.autoApproveDomains} + ) : ( + 미설정 + )} + +
+
+
+
+
+
+
+ ) +} + +export default SettingsPanel diff --git a/frontend/src/tabs/admin/components/SortableMenuItem.tsx b/frontend/src/tabs/admin/components/SortableMenuItem.tsx new file mode 100644 index 0000000..ed5e6f1 --- /dev/null +++ b/frontend/src/tabs/admin/components/SortableMenuItem.tsx @@ -0,0 +1,161 @@ +import data from '@emoji-mart/data' +import EmojiPicker from '@emoji-mart/react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { type MenuConfigItem } from '@common/services/authApi' + +// ─── 메뉴 항목 (Sortable) ──────────────────────────────────── +export interface SortableMenuItemProps { + menu: MenuConfigItem + idx: number + totalCount: number + isEditing: boolean + emojiPickerId: string | null + emojiPickerRef: React.RefObject + onToggle: (id: string) => void + onMove: (idx: number, direction: -1 | 1) => void + onEditStart: (id: string) => void + onEditEnd: () => void + onEmojiPickerToggle: (id: string | null) => void + onLabelChange: (id: string, value: string) => void + onEmojiSelect: (emoji: { native: string }) => void +} + +function SortableMenuItem({ + menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef, + onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect, +}: SortableMenuItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: menu.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + zIndex: isDragging ? 50 : undefined, + } + + return ( +
+
+ + {idx + 1} + {isEditing ? ( + <> +
+ + {emojiPickerId === menu.id && ( +
+ +
+ )} +
+
+ onLabelChange(menu.id, e.target.value)} + className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none" + /> +
{menu.id}
+
+ + + ) : ( + <> + {menu.icon} +
+
+ {menu.label} +
+
{menu.id}
+
+ + + )} +
+
+ +
+ + +
+
+
+ ) +} + +export default SortableMenuItem diff --git a/frontend/src/tabs/admin/components/UsersPanel.tsx b/frontend/src/tabs/admin/components/UsersPanel.tsx new file mode 100644 index 0000000..417c58b --- /dev/null +++ b/frontend/src/tabs/admin/components/UsersPanel.tsx @@ -0,0 +1,350 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { + fetchUsers, + fetchRoles, + updateUserApi, + approveUserApi, + rejectUserApi, + assignRolesApi, + type UserListItem, + type RoleWithPermissions, +} from '@common/services/authApi' +import { getRoleColor, statusLabels } from './adminConstants' + +// ─── 사용자 관리 패널 ───────────────────────────────────────── +function UsersPanel() { + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [allRoles, setAllRoles] = useState([]) + const [roleEditUserId, setRoleEditUserId] = useState(null) + const [selectedRoleSns, setSelectedRoleSns] = useState([]) + const roleDropdownRef = useRef(null) + + const loadUsers = useCallback(async () => { + setLoading(true) + try { + const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) + setUsers(data) + } catch (err) { + console.error('사용자 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, [searchTerm, statusFilter]) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + useEffect(() => { + fetchRoles().then(setAllRoles).catch(console.error) + }, []) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) { + setRoleEditUserId(null) + } + } + if (roleEditUserId) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [roleEditUserId]) + + const handleUnlock = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'ACTIVE' }) + await loadUsers() + } catch (err) { + console.error('계정 잠금 해제 실패:', err) + } + } + + const handleApprove = async (userId: string) => { + try { + await approveUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 승인 실패:', err) + } + } + + const handleReject = async (userId: string) => { + try { + await rejectUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 거절 실패:', err) + } + } + + const handleDeactivate = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'INACTIVE' }) + await loadUsers() + } catch (err) { + console.error('사용자 비활성화 실패:', err) + } + } + + const handleActivate = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'ACTIVE' }) + await loadUsers() + } catch (err) { + console.error('사용자 활성화 실패:', err) + } + } + + const handleOpenRoleEdit = (user: UserListItem) => { + setRoleEditUserId(user.id) + setSelectedRoleSns(user.roleSns || []) + } + + const toggleRoleSelection = (roleSn: number) => { + setSelectedRoleSns(prev => + prev.includes(roleSn) ? prev.filter(s => s !== roleSn) : [...prev, roleSn] + ) + } + + const handleSaveRoles = async (userId: string) => { + try { + await assignRolesApi(userId, selectedRoleSns) + await loadUsers() + setRoleEditUserId(null) + } catch (err) { + console.error('역할 할당 실패:', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + } + + const pendingCount = users.filter(u => u.status === 'PENDING').length + + return ( +
+
+
+
+

사용자 관리

+

총 {users.length}명

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )} +
+
+ + setSearchTerm(e.target.value)} + className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> + +
+
+ +
+ {loading ? ( +
불러오는 중...
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => { + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE + return ( + + + + + + + + + + + ) + })} + +
이름계정소속역할인증상태최근 로그인관리
{user.name}{user.account}{user.orgAbbr || '-'} +
+
handleOpenRoleEdit(user)} + title="클릭하여 역할 변경" + > + {user.roles.length > 0 ? user.roles.map((roleCode, idx) => { + const color = getRoleColor(roleCode, idx) + const roleName = allRoles.find(r => r.code === roleCode)?.name || roleCode + return ( + + {roleName} + + ) + }) : ( + 역할 없음 + )} + + + +
+ {roleEditUserId === user.id && ( +
+
역할 선택
+ {allRoles.map((role, idx) => { + const color = getRoleColor(role.code, idx) + return ( + + ) + })} +
+ + +
+
+ )} +
+
+ {user.oauthProvider ? ( + + + Google + + ) : ( + + + ID/PW + + )} + + + + {statusInfo.label} + + {formatDate(user.lastLogin)} +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + {user.status === 'ACTIVE' && ( + + )} + {(user.status === 'INACTIVE' || user.status === 'REJECTED') && ( + + )} +
+
+ )} +
+
+ ) +} + +export default UsersPanel diff --git a/frontend/src/tabs/admin/components/adminConstants.ts b/frontend/src/tabs/admin/components/adminConstants.ts new file mode 100644 index 0000000..b29f8b4 --- /dev/null +++ b/frontend/src/tabs/admin/components/adminConstants.ts @@ -0,0 +1,36 @@ +export const DEFAULT_ROLE_COLORS: Record = { + ADMIN: 'var(--red)', + MANAGER: 'var(--orange)', + USER: 'var(--cyan)', + VIEWER: 'var(--t3)', +} + +export const CUSTOM_ROLE_COLORS = [ + '#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf', +] + +export function getRoleColor(code: string, index: number): string { + return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length] +} + +export const statusLabels: Record = { + PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, + ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, + LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, + INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, + REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, +} + +export const PERM_RESOURCES = [ + { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, + { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, + { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, + { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, + { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, + { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, + { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, + { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, + { id: 'board', label: '게시판', desc: '게시판 접근' }, + { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, + { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, +] diff --git a/frontend/src/tabs/aerial/components/AerialView.tsx b/frontend/src/tabs/aerial/components/AerialView.tsx index 321850f..26a171c 100755 --- a/frontend/src/tabs/aerial/components/AerialView.tsx +++ b/frontend/src/tabs/aerial/components/AerialView.tsx @@ -1,2445 +1,19 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useEffect } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' import { AerialTheoryView } from './AerialTheoryView' +import { MediaManagement } from './MediaManagement' +import { OilAreaAnalysis } from './OilAreaAnalysis' +import { RealtimeDrone } from './RealtimeDrone' +import { SensorAnalysis } from './SensorAnalysis' +import { SatelliteRequest } from './SatelliteRequest' +import { CctvView } from './CctvView' type AerialTab = 'media' | 'analysis' | 'realtime' | 'sensor' -// ── Mock Data ── - -interface MediaFile { - id: number - incident: string - location: string - filename: string - equipment: string - equipType: 'drone' | 'plane' | 'satellite' - mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학' - datetime: string - size: string - resolution: string -} - -const mediaFiles: MediaFile[] = [ - { id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' }, - { id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' }, - { id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' }, - { id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' }, - { id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' }, - { id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' }, - { id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' }, - { id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' }, - { id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' }, - { id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' }, - { id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' }, - { id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' }, - { id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' }, - { id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' }, - { id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' }, -] - -const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰' - -const equipTagCls = (t: string) => - t === 'drone' - ? 'bg-[rgba(59,130,246,0.12)] text-primary-blue' - : t === 'plane' - ? 'bg-[rgba(34,197,94,0.12)] text-status-green' - : 'bg-[rgba(168,85,247,0.12)] text-primary-purple' - -const mediaTagCls = (t: string) => - t === '영상' - ? 'bg-[rgba(239,68,68,0.12)] text-status-red' - : 'bg-[rgba(234,179,8,0.12)] text-status-yellow' - -// ── Tab 0: 영상·사진 관리 ── - -const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( - -) - -function MediaManagementTab() { - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [equipFilter, setEquipFilter] = useState('all') - const [typeFilter, setTypeFilter] = useState>(new Set()) - const [searchTerm, setSearchTerm] = useState('') - const [sortBy, setSortBy] = useState('latest') - const [showUpload, setShowUpload] = useState(false) - const modalRef = useRef(null) - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - setShowUpload(false) - } - } - if (showUpload) document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [showUpload]) - - const filtered = mediaFiles.filter(f => { - if (equipFilter !== 'all' && f.equipType !== equipFilter) return false - if (typeFilter.size > 0) { - const isPhoto = !['영상'].includes(f.mediaType) - const isVideo = f.mediaType === '영상' - if (typeFilter.has('photo') && !isPhoto) return false - if (typeFilter.has('video') && !isVideo) return false - } - if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false - return true - }) - - const sorted = [...filtered].sort((a, b) => { - if (sortBy === 'name') return a.filename.localeCompare(b.filename) - if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size) - return b.datetime.localeCompare(a.datetime) - }) - - const toggleId = (id: number) => { - setSelectedIds(prev => { - const next = new Set(prev) - if (next.has(id)) { next.delete(id) } else { next.add(id) } - return next - }) - } - - const toggleAll = () => { - if (selectedIds.size === sorted.length) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(sorted.map(f => f.id))) - } - } - - const toggleTypeFilter = (t: string) => { - setTypeFilter(prev => { - const next = new Set(prev) - if (next.has(t)) { next.delete(t) } else { next.add(t) } - return next - }) - } - - const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length - const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length - const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length - - return ( -
- {/* Filters */} -
-
- 촬영 장비: - setEquipFilter('all')} /> - setEquipFilter('drone')} /> - setEquipFilter('plane')} /> - setEquipFilter('satellite')} /> - - 유형: - toggleTypeFilter('photo')} /> - toggleTypeFilter('video')} /> -
-
- setSearchTerm(e.target.value)} - className="px-3 py-1.5 bg-bg-0 border border-border rounded-sm text-text-1 font-korean text-[11px] outline-none w-40 focus:border-primary-cyan" - /> - -
-
- - {/* Summary Stats */} -
- {[ - { icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' }, - { icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' }, - { icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' }, - { icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' }, - { icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' }, - ].map((s, i) => ( -
- {s.icon} -
-
{s.value}
-
{s.label}
-
-
- ))} -
- - {/* File Table */} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {sorted.map(f => ( - toggleId(f.id)} - className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ - selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : '' - }`} - > - - - - - - - - - - - - - ))} - -
- 0} - onChange={toggleAll} - className="accent-primary-blue" - /> - - 사고명위치파일명장비유형촬영일시용량해상도📥
e.stopPropagation()}> - toggleId(f.id)} - className="accent-primary-blue" - /> - {equipIcon(f.equipType)}{f.incident}{f.location}{f.filename} - - {f.equipment} - - - - {f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} - - {f.datetime}{f.size}{f.resolution} e.stopPropagation()}> - -
-
-
- - {/* Bottom Actions */} -
-
- 선택된 파일: {selectedIds.size}건 -
-
- - - -
-
- - {/* Upload Modal */} - {showUpload && ( -
-
-
- 📤 영상·사진 업로드 - -
-
-
📁
-
파일을 드래그하거나 클릭하여 업로드
-
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
-
-
- - -
-
- - -
-
- -