release: Phase 1~5 리팩토링 통합 릴리즈 #26

병합
htlee develop 에서 main 로 14 commits 를 머지했습니다 2026-02-28 18:44:26 +09:00
43개의 변경된 파일9743개의 추가작업 그리고 9403개의 파일을 삭제
Showing only changes of commit c727afd1ba - Show all commits

2
.gitignore vendored
파일 보기

@ -29,7 +29,7 @@ backend/data/*.db-wal
# Large reference data (keep locally, do not commit)
_reference/
scat/
/scat/
참고용/
논문/

파일 보기

@ -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;

파일 보기

@ -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]);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -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<MenuConfigItem[]>([])
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const emojiPickerRef = useRef<HTMLDivElement>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-text-3 text-sm font-korean"> ...</div>
</div>
)
}
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> , , , </p>
</div>
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
hasChanges && !saving
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => setActiveId(event.active.id as string)}
onDragEnd={handleDragEnd}
>
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2 max-w-[700px]">
{menus.map((menu, idx) => (
<SortableMenuItem
key={menu.id}
menu={menu}
idx={idx}
totalCount={menus.length}
isEditing={editingId === menu.id}
emojiPickerId={emojiPickerId}
emojiPickerRef={emojiPickerRef}
onToggle={toggleMenu}
onMove={moveMenu}
onEditStart={setEditingId}
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
onEmojiPickerToggle={setEmojiPickerId}
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
onEmojiSelect={handleEmojiSelect}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeMenu ? (
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
<span className="text-text-3 text-xs"></span>
<span className="text-[16px]">{activeMenu.icon}</span>
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)
}
export default MenusPanel

파일 보기

@ -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<RoleWithPermissions[]>([])
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<number | null>(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 <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowCreateForm(true); setCreateError('') }}
className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
>
+
</button>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]"></th>
{roles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
return (
<th key={role.sn} className="px-4 py-3 text-center min-w-[100px]">
<div className="flex items-center justify-center gap-1">
{editingRoleSn === role.sn ? (
<input
type="text"
value={editRoleName}
onChange={(e) => 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"
/>
) : (
<span
className="text-[11px] font-semibold font-korean cursor-pointer hover:underline"
style={{ color }}
onClick={() => handleStartEditName(role)}
title="클릭하여 이름 수정"
>
{role.name}
</span>
)}
{role.code !== 'ADMIN' && (
<button
onClick={() => handleDeleteRole(role.sn, role.name)}
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
title="역할 삭제"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
)}
</div>
<div className="text-[9px] text-text-3 font-mono mt-0.5">{role.code}</div>
<button
onClick={() => toggleDefault(role.sn)}
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
: 'text-text-3 border border-transparent hover:border-border'
}`}
title="신규 사용자에게 자동 할당되는 기본 역할"
>
{role.isDefault ? '기본역할' : '기본역할 설정'}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{PERM_RESOURCES.map((perm) => (
<tr key={perm.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3">
<div className="text-[12px] text-text-1 font-semibold font-korean">{perm.label}</div>
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
</td>
{roles.map(role => (
<td key={role.sn} className="px-4 py-3 text-center">
<button
onClick={() => togglePerm(role.sn, perm.id)}
className={`w-8 h-8 rounded-md border text-sm transition-all ${
getPermGranted(role.sn, perm.id)
? 'bg-[rgba(6,182,212,0.15)] border-primary-cyan text-primary-cyan'
: 'bg-bg-2 border-border text-text-3 hover:border-text-3'
}`}
>
{getPermGranted(role.sn, perm.id) ? '✓' : '—'}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* 역할 생성 모달 */}
{showCreateForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
<div className="px-5 py-4 border-b border-border">
<h3 className="text-sm font-bold text-text-1 font-korean"> </h3>
</div>
<div className="px-5 py-4 flex flex-col gap-3">
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> </label>
<input
type="text"
value={newRoleCode}
onChange={(e) => 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"
/>
<p className="text-[10px] text-text-3 mt-1 font-korean"> , , ( )</p>
</div>
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> </label>
<input
type="text"
value={newRoleName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-[11px] text-text-3 font-korean block mb-1"> ()</label>
<input
type="text"
value={newRoleDesc}
onChange={(e) => 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"
/>
</div>
{createError && (
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
{createError}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
<button
onClick={() => setShowCreateForm(false)}
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
>
</button>
<button
onClick={handleCreateRole}
disabled={!newRoleCode || !newRoleName || creating}
className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean disabled:opacity-50"
>
{creating ? '생성 중...' : '생성'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default PermissionsPanel

파일 보기

@ -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<RegistrationSettings | null>(null)
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(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 <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="max-w-[640px] flex flex-col gap-6">
{/* 사용자 등록 설정 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
<p className="text-[11px] text-text-3 mt-0.5 font-korean"> </p>
</div>
<div className="divide-y divide-border">
{/* 자동 승인 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
</p>
</div>
<button
onClick={() => handleToggle('autoApprove')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
{/* 기본 역할 자동 할당 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-primary-cyan font-semibold"> </span> .
.
</p>
</div>
<button
onClick={() => handleToggle('defaultRole')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
</div>
</div>
{/* OAuth 설정 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth </h2>
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google </p>
</div>
<div className="px-5 py-4">
<div className="flex-1 mr-4 mb-3">
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1"> </div>
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
Google <span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
(,) .
</p>
<div className="flex gap-2">
<input
type="text"
value={oauthDomainInput}
onChange={(e) => 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"
/>
<button
onClick={async () => {
setSavingOAuth(true)
try {
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
setOauthSettings(updated)
setOauthDomainInput(updated.autoApproveDomains)
} catch (err) {
console.error('OAuth 설정 변경 실패:', err)
} finally {
setSavingOAuth(false)
}
}}
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{savingOAuth ? '저장 중...' : '저장'}
</button>
</div>
</div>
{oauthSettings?.autoApproveDomains && (
<div className="flex flex-wrap gap-1.5 mt-3">
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
>
@{domain}
</span>
))}
</div>
)}
</div>
</div>
{/* 현재 설정 상태 요약 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4">
<div className="flex flex-col gap-3 text-[12px] font-korean">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
<span className="text-text-2">
{' '}
{settings?.autoApprove ? (
<span className="text-green-400 font-semibold"> </span>
) : (
<span className="text-yellow-400 font-semibold"> </span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
<span className="text-text-2">
{' '}
{settings?.defaultRole ? (
<span className="text-green-400 font-semibold"></span>
) : (
<span className="text-text-3 font-semibold"></span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
<span className="text-text-2">
Google OAuth {' '}
{oauthSettings?.autoApproveDomains ? (
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
) : (
<span className="text-text-3 font-semibold"></span>
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default SettingsPanel

파일 보기

@ -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<HTMLDivElement | null>
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 (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
menu.enabled
? 'bg-bg-1 border-border'
: 'bg-bg-0 border-border opacity-50'
}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
title="드래그하여 순서 변경"
>
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
</svg>
</button>
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
{isEditing ? (
<>
<div className="relative shrink-0">
<button
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
title="아이콘 변경"
>
{menu.icon}
</button>
{emojiPickerId === menu.id && (
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
<EmojiPicker
data={data}
onEmojiSelect={onEmojiSelect}
theme="dark"
locale="kr"
previewPosition="none"
skinTonePosition="search"
perLine={8}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={menu.label}
onChange={(e) => 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"
/>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
</div>
<button
onClick={onEditEnd}
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
>
</button>
</>
) : (
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
{menu.label}
</div>
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
</div>
<button
onClick={() => onEditStart(menu.id)}
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
title="라벨/아이콘 편집"
>
</button>
</>
)}
</div>
<div className="flex items-center gap-3 ml-3 shrink-0">
<button
onClick={() => onToggle(menu.id)}
className={`relative w-10 h-5 rounded-full transition-all ${
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
menu.enabled ? 'left-[22px]' : 'left-0.5'
}`}
/>
</button>
<div className="flex gap-1">
<button
onClick={() => onMove(idx, -1)}
disabled={idx === 0}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => onMove(idx, 1)}
disabled={idx === totalCount - 1}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)
}
export default SortableMenuItem

파일 보기

@ -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<string>('')
const [users, setUsers] = useState<UserListItem[]>([])
const [loading, setLoading] = useState(true)
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
const roleDropdownRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {users.length}</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
</div>
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="LOCKED"></option>
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
<input
type="text"
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => 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"
/>
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean">
+
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"> </th>
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean"></th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
return (
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
<td className="px-6 py-3">
<div className="relative">
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={() => 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 (
<span
key={roleCode}
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`
}}
>
{roleName}
</span>
)
}) : (
<span className="text-[10px] text-text-3 font-korean"> </span>
)}
<span className="text-[10px] text-text-3 ml-0.5">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</span>
</div>
{roleEditUserId === user.id && (
<div
ref={roleDropdownRef}
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-1 border border-border rounded-lg shadow-lg min-w-[200px]"
>
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1"> </div>
{allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx)
return (
<label key={role.sn} className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer">
<input
type="checkbox"
checked={selectedRoleSns.includes(role.sn)}
onChange={() => toggleRoleSelection(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>{role.name}</span>
<span className="text-[10px] text-text-3 font-mono">{role.code}</span>
</label>
)
})}
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
<button
onClick={() => setRoleEditUserId(null)}
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
>
</button>
<button
onClick={() => handleSaveRoles(user.id)}
disabled={selectedRoleSns.length === 0}
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
>
</button>
</div>
</div>
)}
</div>
</td>
<td className="px-6 py-3">
{user.oauthProvider ? (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
title={user.email || undefined}
>
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
Google
</span>
) : (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
ID/PW
</span>
)}
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
<td className="px-6 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
>
</button>
</>
)}
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
>
</button>
)}
{user.status === 'ACTIVE' && (
<button
onClick={() => handleDeactivate(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
)}
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
<button
onClick={() => handleActivate(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
export default UsersPanel

파일 보기

@ -0,0 +1,36 @@
export const DEFAULT_ROLE_COLORS: Record<string, string> = {
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<string, { label: string; color: string; dot: string }> = {
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: '시스템 관리 기능 접근' },
]

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,343 @@
import { useState } from 'react'
interface CctvCamera {
id: number
name: string
region: '제주' | '남해' | '서해' | '동해'
location: string
coord: string
status: 'live' | 'offline'
ptz: boolean
source: string
}
const cctvCameras: CctvCamera[] = [
{ id: 1, name: '서귀포항 동측', region: '제주', location: '제주 서귀포시 서귀동', coord: '33.24°N 126.57°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 2, name: '제주항 입구', region: '제주', location: '제주 제주시 건입동', coord: '33.52°N 126.53°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 3, name: '성산포항', region: '제주', location: '제주 서귀포시 성산읍', coord: '33.46°N 126.93°E', status: 'live', ptz: false, source: 'TAGO' },
{ id: 4, name: '모슬포항', region: '제주', location: '제주 서귀포시 대정읍', coord: '33.21°N 126.25°E', status: 'live', ptz: false, source: 'KBS' },
{ id: 5, name: '여수 신항', region: '남해', location: '전남 여수시 웅천동', coord: '34.73°N 127.68°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 6, name: '통영항', region: '남해', location: '경남 통영시 항남동', coord: '34.84°N 128.43°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 7, name: '부산 감천항', region: '남해', location: '부산 서구 암남동', coord: '35.08°N 129.01°E', status: 'live', ptz: false, source: 'KBS' },
{ id: 8, name: '목포 내항', region: '서해', location: '전남 목포시 항동', coord: '34.79°N 126.38°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 9, name: '군산 외항', region: '서해', location: '전북 군산시 소룡동', coord: '35.97°N 126.72°E', status: 'live', ptz: false, source: 'TAGO' },
{ id: 10, name: '인천항 연안', region: '서해', location: '인천 중구 항동', coord: '37.45°N 126.60°E', status: 'offline', ptz: false, source: 'KBS' },
{ id: 11, name: '동해항', region: '동해', location: '강원 동해시 송정동', coord: '37.52°N 129.12°E', status: 'live', ptz: true, source: 'TAGO' },
{ id: 12, name: '포항 영일만', region: '동해', location: '경북 포항시 남구', coord: '36.02°N 129.38°E', status: 'live', ptz: false, source: 'TAGO' },
]
const cctvFavorites = [
{ name: '서귀포항 동측', reason: '유출 사고 인접' },
{ name: '여수 신항', reason: '주요 방제 거점' },
{ name: '목포 내항', reason: '서해 모니터링' },
]
export function CctvView() {
const [searchTerm, setSearchTerm] = useState('')
const [regionFilter, setRegionFilter] = useState('전체')
const [selectedCamera, setSelectedCamera] = useState<CctvCamera | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCamera[]>([])
const regions = ['전체', '제주', '남해', '서해', '동해']
const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
const filtered = cctvCameras.filter(c => {
if (regionFilter !== '전체' && c.region !== regionFilter) return false
if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false
return true
})
const handleSelectCamera = (cam: CctvCamera) => {
setSelectedCamera(cam)
if (gridMode === 1) {
setActiveCells([cam])
} else {
setActiveCells(prev => {
if (prev.length < gridMode && !prev.find(c => c.id === cam.id)) return [...prev, cam]
return prev
})
}
}
const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3
const totalCells = gridMode
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* 왼쪽: 목록 패널 */}
<div className="flex flex-col overflow-hidden bg-bg-1 border-r border-border" style={{ width: 290, minWidth: 290 }}>
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-border shrink-0 bg-bg-2">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-text-1 font-korean flex items-center gap-1.5">
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
CCTV
</div>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-text-3 font-korean">API </span>
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
</div>
</div>
{/* 검색 */}
<div className="flex items-center gap-2 bg-bg-0 border border-border rounded-md px-2.5 py-1.5 mb-2 focus-within:border-primary-cyan/50 transition-colors">
<span className="text-text-3 text-[11px]">🔍</span>
<input
type="text"
placeholder="지점명 또는 지역 검색..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="flex-1 bg-transparent border-none text-text-1 text-[11px] font-korean outline-none"
/>
</div>
{/* 지역 필터 */}
<div className="flex gap-1 flex-wrap">
{regions.map(r => (
<button
key={r}
onClick={() => setRegionFilter(r)}
className="px-2 py-0.5 rounded text-[9px] font-semibold cursor-pointer font-korean border transition-colors"
style={regionFilter === r
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)', borderColor: 'rgba(6,182,212,.3)' }
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
}
>{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r}</button>
))}
</div>
</div>
{/* 상태 바 */}
<div className="flex items-center justify-between px-3.5 py-1 border-b border-border shrink-0 bg-bg-1">
<div className="text-[9px] text-text-3 font-korean">출처: 국립해양조사원 · KBS </div>
<div className="text-[10px] text-text-2 font-korean"><b className="text-text-1">{filtered.length}</b></div>
</div>
{/* 카메라 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{filtered.map(cam => (
<div
key={cam.id}
onClick={() => handleSelectCamera(cam)}
className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors"
style={{
borderColor: 'rgba(255,255,255,.04)',
background: selectedCamera?.id === cam.id ? 'rgba(6,182,212,.08)' : 'transparent',
}}
>
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.status === 'live' ? 'var(--green)' : 'var(--t3)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.name}</div>
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div>
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
{cam.status === 'live' ? (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
) : (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
)}
{cam.ptz && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
</div>
</div>
))}
</div>
</div>
{/* 가운데: 영상 뷰어 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0" style={{ background: '#04070f' }}>
{/* 뷰어 툴바 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'}
</div>
{selectedCamera?.status === 'live' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* PTZ 컨트롤 */}
{selectedCamera?.ptz && (
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
{['◀', '▲', '▼', '▶'].map((d, i) => (
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{d}</button>
))}
<div className="w-px h-4 bg-border mx-0.5" />
{['+', ''].map((z, i) => (
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-0 border border-border rounded text-[9px] text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">{z}</button>
))}
</div>
)}
{/* 분할 모드 */}
<div className="flex border border-border rounded-[5px] overflow-hidden">
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
{ mode: 9, icon: '⊟', label: '9분할' },
].map(g => (
<button
key={g.mode}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--cyan)' }
: { background: 'var(--bg3)', color: 'var(--t2)' }
}
>{g.icon}</button>
))}
</div>
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 </button>
</div>
</div>
{/* 영상 그리드 */}
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative" style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
background: '#000',
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const cam = activeCells[i]
return (
<div key={i} className="relative flex items-center justify-center overflow-hidden" style={{ background: '#0a0e18', border: '1px solid rgba(255,255,255,.06)' }}>
{cam ? (
<>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-4xl opacity-20">📹</div>
</div>
<div className="absolute top-2 left-2 flex items-center gap-1.5">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.name}</span>
<span className="text-[8px] font-bold px-1 py-0.5 rounded" style={{ background: 'rgba(239,68,68,.3)', color: '#f87171' }}> REC</span>
</div>
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t3)' }}>
{cam.coord} · {cam.source}
</div>
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
CCTV
</div>
</>
) : (
<div className="text-[10px] text-text-3 font-korean opacity-40"> </div>
)}
</div>
)
})}
</div>
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
<div className="text-[10px] text-text-3 font-korean">: <b className="text-text-1">{selectedCamera?.name ?? ''}</b></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-text-2">{selectedCamera?.location ?? ''}</span></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coord ?? ''}</span></div>
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO CCTV</div>
</div>
</div>
{/* 오른쪽: 미니맵 + 정보 */}
<div className="flex flex-col overflow-hidden bg-bg-1 border-l border-border" style={{ width: 232, minWidth: 232 }}>
{/* 지도 헤더 */}
<div className="px-3 py-2 border-b border-border text-[11px] font-bold text-text-1 font-korean bg-bg-2 shrink-0 flex items-center justify-between">
<span>🗺 </span>
<span className="text-[9px] text-text-3 font-normal"> </span>
</div>
{/* 미니맵 (placeholder) */}
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative" style={{ height: 210 }}>
<div className="text-[10px] text-text-3 font-korean opacity-50"> </div>
{/* 간략 지도 표현 */}
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
{cctvCameras.filter(c => c.status === 'live').slice(0, 6).map((c, i) => (
<div
key={i}
className="absolute w-2 h-2 rounded-full cursor-pointer"
style={{
background: selectedCamera?.id === c.id ? 'var(--cyan)' : 'var(--green)',
boxShadow: selectedCamera?.id === c.id ? '0 0 6px var(--cyan)' : 'none',
top: `${20 + (i * 25) % 70}%`,
left: `${15 + (i * 30) % 70}%`,
}}
title={c.name}
onClick={() => handleSelectCamera(c)}
/>
))}
</div>
</div>
{/* 카메라 정보 */}
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-border" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">📋 </div>
{selectedCamera ? (
<div className="flex flex-col gap-1.5">
{[
['카메라명', selectedCamera.name],
['지역', selectedCamera.region],
['위치', selectedCamera.location],
['좌표', selectedCamera.coord],
['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'],
['PTZ', selectedCamera.ptz ? '지원' : '미지원'],
['출처', selectedCamera.source],
].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono text-text-1">{v}</span>
</div>
))}
</div>
) : (
<div className="text-[10px] text-text-3 font-korean"> </div>
)}
{/* 방제 즐겨찾기 */}
<div className="mt-3 pt-2.5 border-t border-border">
<div className="text-[10px] font-bold text-text-2 font-korean mb-2"> </div>
<div className="flex flex-col gap-1">
{cctvFavorites.map((fav, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 rounded-[5px] cursor-pointer hover:bg-bg-hover transition-colors"
onClick={() => {
const found = cctvCameras.find(c => c.name === fav.name)
if (found) handleSelectCamera(found)
}}
>
<span className="text-[9px]"></span>
<div className="flex-1 min-w-0">
<div className="text-[9px] font-semibold text-text-1 font-korean truncate">{fav.name}</div>
<div className="text-[8px] text-text-3 font-korean">{fav.reason}</div>
</div>
</div>
))}
</div>
</div>
{/* API 연동 현황 */}
<div className="mt-3 pt-2.5 border-t border-border">
<div className="text-[10px] font-bold text-text-2 font-korean mb-2">🔌 API </div>
<div className="flex flex-col gap-1.5">
{[
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--green)' },
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--green)' },
].map((api, i) => (
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
<span className="text-[9px] text-text-2 font-korean">{api.name}</span>
<span className="text-[9px] font-bold" style={{ color: api.color }}>{api.status}</span>
</div>
))}
<div className="flex items-center justify-between px-2 py-1 bg-bg-3 rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
<span className="text-[9px] text-text-2 font-korean"> </span>
<span className="text-[9px] font-bold font-mono" style={{ color: 'var(--blue)' }}>1 fps</span>
</div>
<div className="text-[9px] text-text-3 font-mono text-right mt-0.5">: {new Date().toLocaleTimeString('ko-KR')}</div>
</div>
</div>
</div>
</div>
</div>
)
}

파일 보기

@ -0,0 +1,335 @@
import { useState, useRef, useEffect } from 'react'
// ── Types & 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'
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
<button
onClick={onClick}
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
active
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
{label}
</button>
)
// ── Component ──
export function MediaManagement() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [equipFilter, setEquipFilter] = useState<string>('all')
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState('latest')
const [showUpload, setShowUpload] = useState(false)
const modalRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
{/* Filters */}
<div className="flex items-center justify-between mb-4">
<div className="flex gap-1.5 items-center">
<span className="text-[11px] text-text-3 font-korean"> :</span>
<FilterBtn label="전체" active={equipFilter === 'all'} onClick={() => setEquipFilter('all')} />
<FilterBtn label="🛸 드론" active={equipFilter === 'drone'} onClick={() => setEquipFilter('drone')} />
<FilterBtn label="✈ 유인항공기" active={equipFilter === 'plane'} onClick={() => setEquipFilter('plane')} />
<FilterBtn label="🛰 위성" active={equipFilter === 'satellite'} onClick={() => setEquipFilter('satellite')} />
<span className="w-px h-4 bg-border mx-1" />
<span className="text-[11px] text-text-3 font-korean">:</span>
<FilterBtn label="📷 사진" active={typeFilter.has('photo')} onClick={() => toggleTypeFilter('photo')} />
<FilterBtn label="🎬 영상" active={typeFilter.has('video')} onClick={() => toggleTypeFilter('video')} />
</div>
<div className="flex gap-2 items-center">
<input
type="text"
placeholder="파일명 검색..."
value={searchTerm}
onChange={e => 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"
/>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
className="prd-i py-1.5 w-auto"
>
<option value="latest"></option>
<option value="name"></option>
<option value="size"></option>
</select>
</div>
</div>
{/* Summary Stats */}
<div className="flex gap-2.5 mb-4">
{[
{ 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) => (
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
<span className="text-xl">{s.icon}</span>
<div>
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</div>
<div className="text-[10px] text-text-3 font-korean">{s.label}</div>
</div>
</div>
))}
</div>
{/* File Table */}
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
<div className="overflow-auto flex-1">
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: 36 }} />
<col style={{ width: 36 }} />
<col style={{ width: 120 }} />
<col style={{ width: 130 }} />
<col />
<col style={{ width: 95 }} />
<col style={{ width: 85 }} />
<col style={{ width: 145 }} />
<col style={{ width: 85 }} />
<col style={{ width: 95 }} />
<col style={{ width: 50 }} />
</colgroup>
<thead>
<tr className="border-b border-border bg-bg-2">
<th className="px-2 py-2.5 text-center">
<input
type="checkbox"
checked={selectedIds.size === sorted.length && sorted.length > 0}
onChange={toggleAll}
className="accent-primary-blue"
/>
</th>
<th className="px-1 py-2.5" />
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 font-korean whitespace-nowrap"></th>
<th className="px-2 py-2.5 text-[10px] font-semibold text-text-3 text-center">📥</th>
</tr>
</thead>
<tbody>
{sorted.map(f => (
<tr
key={f.id}
onClick={() => 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)]' : ''
}`}
>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(f.id)}
onChange={() => toggleId(f.id)}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
{f.equipment}
</span>
</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
</span>
</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
📥
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Bottom Actions */}
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-border">
<div className="text-[11px] text-text-3 font-korean">
: <span className="text-primary-cyan font-semibold">{selectedIds.size}</span>
</div>
<div className="flex gap-2">
<button onClick={toggleAll} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover transition-colors font-korean">
</button>
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean">
📥
</button>
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-primary-purple border border-primary-purple/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean">
🧩
</button>
</div>
</div>
{/* Upload Modal */}
{showUpload && (
<div className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center">
<div ref={modalRef} className="bg-bg-1 border border-border rounded-md w-[480px] max-h-[80vh] overflow-y-auto p-6">
<div className="flex justify-between items-center mb-4">
<span className="text-base font-bold font-korean">📤 · </span>
<button onClick={() => setShowUpload(false)} className="text-text-3 text-lg hover:text-text-1"></button>
</div>
<div className="border-2 border-dashed border-border-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-primary-cyan/40 transition-colors">
<div className="text-3xl mb-2 opacity-50">📁</div>
<div className="text-[13px] font-semibold mb-1 font-korean"> </div>
<div className="text-[11px] text-text-3 font-korean">JPG, TIFF, GeoTIFF, MP4, MOV · 2GB</div>
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<option> (DJI M300 RTK)</option>
<option> (DJI Mavic 3E)</option>
<option> (CN-235)</option>
<option> ( B-512)</option>
<option> (Sentinel-1)</option>
<option> (5)</option>
<option></option>
</select>
</div>
<div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<option> (2026-01-18)</option>
<option> (2026-01-18)</option>
<option> (2026-01-18)</option>
</select>
</div>
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"></label>
<textarea
className="prd-i w-full h-[60px] resize-y"
placeholder="촬영 조건, 비고 등..."
/>
</div>
<button className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
📤
</button>
</div>
</div>
)}
</div>
)
}

파일 보기

@ -0,0 +1,212 @@
import { useState } from 'react'
// ── Types & Mock Data ──
interface MosaicImage {
id: string
filename: string
status: 'done' | 'processing' | 'waiting'
hasOil: boolean
}
const mosaicImages: MosaicImage[] = [
{ id: 'T1', filename: '드론_001.jpg', status: 'done', hasOil: true },
{ id: 'T2', filename: '드론_002.jpg', status: 'done', hasOil: true },
{ id: 'T3', filename: '드론_003.jpg', status: 'done', hasOil: true },
{ id: 'T4', filename: '드론_004.jpg', status: 'done', hasOil: true },
{ id: 'T5', filename: '드론_005.jpg', status: 'processing', hasOil: false },
{ id: 'T6', filename: '드론_006.jpg', status: 'waiting', hasOil: false },
]
// ── Component ──
export function OilAreaAnalysis() {
const [activeStep, setActiveStep] = useState(1)
const [analyzing, setAnalyzing] = useState(false)
const [analyzed, setAnalyzed] = useState(false)
const handleAnalyze = () => {
setAnalyzing(true)
setTimeout(() => {
setAnalyzing(false)
setAnalyzed(true)
}, 1500)
}
const stepCls = (idx: number) => {
if (idx < activeStep) return 'border-status-green text-status-green bg-[rgba(34,197,94,0.05)]'
if (idx === activeStep) return 'border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.05)]'
return 'border-border text-text-3 bg-bg-3'
}
return (
<div className="flex gap-5 h-full overflow-hidden">
{/* Left Panel */}
<div className="w-[340px] min-w-[340px] flex flex-col overflow-y-auto scrollbar-thin">
<div className="text-sm font-bold mb-1 font-korean">🧩 </div>
<div className="text-[11px] text-text-3 mb-4 font-korean"> .</div>
{/* Step Indicator */}
<div className="flex gap-2 mb-3">
{['① 사진 선택', '② 정합·합성', '③ 면적 산정'].map((label, i) => (
<button
key={i}
onClick={() => setActiveStep(i)}
className={`flex-1 py-2 rounded-sm border text-center text-[10px] font-semibold font-korean cursor-pointer transition-colors ${stepCls(i)}`}
>
{label}
</button>
))}
</div>
{/* Selected Images */}
<div className="text-[11px] font-bold mb-2 font-korean"> (6)</div>
<div className="flex flex-col gap-1 mb-3.5">
{['여수항_드론_001.jpg', '여수항_드론_002.jpg', '여수항_드론_003.jpg', '여수항_드론_004.jpg', '여수항_드론_005.jpg', '여수항_드론_006.jpg'].map((name, i) => (
<div key={i} className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean">
<span>🛸</span>
<span className="flex-1 truncate">{name}</span>
<span className={`text-[9px] font-semibold ${
i < 4 ? 'text-status-green' : i === 4 ? 'text-status-orange' : 'text-text-3'
}`}>
{i < 4 ? '✓ 정합' : i === 4 ? '⏳ 정합중' : '대기'}
</span>
</div>
))}
</div>
{/* Analysis Parameters */}
<div className="text-[11px] font-bold mb-2 font-korean"> </div>
<div className="flex flex-col gap-1.5 mb-3.5">
{[
['촬영 고도', '120 m'],
['GSD (지상해상도)', '3.2 cm/px'],
['오버랩 비율', '80% / 70%'],
['좌표계', 'EPSG:5186'],
['유종 판별 기준', 'NDVI + NIR'],
['유막 두께 추정', 'Bonn Agreement'],
].map(([label, value], i) => (
<div key={i} className="flex justify-between items-center text-[11px]">
<span className="text-text-3 font-korean">{label}</span>
<span className="font-mono font-semibold">{value}</span>
</div>
))}
</div>
{/* Action Buttons */}
<button
onClick={handleAnalyze}
disabled={analyzing}
className={`w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none mb-2 transition-colors ${
analyzed
? 'bg-[rgba(34,197,94,0.15)] text-status-green border border-status-green'
: 'text-white'
}`}
style={!analyzed ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : undefined}
>
{analyzing ? '⏳ 분석중...' : analyzed ? '✅ 분석 완료!' : '🧩 면적분석 실행'}
</button>
<button className="w-full py-2.5 border border-border bg-bg-3 text-text-2 rounded-sm text-xs font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
📥 (GeoTIFF)
</button>
</div>
{/* Right Panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold font-korean">🗺 </span>
<div className="flex gap-1.5">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(239,68,68,0.1)] text-status-red font-semibold font-korean"> </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold font-korean"> </span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.1)] text-status-green font-semibold font-korean"> 96.2%</span>
</div>
</div>
{/* Image Grid 3×2 */}
<div className="grid grid-cols-3 gap-1.5 mb-3">
{mosaicImages.map(img => (
<div key={img.id} className="bg-bg-3 border border-border rounded-sm overflow-hidden cursor-pointer hover:border-border-light transition-colors">
<div
className="h-[100px] relative flex items-center justify-center overflow-hidden"
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
>
{img.hasOil && (
<div
className="absolute inset-0"
style={{
background: 'rgba(239,68,68,0.15)',
border: '1px solid rgba(239,68,68,0.35)',
clipPath: 'polygon(20% 30%,60% 15%,85% 40%,70% 80%,30% 75%,10% 50%)',
}}
/>
)}
<div className="text-lg font-bold text-white/[0.08] font-mono">{img.id}</div>
<div className={`absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded-md text-[9px] font-bold font-korean ${
img.status === 'done' && img.hasOil ? 'bg-[rgba(239,68,68,0.2)] text-status-red' :
img.status === 'processing' ? 'bg-[rgba(249,115,22,0.2)] text-status-orange' :
'bg-[rgba(100,116,139,0.2)] text-text-3'
}`}>
{img.status === 'done' && img.hasOil ? '유막' : img.status === 'processing' ? '정합중' : '대기'}
</div>
</div>
<div className="px-2 py-1.5 flex justify-between items-center text-[10px] font-korean text-text-2">
<span>{img.filename}</span>
<span className={
img.status === 'done' ? 'text-status-green' :
img.status === 'processing' ? 'text-status-orange' :
'text-text-3'
}>
{img.status === 'done' ? '✓' : img.status === 'processing' ? '⏳' : '—'}
</span>
</div>
</div>
))}
</div>
{/* Merged Result Preview */}
<div className="relative h-[140px] bg-bg-0 border border-border rounded-sm overflow-hidden mb-3">
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 40% 50%, rgba(10,25,40,0.7), rgba(8,14,26,0.95))' }}>
<div className="absolute border border-dashed rounded flex items-center justify-center text-[10px] font-korean" style={{ top: '15%', left: '10%', width: '65%', height: '70%', borderColor: 'rgba(6,182,212,0.3)', color: 'rgba(6,182,212,0.5)' }}>
(3×2 )
</div>
<div className="absolute" style={{ top: '22%', left: '18%', width: '35%', height: '40%', background: 'rgba(239,68,68,0.12)', border: '1.5px solid rgba(239,68,68,0.4)', borderRadius: '30% 50% 40% 60%' }} />
<div className="absolute" style={{ top: '40%', left: '38%', width: '20%', height: '30%', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '50% 30% 60% 40%' }} />
</div>
<div className="absolute bottom-1.5 left-2.5 text-[9px] text-text-3 font-mono">34.7312°N, 127.6845°E</div>
<div className="absolute bottom-1.5 right-2.5 text-[9px] text-text-3 font-mono"> 1:2,500</div>
</div>
{/* Analysis Results */}
<div className="p-4 bg-bg-3 border border-border rounded-md">
<div className="text-xs font-bold mb-2.5 font-korean">📊 </div>
<div className="grid grid-cols-3 gap-2">
{[
{ value: '0.42 km²', label: '유막 면적', color: 'text-status-red' },
{ value: '12.6 kL', label: '추정 유출량', color: 'text-status-orange' },
{ value: '1.84 km²', label: '합성 영역 면적', color: 'text-primary-cyan' },
].map((r, i) => (
<div key={i} className="text-center py-2.5 px-2 bg-bg-0 border border-border rounded-sm">
<div className={`text-lg font-bold font-mono ${r.color}`}>{r.value}</div>
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{r.label}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-1.5 mt-2.5 text-[11px]">
{[
['두꺼운 유막 (>1mm)', '0.08 km²', 'text-status-red'],
['얇은 유막 (<1mm)', '0.34 km²', 'text-status-orange'],
['무지개 빛깔', '0.12 km²', 'text-status-yellow'],
['Bonn 코드', 'Code 3~4', 'text-text-1'],
].map(([label, value, color], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
<span className="text-text-3 font-korean">{label}</span>
<span className={`font-semibold font-mono ${color}`}>{value}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}

파일 보기

@ -0,0 +1,252 @@
import { useState, useEffect } from 'react'
interface DroneInfo {
id: string
name: string
status: 'active' | 'returning' | 'standby' | 'charging'
battery: number
altitude: number
speed: number
sensor: string
color: string
}
const drones: DroneInfo[] = [
{ id: 'D-01', name: 'DJI M300 #1', status: 'active', battery: 78, altitude: 150, speed: 12, sensor: '광학 4K', color: 'var(--blue)' },
{ id: 'D-02', name: 'DJI M300 #2', status: 'active', battery: 65, altitude: 200, speed: 8, sensor: 'IR 열화상', color: 'var(--red)' },
{ id: 'D-03', name: 'Mavic 3E', status: 'active', battery: 82, altitude: 120, speed: 15, sensor: '광학 4K', color: 'var(--purple)' },
{ id: 'D-04', name: 'DJI M30T', status: 'active', battery: 45, altitude: 180, speed: 10, sensor: '다중센서', color: 'var(--green)' },
{ id: 'D-05', name: 'DJI M300 #3', status: 'returning', battery: 12, altitude: 80, speed: 18, sensor: '광학 4K', color: 'var(--orange)' },
{ id: 'D-06', name: 'Mavic 3E #2', status: 'charging', battery: 35, altitude: 0, speed: 0, sensor: '광학 4K', color: 'var(--t3)' },
]
interface AlertItem {
time: string
type: 'warning' | 'info' | 'danger'
message: string
}
const alerts: AlertItem[] = [
{ time: '15:42', type: 'danger', message: 'D-05 배터리 부족 — 자동 복귀' },
{ time: '15:38', type: 'warning', message: '오염원 신규 탐지 (34.82°N)' },
{ time: '15:35', type: 'info', message: 'D-01~D-03 다시점 융합 완료' },
{ time: '15:30', type: 'warning', message: 'AIS OFF 선박 2척 추가 탐지' },
{ time: '15:25', type: 'info', message: 'D-04 센서 데이터 수집 시작' },
{ time: '15:20', type: 'danger', message: '유류오염 확산 속도 증가 감지' },
{ time: '15:15', type: 'info', message: '3D 재구성 시작 (불명선박-B)' },
]
export function RealtimeDrone() {
const [reconProgress, setReconProgress] = useState(0)
const [reconDone, setReconDone] = useState(false)
const [selectedDrone, setSelectedDrone] = useState<string | null>(null)
useEffect(() => {
if (reconDone) return
const timer = setInterval(() => {
setReconProgress(prev => {
if (prev >= 100) {
clearInterval(timer)
setReconDone(true)
return 100
}
return prev + 2
})
}, 300)
return () => clearInterval(timer)
}, [reconDone])
const statusLabel = (s: string) => {
if (s === 'active') return { text: '비행중', cls: 'text-status-green' }
if (s === 'returning') return { text: '복귀중', cls: 'text-status-orange' }
if (s === 'charging') return { text: '충전중', cls: 'text-text-3' }
return { text: '대기', cls: 'text-text-3' }
}
const alertColor = (t: string) =>
t === 'danger' ? 'border-l-status-red bg-[rgba(239,68,68,0.05)]'
: t === 'warning' ? 'border-l-status-orange bg-[rgba(249,115,22,0.05)]'
: 'border-l-primary-blue bg-[rgba(59,130,246,0.05)]'
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* Map Area */}
<div className="flex-1 relative bg-bg-0 overflow-hidden">
{/* Simulated map background */}
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
{/* Grid lines */}
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.3) 1px, transparent 1px)', backgroundSize: '60px 60px' }} />
{/* Coastline hint */}
<div className="absolute" style={{ top: '20%', left: '5%', width: '40%', height: '60%', border: '1px solid rgba(34,197,94,0.15)', borderRadius: '40% 60% 50% 30%' }} />
{/* Drone position markers */}
{drones.filter(d => d.status !== 'charging').map((d, i) => (
<div
key={d.id}
className="absolute cursor-pointer"
style={{ top: `${25 + i * 12}%`, left: `${30 + i * 10}%` }}
onClick={() => setSelectedDrone(d.id)}
>
<div className="w-3 h-3 rounded-full animate-pulse-dot" style={{ background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
<div className="absolute -top-4 left-4 text-[8px] font-bold font-mono whitespace-nowrap" style={{ color: d.color }}>{d.id}</div>
</div>
))}
{/* Oil spill areas */}
<div className="absolute" style={{ top: '35%', left: '45%', width: '120px', height: '80px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '40% 60% 50% 40%' }} />
</div>
{/* Overlay Stats */}
<div className="absolute top-2.5 left-2.5 flex gap-1.5 z-[2]">
{[
{ label: '탐지 객체', value: '847', unit: '건', color: 'text-primary-blue' },
{ label: '식별 선박', value: '312', unit: '척', color: 'text-primary-cyan' },
{ label: 'AIS OFF', value: '14', unit: '척', color: 'text-status-red' },
{ label: '오염 탐지', value: '3', unit: '건', color: 'text-status-orange' },
].map((s, i) => (
<div key={i} className="bg-[rgba(15,21,36,0.9)] backdrop-blur-sm rounded-sm px-2.5 py-1.5 border border-border">
<div className="text-[7px] text-text-3">{s.label}</div>
<div>
<span className={`font-mono font-bold text-base ${s.color}`}>{s.value}</span>
<span className="text-[7px] text-text-3 ml-0.5">{s.unit}</span>
</div>
</div>
))}
</div>
{/* 3D Reconstruction Progress */}
<div className="absolute bottom-2.5 right-2.5 bg-[rgba(15,21,36,0.9)] rounded-sm px-3 py-2 border z-[3] min-w-[175px] cursor-pointer transition-colors hover:border-primary-cyan/40" style={{ borderColor: 'rgba(6,182,212,0.18)' }}>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-bold text-primary-cyan">🧊 3D </span>
<span className="font-mono font-bold text-[13px] text-primary-cyan">{reconProgress}%</span>
</div>
<div className="w-full h-[3px] bg-white/[0.06] rounded-sm mb-1">
<div className="h-full rounded-sm transition-all duration-500" style={{ width: `${reconProgress}%`, background: 'linear-gradient(90deg, var(--cyan), var(--blue))' }} />
</div>
{!reconDone ? (
<div className="text-[7px] text-text-3">D-01~D-03 ...</div>
) : (
<div className="text-[8px] font-bold text-status-green mt-0.5 animate-pulse-dot"> </div>
)}
</div>
{/* Live Feed Panel */}
{selectedDrone && (() => {
const drone = drones.find(d => d.id === selectedDrone)
if (!drone) return null
return (
<div className="absolute bottom-0 left-0 right-0 bg-[rgba(15,21,36,0.95)] z-[5] border-t" style={{ borderColor: 'rgba(59,130,246,0.2)', height: 190 }}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
<div className="text-[10px] font-bold flex items-center gap-1.5" style={{ color: drone.color }}>
<div className="w-1.5 h-1.5 rounded-full animate-pulse-dot" style={{ background: drone.color }} />
{drone.id}
</div>
<button onClick={() => setSelectedDrone(null)} className="w-5 h-5 rounded bg-white/5 border border-border text-text-3 text-[11px] flex items-center justify-center cursor-pointer hover:text-text-1"></button>
</div>
<div className="grid h-[calc(100%-30px)]" style={{ gridTemplateColumns: '1fr 180px' }}>
<div className="relative overflow-hidden" style={{ background: 'radial-gradient(ellipse at center, #0c1a2e, #060c18)' }}>
{/* Simulated video feed */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-text-3/20 text-2xl font-mono">LIVE FEED</div>
</div>
{/* HUD overlay */}
<div className="absolute top-1.5 left-2 z-[2]">
<span className="text-[11px] font-bold" style={{ color: drone.color }}>{drone.id}</span>
<span className="text-[7px] px-1 py-px rounded bg-white/[0.08] ml-1">{drone.sensor}</span>
<div className="text-[7px] text-text-3 font-mono mt-0.5">34.82°N, 128.95°E</div>
</div>
<div className="absolute top-1.5 right-2 z-[2] flex items-center gap-1 text-[8px] font-bold text-status-red">
<div className="w-1.5 h-1.5 rounded-full bg-status-red" />REC
</div>
<div className="absolute bottom-1 left-2 z-[2] text-[7px] text-text-3">
ALT {drone.altitude}m · SPD {drone.speed}m/s · HDG 045°
</div>
</div>
<div className="p-2 overflow-auto text-[9px] border-l border-border">
<div className="font-bold text-text-2 mb-1.5 font-korean"> </div>
{[
['드론 ID', drone.id],
['기체', drone.name],
['배터리', `${drone.battery}%`],
['고도', `${drone.altitude}m`],
['속도', `${drone.speed}m/s`],
['센서', drone.sensor],
['상태', statusLabel(drone.status).text],
].map(([k, v], i) => (
<div key={i} className="flex justify-between py-0.5">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-text-1">{v}</span>
</div>
))}
</div>
</div>
</div>
)
})()}
</div>
{/* Right Sidebar */}
<div className="w-[260px] bg-[rgba(15,21,36,0.88)] border-l border-border flex flex-col overflow-auto">
{/* Drone Swarm Status */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider"> · 4/6 </div>
<div className="flex flex-col gap-1">
{drones.map(d => {
const st = statusLabel(d.status)
return (
<div
key={d.id}
onClick={() => d.status !== 'charging' && setSelectedDrone(d.id)}
className={`flex items-center gap-2 px-2 py-1.5 rounded-sm cursor-pointer transition-colors ${
selectedDrone === d.id ? 'bg-[rgba(6,182,212,0.08)] border border-primary-cyan/20' : 'hover:bg-white/[0.02] border border-transparent'
}`}
>
<div className="w-2 h-2 rounded-full" style={{ background: d.color }} />
<div className="flex-1 min-w-0">
<div className="text-[9px] font-bold" style={{ color: d.color }}>{d.id}</div>
<div className="text-[7px] text-text-3 truncate">{d.name}</div>
</div>
<div className="text-right">
<div className={`text-[8px] font-semibold ${st.cls}`}>{st.text}</div>
<div className="text-[7px] font-mono text-text-3">{d.battery}%</div>
</div>
</div>
)
})}
</div>
</div>
{/* Multi-Angle Analysis */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider"> </div>
<div className="grid grid-cols-2 gap-1">
{[
{ icon: '🎯', label: '다시점 융합', value: '28건', sub: '360° 식별' },
{ icon: '🧊', label: '3D 재구성', value: '12건', sub: '선박+오염원' },
{ icon: '📡', label: '다센서 융합', value: '45건', sub: '광학+IR+SAR' },
{ icon: '🛢️', label: '오염원 3D', value: '3건', sub: '유류+HNS' },
].map((a, i) => (
<div key={i} className="bg-white/[0.02] rounded-sm px-1.5 py-1.5 border border-white/[0.03]">
<div className="text-[10px] mb-px">{a.icon}</div>
<div className="text-[7px] text-text-3">{a.label}</div>
<div className="text-xs font-bold font-mono text-primary-cyan my-px">{a.value}</div>
<div className="text-[6px] text-text-3">{a.sub}</div>
</div>
))}
</div>
</div>
{/* Real-time Alerts */}
<div className="p-2.5 px-3 flex-1 overflow-auto">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider"> </div>
<div className="flex flex-col gap-1">
{alerts.map((a, i) => (
<div key={i} className={`px-2 py-1.5 border-l-2 rounded-sm text-[9px] font-korean ${alertColor(a.type)}`}>
<span className="font-mono text-text-3 mr-1.5">{a.time}</span>
<span className="text-text-2">{a.message}</span>
</div>
))}
</div>
</div>
</div>
</div>
)
}

파일 보기

@ -0,0 +1,787 @@
import { useState, useRef, useEffect } from 'react'
interface SatRequest {
id: string
zone: string
zoneCoord: string
zoneArea: string
satellite: string
requestDate: string
expectedReceive: string
resolution: string
status: '촬영중' | '대기' | '완료'
provider?: string
purpose?: string
requester?: string
}
const satRequests: SatRequest[] = [
{ id: 'SAT-004', zone: '제주 서귀포 해상 (유출 해역 중심)', zoneCoord: '33.24°N 126.50°E', zoneArea: '15km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 08:14', expectedReceive: '02-20 14:30', resolution: '0.5m', status: '촬영중', provider: 'KARI', purpose: '유출유 확산 모니터링', requester: '방제과 김해양' },
{ id: 'SAT-005', zone: '가파도 북쪽 해안선', zoneCoord: '33.17°N 126.27°E', zoneArea: '8km²', satellite: 'KOMPSAT-3', requestDate: '02-20 09:02', expectedReceive: '02-21 09:00', resolution: '1.0m', status: '대기', provider: 'KARI', purpose: '해안선 오염 확인', requester: '방제과 이민수' },
{ id: 'SAT-006', zone: '마라도 주변 해역', zoneCoord: '33.11°N 126.27°E', zoneArea: '12km²', satellite: 'Sentinel-2', requestDate: '02-20 09:30', expectedReceive: '02-21 11:00', resolution: '10m', status: '대기', provider: 'ESA Copernicus', purpose: '수질 분석용 다분광 촬영', requester: '환경분석팀 박수진' },
{ id: 'SAT-007', zone: '대정읍 해안 오염 확산 구역', zoneCoord: '33.21°N 126.10°E', zoneArea: '20km²', satellite: 'KOMPSAT-3A', requestDate: '02-20 10:05', expectedReceive: '02-22 08:00', resolution: '0.5m', status: '대기', provider: 'KARI', purpose: '확산 예측 모델 검증', requester: '방제과 김해양' },
{ id: 'SAT-003', zone: '제주 남방 100해리 해상', zoneCoord: '33.00°N 126.50°E', zoneArea: '25km²', satellite: 'Sentinel-1', requestDate: '02-19 14:00', expectedReceive: '02-19 23:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: 'SAR 유막 탐지', requester: '환경분석팀 박수진' },
{ id: 'SAT-002', zone: '여수 오동도 인근 해역', zoneCoord: '34.73°N 127.68°E', zoneArea: '18km²', satellite: 'KOMPSAT-3A', requestDate: '02-18 11:30', expectedReceive: '02-18 17:45', resolution: '0.5m', status: '완료', provider: 'KARI', purpose: '유출 초기 범위 확인', requester: '방제과 김해양' },
{ id: 'SAT-001', zone: '통영 해역 남측', zoneCoord: '34.85°N 128.43°E', zoneArea: '30km²', satellite: 'Sentinel-1', requestDate: '02-17 09:00', expectedReceive: '02-17 21:00', resolution: '20m', status: '완료', provider: 'ESA Copernicus', purpose: '야간 SAR 유막 모니터링', requester: '환경분석팀 박수진' },
]
const satellites = [
{ name: 'KOMPSAT-3A', desc: '해상도 0.5m · 광학 / IR · 촬영 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'rgba(34,197,94,.2)', pulse: true },
{ name: 'KOMPSAT-3', desc: '해상도 1.0m · 광학 · 임무 중', status: '임무중', statusColor: 'var(--yellow)', borderColor: 'rgba(234,179,8,.2)', pulse: true },
{ name: 'Sentinel-1 (ESA)', desc: '해상도 20m · SAR · 야간/우천 가능', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
{ name: 'Sentinel-2 (ESA)', desc: '해상도 10m · 다분광 · 수질 분석 적합', status: '가용', statusColor: 'var(--green)', borderColor: 'var(--bd)', pulse: false },
]
const passSchedules = [
{ time: '14:10 14:24', desc: 'KOMPSAT-3A 패스 (제주 남방)', today: true },
{ time: '16:55 17:08', desc: 'Sentinel-1 패스 (제주 전역)', today: true },
{ time: '내일 09:12', desc: 'KOMPSAT-3 패스 (가파도~마라도)', today: false },
{ time: '내일 10:40', desc: 'Sentinel-2 패스 (제주 서측)', today: false },
]
// UP42 위성 카탈로그 데이터
const up42Satellites = [
{ id: 'mwl-hd15', name: 'Maxar WorldView Legion HD15', res: '0.3m', type: 'optical' as const, color: '#3b82f6', cloud: 15 },
{ id: 'pneo-hd15', name: 'Pléiades Neo HD15', res: '0.3m', type: 'optical' as const, color: '#06b6d4', cloud: 10 },
{ id: 'mwl', name: 'Maxar WorldView Legion', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
{ id: 'mwv3', name: 'Maxar WorldView-3', res: '0.5m', type: 'optical' as const, color: '#3b82f6', cloud: 20 },
{ id: 'pneo', name: 'Pléiades Neo', res: '0.5m', type: 'optical' as const, color: '#06b6d4', cloud: 15 },
{ id: 'bj3n', name: 'Beijing-3N', res: '0.5m', type: 'optical' as const, color: '#f97316', cloud: 20, delay: true },
{ id: 'skysat', name: 'SkySat', res: '0.7m', type: 'optical' as const, color: '#22c55e', cloud: 15 },
{ id: 'kmp3a', name: 'KOMPSAT-3A', res: '0.5m', type: 'optical' as const, color: '#a855f7', cloud: 10 },
{ id: 'kmp3', name: 'KOMPSAT-3', res: '1.0m', type: 'optical' as const, color: '#a855f7', cloud: 15 },
{ id: 'spot7', name: 'SPOT 7', res: '1.5m', type: 'optical' as const, color: '#eab308', cloud: 20 },
{ id: 's2', name: 'Sentinel-2', res: '10m', type: 'optical' as const, color: '#ec4899', cloud: 20 },
{ id: 's1', name: 'Sentinel-1 SAR', res: '20m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
{ id: 'alos2', name: 'ALOS-2 PALSAR-2', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
{ id: 'rcm', name: 'RCM (Radarsat)', res: '3m', type: 'sar' as const, color: '#f59e0b', cloud: 0 },
{ id: 'srtm', name: 'SRTM DEM', res: '30m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
{ id: 'cop-dem', name: 'Copernicus DEM', res: '10m', type: 'elevation' as const, color: '#64748b', cloud: 0 },
]
const up42Passes = [
{ sat: 'KOMPSAT-3A', time: '오늘 14:1014:24', res: '0.5m', cloud: '≤10%', note: '최우선 추천', color: '#a855f7' },
{ sat: 'Pléiades Neo', time: '오늘 14:3814:52', res: '0.3m', cloud: '≤15%', note: '초고해상도', color: '#06b6d4' },
{ sat: 'Sentinel-1 SAR', time: '오늘 16:5517:08', res: '20m', cloud: '야간/우천 가능', note: 'SAR', color: '#f59e0b' },
{ sat: 'KOMPSAT-3', time: '내일 09:12', res: '1.0m', cloud: '≤15%', note: '', color: '#a855f7' },
{ sat: 'Maxar WV-3', time: '내일 13:20', res: '0.5m', cloud: '≤20%', note: '', color: '#3b82f6' },
]
type SatModalPhase = 'none' | 'provider' | 'blacksky' | 'up42'
export function SatelliteRequest() {
const [statusFilter, setStatusFilter] = useState('전체')
const [modalPhase, setModalPhase] = useState<SatModalPhase>('none')
const [selectedRequest, setSelectedRequest] = useState<SatRequest | null>(null)
const [showMoreCompleted, setShowMoreCompleted] = useState(false)
// UP42 sub-tab
const [up42SubTab, setUp42SubTab] = useState<'optical' | 'sar' | 'elevation'>('optical')
const [up42SelSat, setUp42SelSat] = useState<string | null>(null)
const [up42SelPass, setUp42SelPass] = useState<number | null>(null)
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
setModalPhase('none')
}
}
if (modalPhase !== 'none') document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [modalPhase])
const allRequests = showMoreCompleted ? satRequests : satRequests.filter(r => r.status !== '완료' || r.id === 'SAT-003')
const filtered = allRequests.filter(r => {
if (statusFilter === '전체') return true
if (statusFilter === '대기') return r.status === '대기'
if (statusFilter === '진행') return r.status === '촬영중'
if (statusFilter === '완료') return r.status === '완료'
return true
})
const statusBadge = (s: SatRequest['status']) => {
if (s === '촬영중') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.15)', border: '1px solid rgba(234,179,8,.3)', color: 'var(--yellow)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--yellow)' }} />
</span>
)
if (s === '대기') return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(59,130,246,.15)', border: '1px solid rgba(59,130,246,.3)', color: 'var(--blue)' }}> </span>
)
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: 'var(--green)' }}> </span>
)
}
const stats = [
{ value: '3', label: '요청 대기', color: 'var(--blue)' },
{ value: '1', label: '촬영 진행 중', color: 'var(--yellow)' },
{ value: '7', label: '수신 완료', color: 'var(--green)' },
{ value: '0.5m', label: '최고 해상도', color: 'var(--cyan)' },
]
const filters = ['전체', '대기', '진행', '완료']
const up42Filtered = up42Satellites.filter(s => s.type === up42SubTab)
// ── 섹션 헤더 헬퍼 (BlackSky 폼) ──
const sectionHeader = (num: number, label: string) => (
<div className="text-[11px] font-bold font-korean mb-2.5 flex items-center gap-1.5" style={{ color: '#818cf8' }}>
<div className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold" style={{ background: 'rgba(99,102,241,.12)', color: '#818cf8' }}>{num}</div>
{label}
</div>
)
const bsInput = "w-full px-3 py-2 rounded-md text-[11px] font-korean outline-none box-border"
const bsInputStyle = { border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }
return (
<div className="overflow-y-auto" style={{ padding: '20px 24px' }}>
{/* 헤더 */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[10px] flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(59,130,246,.2),rgba(168,85,247,.2))', border: '1px solid rgba(59,130,246,.3)' }}>🛰</div>
<div>
<div className="text-base font-bold font-korean text-text-1"> </div>
<div className="text-[11px] text-text-3 font-korean mt-0.5"> </div>
</div>
</div>
<button onClick={() => setModalPhase('provider')} className="px-4 py-2.5 text-white border-none rounded-sm text-[13px] font-semibold cursor-pointer font-korean flex items-center gap-1.5" style={{ background: 'linear-gradient(135deg,var(--blue),var(--purple))' }}>🛰 </button>
</div>
{/* 요약 통계 */}
<div className="grid grid-cols-4 gap-3 mb-5">
{stats.map((s, i) => (
<div key={i} className="bg-bg-2 border border-border rounded-md p-3.5 text-center">
<div className="text-[22px] font-bold font-mono" style={{ color: s.color }}>{s.value}</div>
<div className="text-[10px] text-text-3 mt-1 font-korean">{s.label}</div>
</div>
))}
</div>
{/* 요청 목록 */}
<div className="bg-bg-2 border border-border rounded-md overflow-hidden mb-5">
<div className="flex items-center justify-between px-4 py-3.5 border-b border-border">
<div className="text-[13px] font-bold font-korean text-text-1">📋 </div>
<div className="flex gap-1.5">
{filters.map(f => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className="px-2.5 py-1 rounded text-[10px] font-semibold cursor-pointer font-korean border"
style={statusFilter === f
? { background: 'rgba(59,130,246,.15)', color: 'var(--blue)', borderColor: 'rgba(59,130,246,.3)' }
: { background: 'var(--bg3)', color: 'var(--t2)', borderColor: 'var(--bd)' }
}
>{f}</button>
))}
</div>
</div>
{/* 헤더 행 */}
<div className="grid gap-0 px-4 py-2 bg-bg-3 border-b border-border" style={{ gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px' }}>
{['번호', '촬영 구역', '위성', '요청일시', '예상수신', '해상도', '상태'].map(h => (
<div key={h} className="text-[9px] font-bold text-text-3 font-korean uppercase tracking-wider">{h}</div>
))}
</div>
{/* 데이터 행 */}
{filtered.map(r => (
<div key={r.id}>
<div
onClick={() => setSelectedRequest(selectedRequest?.id === r.id ? null : r)}
className="grid gap-0 px-4 py-3 border-b items-center cursor-pointer hover:bg-bg-hover/30 transition-colors"
style={{
gridTemplateColumns: '60px 1fr 100px 100px 120px 80px 90px',
borderColor: 'rgba(255,255,255,.04)',
background: selectedRequest?.id === r.id ? 'rgba(99,102,241,.06)' : r.status === '촬영중' ? 'rgba(234,179,8,.03)' : 'transparent',
opacity: r.status === '완료' ? 0.7 : 1,
}}
>
<div className="text-[11px] font-mono text-text-2">{r.id}</div>
<div>
<div className="text-xs font-semibold text-text-1 font-korean">{r.zone}</div>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{r.zoneCoord} · {r.zoneArea}</div>
</div>
<div className="text-[11px] font-semibold text-text-1 font-korean">{r.satellite}</div>
<div className="text-[10px] text-text-2 font-mono">{r.requestDate}</div>
<div className="text-[10px] font-semibold font-mono" style={{ color: r.status === '촬영중' ? 'var(--yellow)' : 'var(--t2)' }}>{r.expectedReceive}</div>
<div className="text-[11px] font-bold font-mono" style={{ color: r.status === '완료' ? 'var(--t3)' : 'var(--cyan)' }}>{r.resolution}</div>
<div>{statusBadge(r.status)}</div>
</div>
{/* 상세 정보 패널 */}
{selectedRequest?.id === r.id && (
<div className="px-4 py-3 border-b" style={{ borderColor: 'rgba(255,255,255,.04)', background: 'rgba(99,102,241,.03)' }}>
<div className="grid grid-cols-4 gap-3 mb-2">
{[
['제공자', r.provider || '-'],
['요청 목적', r.purpose || '-'],
['요청자', r.requester || '-'],
['촬영 면적', r.zoneArea],
].map(([k, v], i) => (
<div key={i} className="px-2.5 py-2 bg-bg-0 rounded">
<div className="text-[8px] font-bold text-text-3 font-korean mb-1 uppercase">{k}</div>
<div className="text-[10px] font-semibold text-text-1 font-korean">{v}</div>
</div>
))}
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(6,182,212,.08)', borderColor: 'rgba(6,182,212,.2)', color: 'var(--cyan)' }}>📍 </button>
{r.status === '완료' && (
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(34,197,94,.08)', borderColor: 'rgba(34,197,94,.2)', color: 'var(--green)' }}>📥 </button>
)}
{r.status === '대기' && (
<button className="px-3 py-1.5 text-[10px] font-semibold font-korean rounded border cursor-pointer hover:bg-bg-hover transition-colors" style={{ background: 'rgba(239,68,68,.08)', borderColor: 'rgba(239,68,68,.2)', color: 'var(--red)' }}> </button>
)}
</div>
</div>
)}
</div>
))}
<div
onClick={() => setShowMoreCompleted(!showMoreCompleted)}
className="text-center py-2.5 text-[10px] text-text-3 font-korean cursor-pointer hover:text-text-2 transition-colors"
>
{showMoreCompleted ? '▲ 완료 목록 접기' : '▼ 이전 완료 목록 더보기 (6건)'}
</div>
</div>
{/* 위성 궤도 정보 */}
<div className="grid grid-cols-2 gap-3.5">
{/* 가용 위성 현황 */}
<div className="bg-bg-2 border border-border rounded-md p-4">
<div className="text-xs font-bold text-text-1 font-korean mb-3">🛰 </div>
<div className="flex flex-col gap-2">
{satellites.map((sat, i) => (
<div key={i} className="flex items-center gap-2.5 px-3 py-2 bg-bg-3 rounded-md" style={{ border: `1px solid ${sat.borderColor}` }}>
<div className={`w-2 h-2 rounded-full shrink-0 ${sat.pulse ? 'animate-pulse' : ''}`} style={{ background: sat.statusColor }} />
<div className="flex-1">
<div className="text-[11px] font-semibold text-text-1 font-korean">{sat.name}</div>
<div className="text-[9px] text-text-3 font-korean">{sat.desc}</div>
</div>
<div className="text-[10px] font-bold font-korean" style={{ color: sat.statusColor }}>{sat.status}</div>
</div>
))}
</div>
</div>
{/* 오늘 촬영 가능 시간 */}
<div className="bg-bg-2 border border-border rounded-md p-4">
<div className="text-xs font-bold text-text-1 font-korean mb-3"> (KST)</div>
<div className="flex flex-col gap-1.5">
{passSchedules.map((ps, i) => (
<div
key={i}
className="flex items-center gap-2 px-2.5 py-[7px] rounded-[5px]"
style={{
background: ps.today ? 'rgba(34,197,94,.05)' : 'rgba(59,130,246,.05)',
border: ps.today ? '1px solid rgba(34,197,94,.15)' : '1px solid rgba(59,130,246,.15)',
}}
>
<span className="text-[10px] font-bold font-mono min-w-[90px]" style={{ color: ps.today ? 'var(--cyan)' : 'var(--blue)' }}>{ps.time}</span>
<span className="text-[10px] text-text-1 font-korean">{ps.desc}</span>
</div>
))}
</div>
</div>
</div>
{/* ═══ 모달: 제공자 선택 ═══ */}
{modalPhase !== 'none' && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(5,8,18,.75)', backdropFilter: 'blur(8px)' }}>
<div ref={modalRef}>
{/* ── 제공자 선택 ── */}
{modalPhase === 'provider' && (
<div className="border rounded-2xl w-[640px] overflow-hidden" style={{ background: 'var(--bg1)', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}>
{/* 헤더 */}
<div className="px-7 pt-6 pb-4 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#3b82f6,#06b6d4)' }} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg,rgba(99,102,241,.15),rgba(59,130,246,.08))' }}>🛰</div>
<div>
<div className="text-base font-bold text-text-1 font-korean"> </div>
<div className="text-[10px] text-text-3 font-korean mt-0.5"> </div>
</div>
</div>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer text-text-3 p-1 bg-transparent border-none hover:text-text-1 transition-colors"></button>
</div>
</div>
{/* 제공자 카드 */}
<div className="px-7 pb-6 flex flex-col gap-3.5">
{/* BlackSky (Maxar) */}
<div
onClick={() => setModalPhase('blacksky')}
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(99,102,241,.5)] hover:bg-[rgba(99,102,241,.04)] transition-all"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
<span className="text-[11px] font-extrabold font-mono" style={{ color: '#818cf8', letterSpacing: '-.5px' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
</div>
<div>
<div className="text-sm font-bold text-text-1 font-korean">BlackSky</div>
<div className="text-[9px] text-text-3 font-korean mt-px">Maxar Electro-Optical API</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API </span>
<span className="text-base text-text-3"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO (광학)', '#818cf8'],
['해상도', '~1m', 'var(--t1)'],
['재방문', '≤1시간', 'var(--t1)'],
['납기', '90분 이내', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
</div>
))}
</div>
<div className="text-[9px] text-text-3 font-korean leading-relaxed"> . . Dawn-to-Dusk .</div>
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#818cf8' }}>eapi.maxar.com/e1so/rapidoc</span></div>
</div>
{/* UP42 (EO + SAR) */}
<div
onClick={() => setModalPhase('up42')}
className="cursor-pointer bg-bg-2 border border-border rounded-xl p-5 relative overflow-hidden hover:border-[rgba(59,130,246,.5)] hover:bg-[rgba(59,130,246,.04)] transition-all"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-[42px] h-[42px] rounded-[10px] flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
</div>
<div>
<div className="text-sm font-bold text-text-1 font-korean">UP42 EO + SAR</div>
<div className="text-[9px] text-text-3 font-korean mt-px">Optical · SAR · Elevation </div>
</div>
</div>
<div className="flex items-center gap-1.5">
<span className="px-2 py-0.5 rounded-[10px] text-[8px] font-semibold" style={{ background: 'rgba(34,197,94,.1)', border: '1px solid rgba(34,197,94,.2)', color: '#22c55e' }}>API </span>
<span className="text-base text-text-3"></span>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-2.5">
{[
['유형', 'EO + SAR', '#60a5fa'],
['해상도', '0.3~5m', 'var(--t1)'],
['위성 수', '16+ 컬렉션', 'var(--t1)'],
['야간/악천후', 'SAR 가능', '#22c55e'],
].map(([k, v, c], i) => (
<div key={i} className="text-center p-1.5 bg-bg-0 rounded-md">
<div className="text-[7px] text-text-3 font-korean mb-0.5">{k}</div>
<div className="text-[10px] font-bold font-mono" style={{ color: c }}>{v}</div>
</div>
))}
</div>
<div className="flex gap-1.5 mb-2.5 flex-wrap">
{['Pléiades Neo', 'SPOT 6/7'].map((t, i) => (
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(59,130,246,.08)', border: '1px solid rgba(59,130,246,.15)', color: '#60a5fa' }}>{t}</span>
))}
{['TerraSAR-X', 'Capella SAR', 'ICEYE'].map((t, i) => (
<span key={i} className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.15)', color: 'var(--cyan)' }}>{t}</span>
))}
<span className="px-1.5 py-px rounded text-[8px] font-korean" style={{ background: 'rgba(139,148,158,.08)', border: '1px solid rgba(139,148,158,.15)', color: 'var(--t3)' }}>+11 more</span>
</div>
<div className="text-[9px] text-text-3 font-korean leading-relaxed">(EO) + (SAR) . · SAR . .</div>
<div className="mt-2 text-[8px] text-text-3 font-mono">API: <span style={{ color: '#60a5fa' }}>up42.com</span></div>
</div>
</div>
{/* 하단 */}
<div className="px-7 pb-5 flex items-center justify-between">
<div className="text-[9px] text-text-3 font-korean leading-relaxed">💡 촬영: BlackSky (90 ) · /악천후: UP42 SAR </div>
<button onClick={() => setModalPhase('none')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: 'var(--bd)', background: 'var(--bg3)', color: 'var(--t2)' }}></button>
</div>
</div>
)}
{/* ── BlackSky 긴급 촬영 요청 ── */}
{modalPhase === 'blacksky' && (
<div className="border rounded-[14px] w-[860px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(99,102,241,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
{/* 헤더 */}
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#6366f1,#818cf8,#a78bfa)' }} />
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#1a1a2e,#16213e)', borderColor: 'rgba(99,102,241,.3)' }}>
<span className="text-[10px] font-extrabold font-mono" style={{ color: '#818cf8' }}>B<span style={{ color: '#a78bfa' }}>Sky</span></span>
</div>
<div>
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}>BlackSky </div>
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}>Maxar E1SO RapiDoc API · </div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-md text-[9px] font-semibold font-korean" style={{ background: 'rgba(99,102,241,.1)', border: '1px solid rgba(99,102,241,.25)', color: '#818cf8' }}>API Docs </span>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}></button>
</div>
</div>
{/* 본문 */}
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
{/* API 상태 */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg" style={{ background: 'rgba(34,197,94,.06)', border: '1px solid rgba(34,197,94,.15)' }}>
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e', boxShadow: '0 0 6px rgba(34,197,94,.5)' }} />
<span className="text-[10px] font-semibold font-korean" style={{ color: '#22c55e' }}>API Connected</span>
<span className="text-[9px] font-mono" style={{ color: '#64748b' }}>eapi.maxar.com/e1so/rapidoc · Latency: 142ms</span>
<span className="ml-auto text-[8px] font-mono" style={{ color: '#64748b' }}>Quota: 47/50 </span>
</div>
{/* ① 태스킹 유형 */}
<div>
{sectionHeader(1, '태스킹 유형 · 우선순위')}
<div className="grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option> (Emergency)</option>
<option> (Standard)</option>
<option> (Archive)</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option>P1 (90 )</option>
<option>P2 (6 )</option>
<option>P3 (24 )</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> </label>
<select className={bsInput} style={bsInputStyle}>
<option>Single Collect</option>
<option>Multi-pass Monitoring</option>
<option>Continuous ( )</option>
</select>
</div>
</div>
</div>
{/* ② AOI 지정 */}
<div>
{sectionHeader(2, '관심 영역 (AOI)')}
<div className="grid grid-cols-3 gap-2.5 items-end">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<input type="text" defaultValue="34.5832" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<input type="text" defaultValue="128.4217" className={bsInput} style={bsInputStyle} />
</div>
<button className="px-3.5 py-2 rounded-md text-[10px] font-semibold cursor-pointer font-korean whitespace-nowrap" style={{ border: '1px solid rgba(99,102,241,.3)', background: 'rgba(99,102,241,.08)', color: '#818cf8' }}>📍 AOI </button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}>AOI (km)</label>
<input type="number" defaultValue={10} step={1} min={1} className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> (%)</label>
<input type="number" defaultValue={20} step={5} min={0} max={100} className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> Off-nadir (°)</label>
<input type="number" defaultValue={25} step={5} min={0} max={45} className={bsInput} style={bsInputStyle} />
</div>
</div>
</div>
{/* ③ 촬영 기간 */}
<div>
{sectionHeader(3, '촬영 기간 · 반복')}
<div className="grid grid-cols-3 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<input type="datetime-local" defaultValue="2026-02-26T08:00" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<input type="datetime-local" defaultValue="2026-02-27T20:00" className={bsInput} style={bsInputStyle} />
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> </label>
<select className={bsInput} style={bsInputStyle}>
<option>1 ()</option>
<option> ( )</option>
<option> 6</option>
<option> 12</option>
<option> 1</option>
</select>
</div>
</div>
</div>
{/* ④ 산출물 설정 */}
<div>
{sectionHeader(4, '산출물 설정')}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> <span style={{ color: '#f87171' }}>*</span></label>
<select className={bsInput} style={bsInputStyle}>
<option>Ortho-Rectified ()</option>
<option>Pan-sharpened ()</option>
<option>Basic L1 ()</option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> </label>
<select className={bsInput} style={bsInputStyle}>
<option>GeoTIFF</option>
<option>NITF</option>
<option>JPEG2000</option>
</select>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-3">
{[
{ label: '유출유 탐지 분석 (자동)', checked: true },
{ label: 'GIS 상황판 자동 오버레이', checked: true },
{ label: '변화탐지 (Change Detection)', checked: false },
{ label: '웹훅 알림', checked: false },
].map((opt, i) => (
<label key={i} className="flex items-center gap-1 text-[9px] cursor-pointer font-korean" style={{ color: '#94a3b8' }}>
<input type="checkbox" defaultChecked={opt.checked} style={{ accentColor: '#818cf8', transform: 'scale(.85)' }} /> {opt.label}
</label>
))}
</div>
</div>
{/* ⑤ 연계 사고 · 비고 */}
<div>
{sectionHeader(5, '연계 사고 · 비고')}
<div className="grid grid-cols-2 gap-2.5 mb-2">
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}> </label>
<select className={bsInput} style={bsInputStyle}>
<option>OIL-2024-0892 · M/V STELLAR DAISY</option>
<option>HNS-2024-041 · </option>
<option>RSC-2024-0127 · M/V SEA GUARDIAN</option>
<option value=""> </option>
</select>
</div>
<div>
<label className="block text-[9px] font-korean mb-1" style={{ color: '#64748b' }}></label>
<input type="text" placeholder="소속 / 이름" className={bsInput} style={bsInputStyle} />
</div>
</div>
<textarea
placeholder="촬영 요청 목적, 특이사항, 관심 대상 등을 기록합니다..."
className="w-full h-[50px] px-3 py-2.5 rounded-md text-[10px] font-korean outline-none resize-y leading-relaxed box-border"
style={{ border: '1px solid #21262d', background: '#161b22', color: '#e2e8f0' }}
/>
</div>
</div>
{/* 하단 버튼 */}
<div className="px-6 py-3.5 border-t flex items-center gap-2 shrink-0" style={{ borderColor: '#21262d' }}>
<div className="flex-1 text-[9px] font-korean leading-relaxed" style={{ color: '#64748b' }}>
<span style={{ color: '#f87171' }}>*</span> · P1 90
</div>
<button onClick={() => setModalPhase('provider')} className="px-5 py-2.5 rounded-lg border text-xs font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}> </button>
<button onClick={() => setModalPhase('none')} className="px-7 py-2.5 rounded-lg border-none text-xs font-bold cursor-pointer font-korean text-white" style={{ background: 'linear-gradient(135deg,#6366f1,#818cf8)', boxShadow: '0 4px 16px rgba(99,102,241,.35)' }}>🛰 BlackSky </button>
</div>
</div>
)}
{/* ── UP42 카탈로그 주문 ── */}
{modalPhase === 'up42' && (
<div className="border rounded-[14px] w-[920px] max-h-[90vh] flex flex-col overflow-hidden" style={{ background: '#0d1117', borderColor: 'rgba(59,130,246,.3)', boxShadow: '0 24px 80px rgba(0,0,0,.7)' }}>
{/* 헤더 */}
<div className="px-6 py-4 border-b flex items-center justify-between shrink-0 relative" style={{ borderColor: '#21262d' }}>
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ background: 'linear-gradient(90deg,#3b82f6,#06b6d4,#22c55e)' }} />
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border" style={{ background: 'linear-gradient(135deg,#0a1628,#162a50)', borderColor: 'rgba(59,130,246,.3)' }}>
<span className="text-[13px] font-extrabold font-mono" style={{ color: '#60a5fa', letterSpacing: '-.5px' }}>up<sup className="text-[7px] align-super">42</sup></span>
</div>
<div>
<div className="text-[15px] font-bold font-korean" style={{ color: '#e2e8f0' }}> </div>
<div className="text-[9px] font-korean mt-0.5" style={{ color: '#64748b' }}> (AOI) </div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full text-[10px] font-semibold font-korean" style={{ background: 'rgba(234,179,8,.1)', border: '1px solid rgba(234,179,8,.25)', color: '#eab308' }}> Beijing-3N 2.152.23</span>
<button onClick={() => setModalPhase('none')} className="text-lg cursor-pointer p-1 bg-transparent border-none" style={{ color: '#64748b' }}></button>
</div>
</div>
{/* 본문 (좌: 사이드바, 우: 지도+AOI) */}
<div className="flex-1 flex overflow-hidden">
{/* 왼쪽: 위성 카탈로그 */}
<div className="flex flex-col overflow-hidden border-r" style={{ width: 320, minWidth: 320, borderColor: '#21262d', background: '#0d1117' }}>
{/* Optical / SAR / Elevation 탭 */}
<div className="flex border-b shrink-0" style={{ borderColor: '#21262d' }}>
{(['optical', 'sar', 'elevation'] as const).map(t => (
<button
key={t}
onClick={() => setUp42SubTab(t)}
className="flex-1 py-2 text-[10px] font-bold cursor-pointer border-none font-korean transition-colors"
style={up42SubTab === t
? { background: 'rgba(59,130,246,.1)', color: '#60a5fa', borderBottom: '2px solid #3b82f6' }
: { background: 'transparent', color: '#64748b' }
}
>{t === 'optical' ? 'Optical' : t === 'sar' ? 'SAR' : 'Elevation'}</button>
))}
</div>
{/* 필터 바 */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b shrink-0" style={{ borderColor: '#21262d' }}>
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(59,130,246,.1)', color: '#60a5fa', border: '1px solid rgba(59,130,246,.2)' }}>Filters </span>
<span className="px-2 py-0.5 rounded text-[9px] font-semibold" style={{ background: 'rgba(99,102,241,.1)', color: '#818cf8', border: '1px solid rgba(99,102,241,.2)' }}> 20% </span>
<span className="ml-auto text-[9px] font-mono" style={{ color: '#64748b' }}> </span>
</div>
{/* 컬렉션 수 */}
<div className="px-3 py-1.5 border-b text-[9px] font-korean shrink-0" style={{ borderColor: '#21262d', color: '#64748b' }}>
<b style={{ color: '#e2e8f0' }}>{up42Filtered.length}</b>
</div>
{/* 위성 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: '#21262d transparent' }}>
{up42Filtered.map(sat => (
<div
key={sat.id}
onClick={() => setUp42SelSat(up42SelSat === sat.id ? null : sat.id)}
className="flex items-center gap-2.5 px-3 py-2.5 border-b cursor-pointer transition-colors"
style={{
borderColor: '#161b22',
background: up42SelSat === sat.id ? 'rgba(59,130,246,.08)' : 'transparent',
}}
>
<div className="w-1 h-8 rounded-full shrink-0" style={{ background: sat.color }} />
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold truncate font-korean" style={{ color: '#e2e8f0' }}>{sat.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-bold font-mono" style={{ color: sat.color }}>{sat.res}</span>
{sat.cloud > 0 && <span className="text-[8px] font-mono" style={{ color: '#64748b' }}> {sat.cloud}%</span>}
{'delay' in sat && sat.delay && <span className="text-[8px] font-bold" style={{ color: '#eab308' }}> </span>}
</div>
</div>
{up42SelSat === sat.id && <span className="text-[12px]" style={{ color: '#3b82f6' }}></span>}
</div>
))}
</div>
</div>
{/* 오른쪽: 지도 + AOI + 패스 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* 지도 영역 (placeholder) */}
<div className="flex-1 relative" style={{ background: '#0a0e18' }}>
{/* 검색바 */}
<div className="absolute top-3 left-3 right-3 flex items-center gap-2 px-3 py-2 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d', backdropFilter: 'blur(8px)' }}>
<span style={{ color: '#8690a6', fontSize: 13 }}>🔍</span>
<input type="text" placeholder="위치 또는 좌표 입력..." className="flex-1 bg-transparent border-none outline-none text-[11px] font-korean" style={{ color: '#e2e8f0' }} />
</div>
{/* 지도 placeholder */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-3xl mb-2 opacity-20">🗺</div>
<div className="text-[11px] font-korean opacity-40" style={{ color: '#64748b' }}> AOI를 </div>
</div>
</div>
{/* AOI 도구 버튼 (오른쪽 사이드) */}
<div className="absolute top-14 right-3 flex flex-col gap-1 p-1.5 rounded-lg z-10" style={{ background: 'rgba(13,17,23,.9)', border: '1px solid #21262d' }}>
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>ADD</div>
{[
{ icon: '⬜', title: '사각형 AOI' },
{ icon: '🔷', title: '다각형 AOI' },
{ icon: '⭕', title: '원형 AOI' },
{ icon: '📁', title: '파일 업로드' },
].map((t, i) => (
<button key={i} className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title={t.title}>{t.icon}</button>
))}
<div className="h-px my-0.5" style={{ background: '#21262d' }} />
<div className="text-[7px] font-bold text-center mb-0.5" style={{ color: '#64748b' }}>AOI</div>
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }} title="저장된 AOI">💾</button>
<button className="w-7 h-7 flex items-center justify-center rounded text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#ef4444' }} title="AOI 삭제">🗑</button>
</div>
{/* 줌 컨트롤 */}
<div className="absolute bottom-3 right-3 flex flex-col rounded-md overflow-hidden z-10" style={{ border: '1px solid #21262d' }}>
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none" style={{ background: '#161b22', color: '#8690a6' }}>+</button>
<button className="w-7 h-7 flex items-center justify-center text-sm cursor-pointer border-none border-t" style={{ background: '#161b22', color: '#8690a6', borderTopColor: '#21262d' }}></button>
</div>
{/* 이 지역 검색 버튼 */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 z-10">
<button className="px-4 py-2 rounded-full text-[10px] font-semibold cursor-pointer font-korean" style={{ background: 'rgba(59,130,246,.9)', color: '#fff', border: 'none', boxShadow: '0 2px 12px rgba(59,130,246,.3)' }}>🔍 </button>
</div>
</div>
{/* 위성 패스 타임라인 */}
<div className="border-t px-4 py-3 shrink-0" style={{ borderColor: '#21262d', background: 'rgba(13,17,23,.95)' }}>
<div className="text-[10px] font-bold font-korean mb-2" style={{ color: '#e2e8f0' }}>🛰 AOI </div>
<div className="flex flex-col gap-1.5">
{up42Passes.map((p, i) => (
<div
key={i}
onClick={() => setUp42SelPass(up42SelPass === i ? null : i)}
className="flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer transition-colors"
style={{
background: up42SelPass === i ? 'rgba(59,130,246,.1)' : '#161b22',
border: up42SelPass === i ? '1px solid rgba(59,130,246,.3)' : '1px solid #21262d',
}}
>
<div className="w-1.5 h-5 rounded-full shrink-0" style={{ background: p.color }} />
<div className="flex-1 flex items-center gap-3 min-w-0">
<span className="text-[10px] font-bold font-korean min-w-[100px]" style={{ color: '#e2e8f0' }}>{p.sat}</span>
<span className="text-[9px] font-bold font-mono min-w-[110px]" style={{ color: '#60a5fa' }}>{p.time}</span>
<span className="text-[9px] font-mono" style={{ color: '#06b6d4' }}>{p.res}</span>
<span className="text-[8px] font-mono" style={{ color: '#64748b' }}>{p.cloud}</span>
</div>
{p.note && (
<span className="px-1.5 py-px rounded text-[8px] font-bold shrink-0" style={{
background: p.note === '최우선 추천' ? 'rgba(34,197,94,.1)' : p.note === '초고해상도' ? 'rgba(6,182,212,.1)' : p.note === 'SAR' ? 'rgba(245,158,11,.1)' : 'rgba(99,102,241,.1)',
color: p.note === '최우선 추천' ? '#22c55e' : p.note === '초고해상도' ? '#06b6d4' : p.note === 'SAR' ? '#f59e0b' : '#818cf8',
}}>{p.note}</span>
)}
{up42SelPass === i && <span className="text-xs" style={{ color: '#3b82f6' }}></span>}
</div>
))}
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-3 border-t flex items-center justify-between shrink-0" style={{ borderColor: '#21262d' }}>
<div className="text-[9px] font-korean" style={{ color: '#64748b' }}> ? <span style={{ color: '#60a5fa', cursor: 'pointer' }}> </span> <span style={{ color: '#60a5fa', cursor: 'pointer' }}> </span></div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-korean mr-1.5" style={{ color: '#8690a6' }}>
: {up42SelSat ? up42Satellites.find(s => s.id === up42SelSat)?.name : '없음'}
</span>
<button onClick={() => setModalPhase('provider')} className="px-4 py-2 rounded-lg border text-[11px] font-semibold cursor-pointer font-korean" style={{ borderColor: '#21262d', background: '#161b22', color: '#94a3b8' }}> </button>
<button
onClick={() => setModalPhase('none')}
className="px-6 py-2 rounded-lg border-none text-[11px] font-bold cursor-pointer font-korean text-white transition-opacity"
style={{
background: up42SelSat ? 'linear-gradient(135deg,#3b82f6,#06b6d4)' : '#21262d',
opacity: up42SelSat ? 1 : 0.5,
color: up42SelSat ? '#fff' : '#64748b',
boxShadow: up42SelSat ? '0 4px 16px rgba(59,130,246,.35)' : 'none',
}}
>🛰 </button>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}

파일 보기

@ -0,0 +1,497 @@
import { useState } from 'react';
interface ReconItem {
id: string
name: string
type: 'vessel' | 'pollution'
status: 'complete' | 'processing'
points: string
polygons: string
coverage: string
}
const reconItems: ReconItem[] = [
{ id: 'V-001', name: '불명선박-A', type: 'vessel', status: 'complete', points: '980K', polygons: '38K', coverage: '97.1%' },
{ id: 'V-002', name: '불명선박-B', type: 'vessel', status: 'complete', points: '1.2M', polygons: '48K', coverage: '98.4%' },
{ id: 'V-003', name: '어선 #37', type: 'vessel', status: 'processing', points: '420K', polygons: '16K', coverage: '64.2%' },
{ id: 'P-001', name: '유류오염-A', type: 'pollution', status: 'complete', points: '560K', polygons: '22K', coverage: '95.8%' },
{ id: 'P-002', name: '유류오염-B', type: 'pollution', status: 'processing', points: '310K', polygons: '12K', coverage: '52.1%' },
]
function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string }) {
const isProcessing = status === 'processing'
const isWire = viewMode === 'wire'
const isPoint = viewMode === 'point'
const [vesselPoints] = useState(() =>
Array.from({ length: 300 }, (_, i) => {
const x = 35 + Math.random() * 355
const y = 15 + Math.random() * 160
const inHull = y > 60 && y < 175 && x > 35 && x < 390
const inBridge = x > 260 && x < 330 && y > 25 && y < 60
if (!inHull && !inBridge && Math.random() > 0.15) return null
const alpha = 0.15 + Math.random() * 0.55
const r = 0.8 + Math.random() * 0.8
return { i, x, y, r, alpha }
})
)
// 선박 SVG 와이어프레임/솔리드 3D 투시
return (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(15deg) rotateY(-25deg) rotateZ(2deg)', transformStyle: 'preserve-3d', position: 'relative', width: '420px', height: '200px' }}>
<svg viewBox="0 0 420 200" width="420" height="200" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
{/* 수선 (waterline) */}
<ellipse cx="210" cy="165" rx="200" ry="12" fill="none" stroke="rgba(6,182,212,0.15)" strokeWidth="0.5" strokeDasharray="4 2" />
{/* 선체 (hull) - 3D 효과 */}
<path d="M 30 140 Q 40 170 100 175 L 320 175 Q 380 170 395 140 L 390 100 Q 385 85 370 80 L 50 80 Q 35 85 30 100 Z"
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.08)'}
stroke={isProcessing ? 'rgba(6,182,212,0.2)' : 'rgba(6,182,212,0.5)'}
strokeWidth={isWire ? '0.8' : '1.2'} />
{/* 선체 하부 */}
<path d="M 30 140 Q 20 155 60 168 L 100 175 M 395 140 Q 405 155 360 168 L 320 175"
fill="none" stroke="rgba(6,182,212,0.3)" strokeWidth="0.7" />
{/* 갑판 (deck) */}
<path d="M 50 80 Q 45 65 55 60 L 365 60 Q 375 65 370 80"
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.05)'}
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.45)'}
strokeWidth={isWire ? '0.8' : '1'} />
{/* 선교 (bridge) */}
<rect x="260" y="25" width="70" height="35" rx="2"
fill={isWire || isPoint ? 'none' : 'rgba(6,182,212,0.1)'}
stroke={isProcessing ? 'rgba(6,182,212,0.15)' : 'rgba(6,182,212,0.5)'}
strokeWidth={isWire ? '0.8' : '1'} />
{/* 선교 창문 */}
{!isPoint && <g stroke="rgba(6,182,212,0.3)" strokeWidth="0.5" fill="none">
<rect x="268" y="30" width="10" height="6" rx="1" />
<rect x="282" y="30" width="10" height="6" rx="1" />
<rect x="296" y="30" width="10" height="6" rx="1" />
<rect x="310" y="30" width="10" height="6" rx="1" />
</g>}
{/* 마스트 */}
<line x1="295" y1="25" x2="295" y2="8" stroke="rgba(6,182,212,0.4)" strokeWidth="1" />
<line x1="288" y1="12" x2="302" y2="12" stroke="rgba(6,182,212,0.3)" strokeWidth="0.8" />
{/* 연통 (funnel) */}
<rect x="235" y="38" width="18" height="22" rx="1"
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.1)'}
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.4)'}
strokeWidth={isWire ? '0.8' : '1'} />
{/* 화물 크레인 */}
<g stroke={isProcessing ? 'rgba(249,115,22,0.15)' : 'rgba(249,115,22,0.4)'} strokeWidth="0.8" fill="none">
<line x1="150" y1="60" x2="150" y2="20" />
<line x1="150" y1="22" x2="120" y2="40" />
<line x1="180" y1="60" x2="180" y2="25" />
<line x1="180" y1="27" x2="155" y2="42" />
</g>
{/* 선체 리브 (와이어프레임 / 포인트 모드) */}
{(isWire || isPoint) && <g stroke="rgba(6,182,212,0.15)" strokeWidth="0.4">
{[80, 120, 160, 200, 240, 280, 320, 360].map(x => (
<line key={x} x1={x} y1="60" x2={x} y2="175" />
))}
{[80, 100, 120, 140, 160].map(y => (
<line key={y} x1="30" y1={y} x2="395" y2={y} />
))}
</g>}
{/* 포인트 클라우드 모드 */}
{isPoint && <g>
{vesselPoints.map(p => p && (
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
))}
</g>}
{/* 선수/선미 표시 */}
<text x="395" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)"></text>
<text x="15" y="95" fill="rgba(6,182,212,0.3)" fontSize="8" fontFamily="var(--fM)"></text>
{/* 측정선 (3D 모드) */}
{viewMode === '3d' && <>
<line x1="30" y1="185" x2="395" y2="185" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
<text x="200" y="195" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">84.7m</text>
<line x1="405" y1="60" x2="405" y2="175" stroke="rgba(249,115,22,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
<text x="415" y="120" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="start" transform="rotate(90, 415, 120)">14.2m</text>
</>}
</svg>
{/* 처리중 오버레이 */}
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-primary-cyan/40 text-xs font-mono animate-pulse"> ...</div>
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-primary-cyan/40 rounded-full" style={{ width: '64%', animation: 'pulse 2s infinite' }} />
</div>
</div>
</div>
)}
</div>
</div>
)
}
function Pollution3DModel({ viewMode, status }: { viewMode: string; status: string }) {
const isProcessing = status === 'processing'
const isWire = viewMode === 'wire'
const isPoint = viewMode === 'point'
const [pollutionPoints] = useState(() =>
Array.from({ length: 400 }, (_, i) => {
const cx = 190, cy = 145, rx = 130, ry = 75
const angle = Math.random() * Math.PI * 2
const r = Math.sqrt(Math.random())
const x = cx + r * rx * Math.cos(angle)
const y = cy + r * ry * Math.sin(angle)
if (x < 40 || x > 340 || y < 50 || y > 230) return null
const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2)
const intensity = Math.max(0.1, 1 - dist)
const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})`
const circleR = 0.6 + Math.random() * 1.2
return { i, x, y, r: circleR, color }
})
)
return (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: '380px', height: '260px' }}>
<svg viewBox="0 0 380 260" width="380" height="260" style={{ filter: isProcessing ? 'saturate(0.3) opacity(0.5)' : undefined }}>
{/* 해수면 그리드 */}
<g stroke="rgba(6,182,212,0.08)" strokeWidth="0.4">
{Array.from({ length: 15 }, (_, i) => <line key={`h${i}`} x1="0" y1={i * 20} x2="380" y2={i * 20} />)}
{Array.from({ length: 20 }, (_, i) => <line key={`v${i}`} x1={i * 20} y1="0" x2={i * 20} y2="260" />)}
</g>
{/* 유막 메인 형태 - 불규칙 blob */}
<path d="M 120 80 Q 80 90 70 120 Q 55 155 80 180 Q 100 205 140 210 Q 180 220 220 205 Q 270 195 300 170 Q 320 145 310 115 Q 300 85 270 75 Q 240 65 200 70 Q 160 68 120 80 Z"
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.08)'}
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.45)'}
strokeWidth={isWire ? '0.8' : '1.5'} />
{/* 유막 두께 등고선 */}
<path d="M 155 100 Q 125 115 120 140 Q 115 165 135 180 Q 155 195 190 190 Q 230 185 255 165 Q 270 145 260 120 Q 250 100 225 95 Q 195 88 155 100 Z"
fill={isWire || isPoint ? 'none' : 'rgba(249,115,22,0.08)'}
stroke={isProcessing ? 'rgba(249,115,22,0.12)' : 'rgba(249,115,22,0.35)'}
strokeWidth="0.8" strokeDasharray={isWire ? '4 2' : 'none'} />
{/* 유막 최고 두께 핵심 */}
<path d="M 175 120 Q 160 130 165 150 Q 170 170 195 170 Q 220 168 230 150 Q 235 130 220 120 Q 205 110 175 120 Z"
fill={isWire || isPoint ? 'none' : 'rgba(239,68,68,0.15)'}
stroke={isProcessing ? 'rgba(239,68,68,0.15)' : 'rgba(239,68,68,0.5)'}
strokeWidth="0.8" />
{/* 확산 방향 화살표 */}
<g stroke="rgba(249,115,22,0.5)" strokeWidth="1" fill="rgba(249,115,22,0.5)">
<line x1="250" y1="140" x2="330" y2="120" />
<polygon points="330,120 322,115 324,123" />
<text x="335" y="122" fill="rgba(249,115,22,0.6)" fontSize="8" fontFamily="var(--fM)">ESE 0.3km/h</text>
</g>
{/* 와이어프레임 추가 등고선 */}
{(isWire || isPoint) && <g stroke="rgba(239,68,68,0.12)" strokeWidth="0.3">
<ellipse cx="190" cy="145" rx="140" ry="80" fill="none" />
<ellipse cx="190" cy="145" rx="100" ry="55" fill="none" />
<ellipse cx="190" cy="145" rx="60" ry="35" fill="none" />
</g>}
{/* 포인트 클라우드 */}
{isPoint && <g>
{pollutionPoints.map(p => p && (
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
))}
</g>}
{/* 두께 색상 범례 */}
{viewMode === '3d' && <>
<text x="165" y="148" fill="rgba(239,68,68,0.7)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">3.2mm</text>
<text x="130" y="165" fill="rgba(249,115,22,0.5)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">1.5mm</text>
<text x="95" y="130" fill="rgba(234,179,8,0.4)" fontSize="7" fontFamily="var(--fM)" textAnchor="middle">0.3mm</text>
</>}
{/* 측정선 (3D 모드) */}
{viewMode === '3d' && <>
<line x1="55" y1="240" x2="320" y2="240" stroke="rgba(34,197,94,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
<text x="187" y="252" fill="rgba(34,197,94,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle">1.24 km</text>
<line x1="25" y1="80" x2="25" y2="210" stroke="rgba(59,130,246,0.4)" strokeWidth="0.5" strokeDasharray="3 2" />
<text x="15" y="150" fill="rgba(59,130,246,0.6)" fontSize="8" fontFamily="var(--fM)" textAnchor="middle" transform="rotate(-90, 15, 150)">0.68 km</text>
</>}
</svg>
{/* 두께 색상 범례 바 */}
{viewMode === '3d' && !isProcessing && (
<div className="absolute bottom-0 right-2 flex items-center gap-1" style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
<span>0mm</span>
<div style={{ width: '60px', height: '4px', borderRadius: '2px', background: 'linear-gradient(90deg, rgba(234,179,8,0.6), rgba(249,115,22,0.7), rgba(239,68,68,0.8))' }} />
<span>3.2mm</span>
</div>
)}
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-status-red/40 text-xs font-mono animate-pulse"> ...</div>
<div className="w-24 h-0.5 bg-bg-3 rounded-full mt-2 mx-auto overflow-hidden">
<div className="h-full bg-status-red/40 rounded-full" style={{ width: '52%', animation: 'pulse 2s infinite' }} />
</div>
</div>
</div>
)}
</div>
</div>
)
}
export function SensorAnalysis() {
const [subTab, setSubTab] = useState<'vessel' | 'pollution'>('vessel')
const [viewMode, setViewMode] = useState('3d')
const [selectedItem, setSelectedItem] = useState<ReconItem>(reconItems[1])
const filteredItems = reconItems.filter(r => r.type === (subTab === 'vessel' ? 'vessel' : 'pollution'))
return (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* Left Panel */}
<div className="w-[280px] bg-bg-1 border-r border-border flex flex-col overflow-auto">
{/* 3D Reconstruction List */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📋 3D </div>
<div className="flex gap-1 mb-2">
<button
onClick={() => setSubTab('vessel')}
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'vessel'
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
: 'text-text-3 bg-bg-0 border-border'
}`}
>
🚢
</button>
<button
onClick={() => setSubTab('pollution')}
className={`flex-1 py-1.5 text-center text-[9px] font-semibold rounded cursor-pointer border transition-colors font-korean ${
subTab === 'pollution'
? 'text-primary-cyan bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
: 'text-text-3 bg-bg-0 border-border'
}`}
>
🛢
</button>
</div>
<div className="flex flex-col gap-1">
{filteredItems.map(item => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
selectedItem.id === item.id
? 'bg-[rgba(6,182,212,0.08)] border-primary-cyan/20'
: 'border-transparent hover:bg-white/[0.02]'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-bold text-text-1 font-korean">{item.name}</div>
<div className="text-[8px] text-text-3 font-mono">{item.id} · {item.points} pts</div>
</div>
<span className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-status-green' : 'text-status-orange'}`}>
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
</span>
</div>
))}
</div>
</div>
{/* Source Images */}
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
<div className="text-[10px] font-bold text-text-3 mb-1.5 uppercase tracking-wider">📹 </div>
<div className="grid grid-cols-2 gap-1">
{[
{ label: 'D-01 정면', sensor: '광학', color: 'text-primary-blue' },
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-status-red' },
{ label: 'D-03 우현', sensor: '광학', color: 'text-primary-purple' },
{ label: 'D-02 상부', sensor: 'IR', color: 'text-status-red' },
].map((src, i) => (
<div key={i} className="relative rounded-sm bg-bg-0 border border-border overflow-hidden aspect-square">
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}>
<div className="text-text-3/10 text-xs font-mono">{src.label.split(' ')[0]}</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-[8px] text-text-3 font-korean">
<span>{src.label}</span>
<span className={src.color}>{src.sensor}</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Center Panel - 3D Canvas */}
<div className="flex-1 relative bg-bg-0 border-x border-border flex items-center justify-center overflow-hidden">
{/* Simulated 3D viewport */}
<div className="absolute inset-0" style={{ background: 'radial-gradient(ellipse at 50% 50%, #0c1a2e, #060c18)' }}>
{/* Grid floor */}
<div className="absolute inset-0 opacity-[0.06]" style={{ backgroundImage: 'linear-gradient(rgba(6,182,212,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(6,182,212,0.5) 1px, transparent 1px)', backgroundSize: '40px 40px', transform: 'perspective(500px) rotateX(55deg)', transformOrigin: 'center 80%' }} />
{/* 3D Model Visualization */}
{selectedItem.type === 'vessel' ? (
<Vessel3DModel viewMode={viewMode} status={selectedItem.status} />
) : (
<Pollution3DModel viewMode={viewMode} status={selectedItem.status} />
)}
{/* Axis indicator */}
<div className="absolute bottom-16 left-4" style={{ fontSize: '9px', fontFamily: 'var(--fM)' }}>
<div style={{ color: '#ef4444' }}>X </div>
<div style={{ color: '#22c55e' }}>Y </div>
<div style={{ color: '#3b82f6' }}>Z </div>
</div>
</div>
{/* Title */}
<div className="absolute top-3 left-3 z-[2]">
<div className="text-[10px] font-bold text-text-3 uppercase tracking-wider">3D Vessel Analysis</div>
<div className="text-[13px] font-bold text-primary-cyan my-1 font-korean">{selectedItem.name} </div>
<div className="text-[9px] text-text-3 font-mono">34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}</div>
</div>
{/* View Mode Buttons */}
<div className="absolute top-3 right-3 flex gap-1 z-[2]">
{[
{ id: '3d', label: '3D모델' },
{ id: 'point', label: '포인트클라우드' },
{ id: 'wire', label: '와이어프레임' },
].map(m => (
<button
key={m.id}
onClick={() => setViewMode(m.id)}
className={`px-2.5 py-1.5 text-[10px] font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
viewMode === m.id
? 'bg-[rgba(6,182,212,0.2)] border-primary-cyan/50 text-primary-cyan'
: 'bg-black/40 border-primary-cyan/20 text-text-3 hover:bg-black/60 hover:border-primary-cyan/40'
}`}
>
{m.label}
</button>
))}
</div>
{/* Bottom Stats */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-3 bg-black/50 backdrop-blur-lg px-4 py-2 rounded-md border z-[2]" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
{[
{ value: selectedItem.points, label: '포인트' },
{ value: selectedItem.polygons, label: '폴리곤' },
{ value: '3', label: '시점' },
{ value: selectedItem.coverage, label: '커버리지' },
{ value: '0.023m', label: 'RMS오차' },
].map((s, i) => (
<div key={i} className="text-center">
<div className="font-mono font-bold text-sm text-primary-cyan">{s.value}</div>
<div className="text-[8px] text-text-3 mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
</div>
{/* Right Panel - Analysis Details */}
<div className="w-[270px] bg-bg-1 border-l border-border flex flex-col overflow-auto">
{/* Ship/Pollution Info */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📊 </div>
<div className="flex flex-col gap-1.5 text-[10px]">
{(selectedItem.type === 'vessel' ? [
['대상', selectedItem.name],
['선종 추정', '일반화물선 (추정)'],
['길이', '약 85m'],
['폭', '약 14m'],
['AIS 상태', 'OFF (미식별)'],
['최초 탐지', '2026-01-18 14:20'],
['촬영 시점', '3 시점 (정면/좌현/우현)'],
['센서', '광학 4K + IR 열화상'],
] : [
['대상', selectedItem.name],
['유형', '유류 오염'],
['추정 면적', '0.42 km²'],
['추정 유출량', '12.6 kL'],
['유종', 'B-C유 (추정)'],
['최초 탐지', '2026-01-18 13:50'],
['확산 속도', '0.3 km/h (ESE 방향)'],
]).map(([k, v], i) => (
<div key={i} className="flex justify-between items-start">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-text-1 text-right ml-2">{v}</span>
</div>
))}
</div>
</div>
{/* AI Detection Results */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">🤖 AI </div>
<div className="flex flex-col gap-1">
{(selectedItem.type === 'vessel' ? [
{ label: '선박 식별', confidence: 94, color: 'bg-status-green' },
{ label: '선종 분류', confidence: 78, color: 'bg-status-yellow' },
{ label: '손상 감지', confidence: 45, color: 'bg-status-orange' },
{ label: '화물 분석', confidence: 62, color: 'bg-status-yellow' },
] : [
{ label: '유막 탐지', confidence: 97, color: 'bg-status-green' },
{ label: '유종 분류', confidence: 85, color: 'bg-status-green' },
{ label: '두께 추정', confidence: 72, color: 'bg-status-yellow' },
{ label: '확산 예측', confidence: 68, color: 'bg-status-orange' },
]).map((r, i) => (
<div key={i}>
<div className="flex justify-between text-[9px] mb-0.5">
<span className="text-text-3 font-korean">{r.label}</span>
<span className="font-mono font-semibold text-text-1">{r.confidence}%</span>
</div>
<div className="w-full h-1 bg-bg-0 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${r.color}`} style={{ width: `${r.confidence}%` }} />
</div>
</div>
))}
</div>
</div>
{/* Comparison / Measurements */}
<div className="p-2.5 px-3 border-b border-border">
<div className="text-[10px] font-bold text-text-3 mb-2 uppercase tracking-wider">📐 3D </div>
<div className="flex flex-col gap-1 text-[10px]">
{(selectedItem.type === 'vessel' ? [
['전장 (LOA)', '84.7 m'],
['형폭 (Breadth)', '14.2 m'],
['건현 (Freeboard)', '3.8 m'],
['흘수 (Draft)', '5.6 m (추정)'],
['마스트 높이', '22.3 m'],
] : [
['유막 면적', '0.42 km²'],
['최대 길이', '1.24 km'],
['최대 폭', '0.68 km'],
['평균 두께', '0.8 mm'],
['최대 두께', '3.2 mm'],
]).map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-primary-cyan">{v}</span>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="p-2.5 px-3">
<button className="w-full py-2.5 rounded-sm text-xs font-bold font-korean text-white border-none cursor-pointer mb-2" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
📊
</button>
<button className="w-full py-2 border border-border bg-bg-3 text-text-2 rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-hover transition-colors">
📥 3D
</button>
</div>
</div>
</div>
)
}

파일 보기

@ -0,0 +1,332 @@
import { useState, useEffect } from 'react'
import type { AssetOrg } from './assetTypes'
import { typeTagCls } from './assetTypes'
import { organizations } from './assetMockData'
import AssetMap from './AssetMap'
function AssetManagement() {
const [viewMode, setViewMode] = useState<'list' | 'map'>('list')
const [selectedOrg, setSelectedOrg] = useState<AssetOrg>(organizations[0])
const [detailTab, setDetailTab] = useState<'equip' | 'material' | 'contact'>('equip')
const [regionFilter, setRegionFilter] = useState('all')
const [searchTerm, setSearchTerm] = useState('')
const [typeFilterVal, setTypeFilterVal] = useState('all')
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 15
const filtered = organizations.filter(o => {
if (regionFilter !== 'all' && !o.jurisdiction.includes(regionFilter)) return false
if (typeFilterVal !== 'all' && o.type !== typeFilterVal) return false
if (searchTerm && !o.name.includes(searchTerm) && !o.address.includes(searchTerm)) return false
return true
})
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
const safePage = Math.min(currentPage, totalPages)
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize)
// 필터 변경 시 첫 페이지로
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm])
const regionShort = (j: string) => {
if (j.includes('중부')) return '중부청'
if (j.includes('서해')) return '서해청'
if (j.includes('남해')) return '남해청'
if (j.includes('동해')) return '동해청'
if (j.includes('중앙')) return '중특단'
return '제주청'
}
return (
<div className="flex flex-col h-full">
{/* View Switcher & Filters */}
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
<div className="flex gap-1">
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
viewMode === 'list'
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
📋
</button>
<button
onClick={() => setViewMode('map')}
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
viewMode === 'map'
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
🗺
</button>
</div>
<div className="flex gap-1.5 items-center">
<input
type="text"
placeholder="기관명 검색..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="prd-i w-40 py-1.5 px-2.5"
/>
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
<option value="all"> </option>
<option value="남해"></option>
<option value="서해"></option>
<option value="중부"></option>
<option value="동해"></option>
<option value="제주"></option>
</select>
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
<option value="all"> </option>
<option value="해경관할"></option>
<option value="해경경찰서"></option>
<option value="파출소"></option>
<option value="관련기관"></option>
<option value="해양환경공단"></option>
<option value="업체"></option>
<option value="지자체"></option>
<option value="기름저장시설"></option>
<option value="정유사"></option>
<option value="해군"></option>
<option value="기타"></option>
</select>
</div>
</div>
{viewMode === 'list' ? (
/* ── LIST VIEW ── */
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
<div className="flex-1">
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '3.5%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '12%' }} />
<col />
<col style={{ width: '8%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '5%' }} />
</colgroup>
<thead>
<tr className="border-b border-border bg-bg-0">
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => (
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold text-text-2 font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{paged.map((org, idx) => (
<tr
key={org.id}
className={`border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
selectedOrg.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
}`}
onClick={() => { setSelectedOrg(org); setViewMode('map') }}
>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{(safePage - 1) * pageSize + idx + 1}</td>
<td className="px-2.5 py-2">
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>{org.type}</span>
</td>
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
<td className="px-2.5 py-2 text-center font-mono text-[10px] font-semibold">{org.vessel}</td>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.skimmer}</td>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.pump}</td>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.vehicle}</td>
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.sprayer}</td>
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
<span className="text-[10px] text-text-3 font-korean">
<span className="font-semibold text-text-2">{filtered.length}</span> {' '}
<span className="font-semibold text-text-2">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(1)}
disabled={safePage <= 1}
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
>«</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
></button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<button
key={p}
onClick={() => setCurrentPage(p)}
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
p === safePage
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
: 'border border-border bg-bg-3 text-text-3 hover:bg-bg-hover'
}`}
>{p}</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
></button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={safePage >= totalPages}
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
>»</button>
</div>
</div>
</div>
) : (
/* ── MAP VIEW ── */
<div className="flex-1 flex overflow-hidden rounded-md border border-border">
{/* Map */}
<div className="flex-1 relative overflow-hidden">
<AssetMap
organizations={filtered}
selectedOrg={selectedOrg}
onSelectOrg={setSelectedOrg}
regionFilter={regionFilter}
onRegionFilterChange={setRegionFilter}
/>
</div>
{/* Right Detail Panel */}
<aside className="w-[340px] min-w-[340px] bg-bg-1 border-l border-border flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border">
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
<div className="text-[11px] text-text-2 font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
<div className="text-[11px] text-text-3 font-korean">{selectedOrg.address}</div>
</div>
{/* Sub-tabs */}
<div className="flex border-b border-border">
{(['equip', 'material', 'contact'] as const).map(t => (
<button
key={t}
onClick={() => setDetailTab(t)}
className={`flex-1 py-2.5 text-center text-[11px] font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
detailTab === t
? 'text-primary-cyan border-primary-cyan'
: 'text-text-3 border-transparent hover:text-text-2'
}`}
>
{t === 'equip' ? '장비' : t === 'material' ? '자재' : '연락처'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
{/* Summary */}
<div className="grid grid-cols-3 gap-1.5 mb-3">
{[
{ value: `${selectedOrg.vessel}`, label: '방제선' },
{ value: `${selectedOrg.skimmer}`, label: '유회수기' },
{ value: String(selectedOrg.totalAssets), label: '총 자산' },
].map((s, i) => (
<div key={i} className="bg-bg-3 border border-border rounded-sm p-2.5 text-center">
<div className="text-lg font-bold text-primary-cyan font-mono">{s.value}</div>
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{s.label}</div>
</div>
))}
</div>
{detailTab === 'equip' && (
<div className="flex flex-col gap-1">
{selectedOrg.equipment.length > 0 ? selectedOrg.equipment.map((cat, ci) => {
const unitMap: Record<string, string> = {
'방제선': '척', '유회수기': '대', '비치크리너': '대', '이송펌프': '대', '방제차량': '대',
'해안운반차': '대', '고압세척기': '대', '저압세척기': '대', '동력분무기': '대', '유량계측기': '대',
'방제창고': '개소', '발전기': '대', '현장지휘소': '개', '지원장비': '대', '장비부품': '개',
'경비함정방제': '대', '살포장치': '대',
}
const unit = unitMap[cat.category] || '개'
return (
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-3 border border-border rounded-sm hover:bg-bg-hover transition-colors">
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
{cat.icon} {cat.category}
</span>
<span className="text-[11px] font-bold font-mono"><span className="text-primary-cyan">{cat.count}</span><span className="text-text-3 font-normal ml-0.5">{unit}</span></span>
</div>
)
}) : (
<div className="text-center text-text-3 text-xs py-8 font-korean"> .</div>
)}
</div>
)}
{detailTab === 'material' && (
<div className="flex flex-col gap-1.5">
{[
['방제선', `${selectedOrg.vessel}`],
['유회수기', `${selectedOrg.skimmer}`],
['이송펌프', `${selectedOrg.pump}`],
['방제차량', `${selectedOrg.vehicle}`],
['살포장치', `${selectedOrg.sprayer}`],
['총 자산', `${selectedOrg.totalAssets}`],
].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-0 rounded text-[11px]">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono font-semibold text-text-1">{v}</span>
</div>
))}
</div>
)}
{detailTab === 'contact' && (
<div className="bg-bg-3 border border-border rounded-sm p-3">
{selectedOrg.contacts.length > 0 ? selectedOrg.contacts.map((c, i) => (
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
{[
['기관/업체', c.name],
['연락처', c.phone],
].map(([k, v], j) => (
<div key={j} className="flex justify-between py-1 text-[11px]">
<span className="text-text-3 font-korean">{k}</span>
<span className="font-mono text-text-1">{v}</span>
</div>
))}
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border my-1" />}
</div>
)) : (
<div className="text-center text-text-3 text-xs py-4 font-korean"> .</div>
)}
</div>
)}
</div>
{/* Bottom Actions */}
<div className="p-3.5 border-t border-border flex gap-2">
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
📥
</button>
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">
</button>
</div>
</aside>
</div>
)}
</div>
)
}
export default AssetManagement

파일 보기

@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { AssetOrg } from './assetTypes'
import { typeColor } from './assetTypes'
interface AssetMapProps {
organizations: AssetOrg[]
selectedOrg: AssetOrg
onSelectOrg: (o: AssetOrg) => void
regionFilter: string
onRegionFilterChange: (v: string) => void
}
function AssetMap({
organizations: orgs,
selectedOrg,
onSelectOrg,
regionFilter,
onRegionFilterChange,
}: AssetMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<L.LayerGroup | null>(null)
// Initialize map once
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return
const map = L.map(mapContainerRef.current, {
center: [35.9, 127.8],
zoom: 7,
zoomControl: false,
attributionControl: false,
})
// Dark-themed OpenStreetMap tiles
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
L.control.attribution({ position: 'bottomright' }).addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
mapRef.current = map
markersRef.current = L.layerGroup().addTo(map)
return () => {
map.remove()
mapRef.current = null
markersRef.current = null
}
}, [])
// Update markers when orgs or selectedOrg changes
useEffect(() => {
if (!mapRef.current || !markersRef.current) return
markersRef.current.clearLayers()
orgs.forEach(org => {
const isSelected = selectedOrg.id === org.id
const tc = typeColor(org.type)
const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7
const cm = L.circleMarker([org.lat, org.lng], {
radius: isSelected ? radius + 4 : radius,
fillColor: isSelected ? tc.selected : tc.bg,
color: isSelected ? tc.selected : tc.border,
weight: isSelected ? 3 : 2,
fillOpacity: isSelected ? 0.9 : 0.7,
})
cm.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${org.name}</div>
<div style="font-size:10px;opacity:0.7;">${org.type} · ${org.totalAssets}</div>
</div>`,
{ permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' }
)
cm.on('click', () => onSelectOrg(org))
markersRef.current!.addLayer(cm)
})
}, [orgs, selectedOrg, onSelectOrg])
// Pan to selected org
useEffect(() => {
if (!mapRef.current) return
mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 })
}, [selectedOrg])
return (
<div className="w-full h-full relative">
<style>{`
.asset-map-tooltip {
background: rgba(15,21,36,0.92) !important;
border: 1px solid rgba(30,42,66,0.8) !important;
color: #e4e8f1 !important;
border-radius: 6px !important;
padding: 4px 8px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
}
.asset-map-tooltip::before {
border-top-color: rgba(15,21,36,0.92) !important;
}
`}</style>
<div ref={mapContainerRef} className="w-full h-full" />
{/* Region filter overlay */}
<div className="absolute top-3 left-3 z-[1000] flex gap-1">
{[
{ value: 'all', label: '전체' },
{ value: '남해', label: '남해청' },
{ value: '서해', label: '서해청' },
{ value: '중부', label: '중부청' },
{ value: '동해', label: '동해청' },
{ value: '제주', label: '제주청' },
].map(r => (
<button
key={r.value}
onClick={() => onRegionFilterChange(r.value)}
className={`px-2.5 py-1.5 text-[10px] font-bold rounded font-korean transition-colors ${
regionFilter === r.value
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
: 'bg-bg-0/80 text-text-2 border border-border hover:bg-bg-hover/80'
}`}
>
{r.label}
</button>
))}
</div>
{/* Legend overlay */}
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-0/90 border border-border rounded-sm p-2.5 backdrop-blur-sm">
<div className="text-[9px] text-text-3 font-bold mb-1.5 font-korean"></div>
{[
{ color: '#06b6d4', label: '해경관할' },
{ color: '#3b82f6', label: '해경경찰서' },
{ color: '#22c55e', label: '파출소' },
{ color: '#a855f7', label: '관련기관' },
{ color: '#14b8a6', label: '해양환경공단' },
{ color: '#f59e0b', label: '업체' },
{ color: '#ec4899', label: '지자체' },
{ color: '#8b5cf6', label: '기름저장시설' },
{ color: '#0d9488', label: '정유사' },
{ color: '#64748b', label: '해군' },
{ color: '#6b7280', label: '기타' },
].map((item, i) => (
<div key={i} className="flex items-center gap-1.5 mb-0.5 last:mb-0">
<span className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0" style={{ background: item.color }} />
<span className="text-[10px] text-text-2 font-korean">{item.label}</span>
</div>
))}
</div>
</div>
)
}
export default AssetMap

파일 보기

@ -0,0 +1,255 @@
interface TheoryItem {
title: string
source: string
desc: string
tags?: { label: string; color: string }[]
highlight?: boolean
}
interface TheorySection {
icon: string
title: string
color: string
bgTint: string
items: TheoryItem[]
dividerAfter?: number
dividerLabel?: string
}
const THEORY_SECTIONS: TheorySection[] = [
{
icon: '🚢', title: '방제선 성능 기준', color: 'var(--blue)', bgTint: 'rgba(59,130,246,.08)',
items: [
{
title: '해양경찰청 방제선 성능기준 고시',
source: '해양경찰청 고시 제2022-11호 | 방제선·방제정 등급별 회수용량·속력·펌프사양 기준 정의',
desc: '1~5등급 방제선 기준 · 회수능력(㎥/h) · 오일펜스 전장 탑재량 · WING 자산 등급 필터링 근거',
},
{
title: 'IMO OPRC 1990 — 방제자원 비축 기준',
source: 'International Convention on Oil Pollution Preparedness, Response and Co-operation | IMO, 1990',
desc: '국가 방제역량 비축 최저 기준 · 항만별 Tier 1/2/3 대응자원 분류 · 국내 방제자원 DB 설계 기초',
},
{
title: '해양오염방제업 등록기준 (해양환경관리법 시행규칙)',
source: '해양수산부령 | 별표 9 — 방제업 종류별 방제선·기자재 보유기준',
desc: '제1종·제2종 방제업 자산 보유기준 · 오일펜스 전장·회수기 용량 법적 최저기준 · WING 자산현황 적법성 검증 기준',
},
],
},
{
icon: '🪢', title: '오일펜스·흡착재 규격', color: 'var(--boom, #f59e0b)', bgTint: 'rgba(245,158,11,.08)',
items: [
{
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
source: 'ASTM International | 오일펜스·회수기·흡착재 성능시험·선정 기준 가이드',
desc: '오일펜스 인장강도·부력기준 · 흡착포 흡수율(g/g) 측정법 · WING 자산 성능등급 분류 참조 기준',
},
{
title: '기름오염방제시 오일펜스 사용지침 (ITOPF TIP 03 한국어판)',
source: 'ITOPF | 해양경찰청·해양환경관리공단 번역, 2011',
desc: '커튼형·펜스형·해안용 규격분류 · 유속별 운용한계(0.7~3.0 kt) · 힘 계산식 F=100·A·V² · 앵커 파지력 기준표',
},
],
},
{
icon: '⚙️', title: '방제자원 배치·동원 이론', color: 'var(--purple)', bgTint: 'rgba(168,85,247,.08)',
dividerAfter: 2, dividerLabel: '📐 최적화 수리모델 참고문헌',
items: [
{
title: 'An Emergency Scheduling Model for Oil Containment Boom in Dynamically Changing Marine Oil Spills',
source: 'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
highlight: true,
},
{
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
source: 'Garrett, R.A. et al. | Eur. J. Oper. Res. 257:272286, 2017',
desc: '불확실성 하 방제자원 동적 배분 최적화 · 시나리오별 비축량 산정 · WING 자산 우선순위 배치 알고리즘 이론 기반',
},
{
title: '해양오염방제 국가긴급방제계획 (NOSCP)',
source: '해양경찰청 | 국가긴급방제계획, 2023년판',
desc: 'Tier 3급 대형사고 자원 동원체계 · 기관별 역할분담·지휘계통 · WING 방제자산 연계 법적 근거',
},
{
title: 'A Mixed Integer Programming Approach to Improve Oil Spill Response Resource Allocation in the Canadian Arctic',
source: 'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
highlight: true,
tags: [
{ label: 'MIP 수리모델', color: 'var(--purple)' },
{ label: '자원 위치 선택', color: 'var(--blue)' },
{ label: '북극해 적용', color: 'var(--cyan)' },
],
},
{
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.1116, 2020',
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
highlight: true,
tags: [
{ label: 'GA 메타휴리스틱', color: 'var(--purple)' },
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
],
},
{
title: 'A Two-Stage Stochastic Optimization Framework for Environmentally Sensitive Oil Spill Response Resource Allocation',
source: 'Rahman, M.A., Kuhel, M.T. & Novoa, C. | arXiv preprint arXiv:2511.22218, 2025',
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
highlight: true,
tags: [
{ label: '확률적 MILP', color: 'var(--purple)' },
{ label: '2단계 최적화', color: 'var(--blue)' },
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
],
},
{
title: 'Mixed-Integer Dynamic Optimization for Oil-Spill Response Planning with Integration of Dynamic Oil Weathering Model',
source: 'You, F. & Leyffer, S. | Argonne National Laboratory Technical Note, 2008',
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
highlight: true,
tags: [
{ label: 'MINLP 동적 최적화', color: 'var(--purple)' },
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
],
},
],
},
{
icon: '🗄', title: '자산 현행화·데이터 관리', color: 'var(--green, #22c55e)', bgTint: 'rgba(34,197,94,.08)',
items: [
{
title: '해양오염방제자원 현황관리 지침',
source: '해양경찰청 예규 | 방제자원 등록·현행화·이력관리 절차 규정',
desc: '분기별 자산 실사 기준 · 자산분류코드 체계 · WING 업로드 양식(xlsx) 필드 정의 근거',
},
{
title: 'ISO 55000 — Asset Management: Overview, Principles and Terminology',
source: 'International Organization for Standardization | ISO 55000:2014',
desc: '자산 생애주기 관리 원칙 · 자산가치·상태 평가 프레임워크 · WING 자산 노후도·교체주기 산정 이론 기준',
},
],
},
]
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
'var(--purple)': { bg: 'rgba(168,85,247,0.08)', bd: 'rgba(168,85,247,0.2)', fg: '#a855f7' },
'var(--blue)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
'var(--cyan)': { bg: 'rgba(6,182,212,0.08)', bd: 'rgba(6,182,212,0.2)', fg: '#06b6d4' },
'var(--green, #22c55e)': { bg: 'rgba(34,197,94,0.08)', bd: 'rgba(34,197,94,0.2)', fg: '#22c55e' },
'var(--boom, #f59e0b)': { bg: 'rgba(245,158,11,0.08)', bd: 'rgba(245,158,11,0.2)', fg: '#f59e0b' },
}
function TheoryCard({ section }: { section: TheorySection }) {
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)')
return (
<div style={{
background: 'var(--bg3)', border: '1px solid var(--bd)',
borderRadius: 'var(--rM, 10px)', overflow: 'hidden',
}}>
{/* Section Header */}
<div style={{
padding: '12px 16px', background: section.bgTint,
borderBottom: '1px solid var(--bd)',
display: 'flex', alignItems: 'center', gap: '8px',
}}>
<span style={{ fontSize: '14px' }}>{section.icon}</span>
<span style={{ fontSize: '12px', fontWeight: 700, color: section.color, fontFamily: 'var(--fK)' }}>
{section.title}
</span>
</div>
{/* Items */}
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
{section.items.map((item, i) => (
<div key={i}>
{/* Divider */}
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
<div style={{ borderTop: '1px dashed var(--bd)', margin: '4px 0 12px', paddingTop: '8px' }}>
<div style={{ fontSize: '8px', fontWeight: 700, color: section.color, marginBottom: '6px', opacity: 0.7 }}>
{section.dividerLabel}
</div>
</div>
)}
<div style={{
display: 'grid', gridTemplateColumns: '24px 1fr', gap: '8px',
padding: '8px 10px', background: 'var(--bg0)', borderRadius: '6px',
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
}}>
{/* Number badge */}
<div style={{
width: '20px', height: '20px', borderRadius: '4px',
background: badgeBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '9px', flexShrink: 0,
fontWeight: item.highlight ? 700 : 400,
color: item.highlight ? section.color : undefined,
}}>
{['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'][i]}
</div>
<div>
<div style={{ color: 'var(--t1)', fontWeight: 700, marginBottom: '2px' }}>
{item.title}
</div>
<div style={{ color: 'var(--t3)', lineHeight: '1.6' }}>
{item.source}
</div>
{/* Tags */}
{item.tags && (
<div style={{ marginTop: '3px', display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
{item.tags.map((tag, ti) => {
const tc = TAG_COLORS[tag.color] || { bg: 'rgba(107,114,128,0.08)', bd: 'rgba(107,114,128,0.2)', fg: '#6b7280' }
return (
<span key={ti} style={{
padding: '1px 5px', borderRadius: '3px', fontSize: '8px',
color: tc.fg, background: tc.bg, border: `1px solid ${tc.bd}`,
}}>
{tag.label}
</span>
)
})}
</div>
)}
<div style={{ marginTop: '2px', color: 'var(--t2)' }}>
{item.desc}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
}
function AssetTheory() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0' }}>
<div style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: '4px' }}>
📚
</div>
<div style={{ fontSize: '12px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '24px' }}>
· ·
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '18px', alignItems: 'start' }}>
{/* Left column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
{THEORY_SECTIONS.slice(0, 2).map((sec) => (
<TheoryCard key={sec.title} section={sec} />
))}
</div>
{/* Right column */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
{THEORY_SECTIONS.slice(2).map((sec) => (
<TheoryCard key={sec.title} section={sec} />
))}
</div>
</div>
</div>
)
}
export default AssetTheory

파일 보기

@ -0,0 +1,124 @@
import { useState } from 'react'
import { uploadHistory } from './assetMockData'
function AssetUpload() {
const [uploadMode, setUploadMode] = useState<'add' | 'replace'>('add')
const [uploaded, setUploaded] = useState(false)
const handleUpload = () => {
setUploaded(true)
setTimeout(() => setUploaded(false), 3000)
}
return (
<div className="flex gap-8 h-full overflow-auto">
{/* Left - Upload */}
<div className="flex-1 max-w-[580px]">
<div className="text-[13px] font-bold mb-3.5 font-korean">📤 </div>
{/* Drop Zone */}
<div className="border-2 border-dashed border-border-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-primary-cyan/40 transition-colors">
<div className="text-4xl mb-2.5 opacity-50">📁</div>
<div className="text-sm font-semibold mb-1.5 font-korean"> </div>
<div className="text-[11px] text-text-3 mb-4 font-korean">(.xlsx), CSV · 10MB</div>
<button className="px-7 py-2.5 text-[13px] font-semibold rounded-sm text-white border-none cursor-pointer font-korean" style={{ background: 'linear-gradient(135deg, var(--blue), #2563eb)' }}>
</button>
</div>
{/* Asset Classification */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<option></option>
<option></option>
<option></option>
<option></option>
<option>·</option>
<option>MPRS·</option>
</select>
</div>
{/* Jurisdiction */}
<div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full">
<option> - </option>
<option> - </option>
<option> - </option>
<option> - </option>
<option> - </option>
<option> - </option>
<option> - </option>
</select>
</div>
{/* Upload Mode */}
<div className="mb-5">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<div className="flex gap-4 text-xs text-text-2 font-korean">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" checked={uploadMode === 'add'} onChange={() => setUploadMode('add')} className="accent-primary-blue" />
( + )
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" checked={uploadMode === 'replace'} onChange={() => setUploadMode('replace')} className="accent-primary-blue" />
</label>
</div>
</div>
{/* Upload Button */}
<button
onClick={handleUpload}
className={`w-full py-3.5 rounded-sm text-sm font-bold font-korean border-none cursor-pointer transition-all ${
uploaded
? 'bg-[rgba(34,197,94,0.2)] text-status-green border border-status-green'
: 'text-white'
}`}
style={!uploaded ? { background: 'linear-gradient(135deg, var(--blue), #2563eb)' } : undefined}
>
{uploaded ? '✅ 업로드 완료!' : '📤 업로드 실행'}
</button>
</div>
{/* Right - Permission & History */}
<div className="flex-1 max-w-[480px]">
{/* Permission System */}
<div className="text-[13px] font-bold mb-3.5 font-korean">🔐 </div>
<div className="flex flex-col gap-2 mb-7">
{[
{ icon: '👑', role: '본청 관리자', desc: '전체 자산 조회·수정·삭제·업로드', color: 'text-status-red', bg: 'rgba(239,68,68,0.15)' },
{ icon: '🏛', role: '지방청 담당자', desc: '소속 지방청 및 하위 해경서 자산 수정·업로드', color: 'text-status-orange', bg: 'rgba(249,115,22,0.15)' },
{ icon: '⚓', role: '해경서 담당자', desc: '소속 해경서 자산 수정·업로드', color: 'text-primary-blue', bg: 'rgba(59,130,246,0.15)' },
{ icon: '👤', role: '일반 사용자', desc: '조회·다운로드만 가능', color: 'text-text-2', bg: 'rgba(100,116,139,0.15)' },
].map((p, i) => (
<div key={i} className="flex items-center gap-3 p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-base" style={{ background: p.bg }}>{p.icon}</div>
<div>
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
<div className="text-[10px] text-text-3 font-korean">{p.desc}</div>
</div>
</div>
))}
</div>
{/* Upload History */}
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 </div>
<div className="flex flex-col gap-2">
{uploadHistory.map((h, i) => (
<div key={i} className="flex justify-between items-center p-3.5 px-4 bg-bg-3 border border-border rounded-sm">
<div>
<div className="text-xs font-semibold font-korean">{h.filename}</div>
<div className="text-[10px] text-text-3 mt-0.5 font-korean">{h.date} · {h.uploader} · {h.count}</div>
</div>
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-status-green"></span>
</div>
))}
</div>
</div>
</div>
)
}
export default AssetUpload

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,319 @@
import { useState } from 'react'
import type { InsuranceRow } from './assetTypes'
import { insuranceDemoData } from './assetMockData'
function ShipInsurance() {
const [apiConnected, setApiConnected] = useState(false)
const [showConfig, setShowConfig] = useState(false)
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance')
const [configApiKey, setConfigApiKey] = useState('')
const [configKeyType, setConfigKeyType] = useState('mmsi')
const [configRespType, setConfigRespType] = useState('json')
const [searchType, setSearchType] = useState('mmsi')
const [searchVal, setSearchVal] = useState('')
const [insTypeFilter, setInsTypeFilter] = useState('전체')
const [viewState, setViewState] = useState<'empty' | 'loading' | 'result'>('empty')
const [resultData, setResultData] = useState<InsuranceRow[]>([])
const [lastSync, setLastSync] = useState('—')
const placeholderMap: Record<string, string> = {
mmsi: 'MMSI 번호 입력 (예: 440123456)',
imo: 'IMO 번호 입력 (예: 9876543)',
shipname: '선박명 입력 (예: 한라호)',
callsign: '호출부호 입력 (예: HLXX1)',
}
const getStatus = (expiry: string) => {
const now = new Date()
const exp = new Date(expiry)
const daysLeft = Math.ceil((exp.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (exp < now) return 'expired' as const
if (daysLeft <= 30) return 'soon' as const
return 'valid' as const
}
const handleSaveConfig = () => {
if (!configApiKey) { alert('API Key를 입력하세요.'); return }
setShowConfig(false)
alert('API 설정이 저장되었습니다.')
}
const handleTestConnect = async () => {
await new Promise(r => setTimeout(r, 1200))
alert('⚠ API Key가 설정되지 않았습니다.\n[API 설정] 버튼에서 한국해운조합 API Key를 먼저 등록하세요.')
}
const loadDemoData = () => {
setResultData(insuranceDemoData)
setViewState('result')
setApiConnected(false)
setLastSync(new Date().toLocaleString('ko-KR'))
}
const handleQuery = async () => {
if (!searchVal.trim()) { alert('조회값을 입력하세요.'); return }
setViewState('loading')
await new Promise(r => setTimeout(r, 900))
loadDemoData()
}
const handleBatchQuery = async () => {
setViewState('loading')
await new Promise(r => setTimeout(r, 1400))
loadDemoData()
}
const handleFullSync = async () => {
setLastSync('동기화 중...')
await new Promise(r => setTimeout(r, 1000))
setLastSync(new Date().toLocaleString('ko-KR'))
alert('전체 동기화는 API 연동 후 활성화됩니다.')
}
// summary computation
const validCount = resultData.filter(r => getStatus(r.expiry) !== 'expired').length
const soonList = resultData.filter(r => getStatus(r.expiry) === 'soon')
const expiredList = resultData.filter(r => getStatus(r.expiry) === 'expired')
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'auto' }}>
{/* ── 헤더 ── */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
<div style={{ fontSize: 18, fontWeight: 700, fontFamily: 'var(--fK)' }}>🛡 </div>
<div style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '3px 10px', borderRadius: 10,
fontSize: 10, fontWeight: 700, fontFamily: 'var(--fK)',
background: apiConnected ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
color: apiConnected ? 'var(--green)' : 'var(--red)',
border: `1px solid ${apiConnected ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: apiConnected ? 'var(--green)' : 'var(--red)', display: 'inline-block' }} />
{apiConnected ? 'API 연결됨' : 'API 미연결'}
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>(KSA) Open API · P&I </div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleTestConnect} style={{ padding: '8px 16px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔌 </button>
<button onClick={() => setShowConfig(v => !v)} style={{ padding: '8px 16px', background: 'var(--bg3)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}> API </button>
</div>
</div>
{/* ── API 설정 패널 ── */}
{showConfig && (
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '20px 24px', marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 14, color: 'var(--cyan)' }}> API </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Endpoint URL</label>
<input type="text" value={configEndpoint} onChange={e => setConfigEndpoint(e.target.value)} placeholder="https://api.haewoon.or.kr/v1/..."
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}>API Key</label>
<input type="password" value={configApiKey} onChange={e => setConfigApiKey(e.target.value)} placeholder="발급받은 API Key 입력"
style={{ width: '100%', padding: '9px 12px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 12, outline: 'none', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}> </label>
<select value={configKeyType} onChange={e => setConfigKeyType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
<option value="mmsi">MMSI</option>
<option value="imo">IMO </option>
<option value="shipname"></option>
<option value="callsign"></option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 5 }}> </label>
<select value={configRespType} onChange={e => setConfigRespType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', width: '100%' }}>
<option value="json">JSON</option>
<option value="xml">XML</option>
</select>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleSaveConfig} style={{ padding: '9px 20px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)' }}>💾 </button>
<button onClick={() => setShowConfig(false)} style={{ padding: '9px 16px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--fK)' }}></button>
</div>
{/* API 연동 안내 */}
<div style={{ marginTop: 16, padding: '12px 16px', background: 'rgba(6,182,212,.05)', border: '1px solid rgba(6,182,212,.15)', borderRadius: 'var(--rS)', fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.8 }}>
<span style={{ color: 'var(--cyan)', fontWeight: 700 }}>📋 API </span><br />
IT지원팀에 API <br />
<br />
데이터: P&I , , , , ,
</div>
</div>
)}
{/* ── 검색 영역 ── */}
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', padding: '18px 20px', marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', marginBottom: 12, color: 'var(--t2)' }}>🔍 </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}> </label>
<select value={searchType} onChange={e => setSearchType(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 120 }}>
<option value="mmsi">MMSI</option>
<option value="imo">IMO </option>
<option value="shipname"></option>
<option value="callsign"></option>
</select>
</div>
<div style={{ flex: 1, minWidth: 220 }}>
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}></label>
<input type="text" value={searchVal} onChange={e => setSearchVal(e.target.value)} placeholder={placeholderMap[searchType]}
style={{ width: '100%', padding: '9px 14px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', color: 'var(--t1)', fontFamily: 'var(--fM)', fontSize: 13, outline: 'none', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: 4 }}> </label>
<select value={insTypeFilter} onChange={e => setInsTypeFilter(e.target.value)} className="prd-i" style={{ borderColor: 'var(--bd)', minWidth: 140 }}>
<option></option>
<option>P&I </option>
<option></option>
<option>()</option>
<option></option>
</select>
</div>
<button onClick={handleQuery} style={{ padding: '9px 24px', background: 'linear-gradient(135deg, var(--cyan), var(--blue))', color: '#fff', border: 'none', borderRadius: 'var(--rS)', fontSize: 13, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>🔍 </button>
<button onClick={handleBatchQuery} style={{ padding: '9px 18px', background: 'rgba(168,85,247,.12)', color: 'var(--purple)', border: '1px solid rgba(168,85,247,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)', flexShrink: 0 }}>📋 </button>
</div>
</div>
{/* ── 결과 영역 ── */}
{/* 초기 안내 상태 */}
{viewState === 'empty' && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 20px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
<div style={{ fontSize: 48, marginBottom: 16, opacity: 0.3 }}>🛡</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 8 }}> API </div>
<div style={{ fontSize: 12, color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', lineHeight: 1.8 }}>
API API Key를 <br />
MMSI·IMO· .<br />
<span style={{ color: 'var(--cyan)' }}> </span> .
</div>
<div style={{ marginTop: 20, display: 'flex', gap: 10 }}>
<button onClick={() => setShowConfig(true)} style={{ padding: '10px 20px', background: 'rgba(6,182,212,.12)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,.3)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}> API </button>
<button onClick={loadDemoData} style={{ padding: '10px 20px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📊 </button>
</div>
</div>
)}
{/* 로딩 */}
{viewState === 'loading' && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 60, background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)' }}>
<div style={{ width: 36, height: 36, border: '3px solid var(--bd)', borderTopColor: 'var(--cyan)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', marginBottom: 14 }} />
<div style={{ fontSize: 13, color: 'var(--t2)', fontFamily: 'var(--fK)' }}> API ...</div>
</div>
)}
{/* 결과 테이블 */}
{viewState === 'result' && (
<>
{/* 요약 카드 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 14 }}>
{[
{ label: '전체', val: resultData.length, color: 'var(--cyan)', bg: 'rgba(6,182,212,.08)' },
{ label: '유효', val: validCount, color: 'var(--green)', bg: 'rgba(34,197,94,.08)' },
{ label: '만료임박(30일)', val: soonList.length, color: 'var(--yellow)', bg: 'rgba(234,179,8,.08)' },
{ label: '만료/미가입', val: resultData.length - validCount, color: 'var(--red)', bg: 'rgba(239,68,68,.08)' },
].map((c, i) => (
<div key={i} style={{ padding: '14px 16px', background: c.bg, border: `1px solid ${c.color}33`, borderRadius: 'var(--rS)', textAlign: 'center' }}>
<div style={{ fontSize: 22, fontWeight: 800, color: c.color, fontFamily: 'var(--fM)' }}>{c.val}</div>
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: 2 }}>{c.label}</div>
</div>
))}
</div>
{/* 테이블 */}
<div style={{ background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rM)', overflow: 'hidden', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--bd)' }}>
<div style={{ fontSize: 12, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)' }}> <span style={{ color: 'var(--cyan)' }}>{resultData.length}</span></div>
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={() => alert('엑셀 내보내기 기능은 실제 API 연동 후 활성화됩니다.')} style={{ padding: '5px 12px', background: 'rgba(34,197,94,.1)', color: 'var(--green)', border: '1px solid rgba(34,197,94,.25)', borderRadius: 'var(--rS)', fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'var(--fK)' }}>📥 </button>
<button onClick={handleQuery} style={{ padding: '5px 12px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', fontSize: 11, cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔄 </button>
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11, fontFamily: 'var(--fK)' }}>
<thead>
<tr style={{ background: 'var(--bg0)' }}>
{[
{ label: '선박명', align: 'left' },
{ label: 'MMSI', align: 'center' },
{ label: 'IMO', align: 'center' },
{ label: '보험종류', align: 'center' },
{ label: '보험사', align: 'center' },
{ label: '증권번호', align: 'center' },
{ label: '보험기간', align: 'center' },
{ label: '보상한도', align: 'right' },
{ label: '상태', align: 'center' },
].map((h, i) => (
<th key={i} style={{ padding: '10px 14px', textAlign: h.align as 'left' | 'center' | 'right', fontWeight: 700, color: 'var(--t2)', borderBottom: '1px solid var(--bd)', whiteSpace: 'nowrap' }}>{h.label}</th>
))}
</tr>
</thead>
<tbody>
{resultData.map((r, i) => {
const st = getStatus(r.expiry)
const isExp = st === 'expired'
const isSoon = st === 'soon'
return (
<tr key={i} style={{ borderBottom: '1px solid var(--bd)', background: isExp ? 'rgba(239,68,68,.03)' : undefined }}>
<td style={{ padding: '10px 14px', fontWeight: 600 }}>{r.shipName}</td>
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.mmsi || '—'}</td>
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11 }}>{r.imo || '—'}</td>
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insType}</td>
<td style={{ padding: '10px 14px', textAlign: 'center' }}>{r.insurer}</td>
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 10, color: 'var(--t3)' }}>{r.policyNo}</td>
<td style={{ padding: '10px 14px', textAlign: 'center', fontFamily: 'var(--fM)', fontSize: 11, color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : undefined, fontWeight: isExp || isSoon ? 700 : undefined }}>{r.start} ~ {r.expiry}</td>
<td style={{ padding: '10px 14px', textAlign: 'right', fontWeight: 700, fontFamily: 'var(--fM)' }}>{r.limit}</td>
<td style={{ padding: '10px 14px', textAlign: 'center' }}>
<span style={{
padding: '3px 10px', borderRadius: 10, fontSize: 10, fontWeight: 600,
background: isExp ? 'rgba(239,68,68,.15)' : isSoon ? 'rgba(234,179,8,.15)' : 'rgba(34,197,94,.15)',
color: isExp ? 'var(--red)' : isSoon ? 'var(--yellow)' : 'var(--green)',
}}>
{isExp ? '만료' : isSoon ? '만료임박' : '유효'}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* 경고 */}
{(expiredList.length > 0 || soonList.length > 0) && (
<div style={{ padding: '12px 16px', background: 'rgba(234,179,8,.06)', border: '1px solid rgba(234,179,8,.25)', borderRadius: 'var(--rS)', fontSize: 12, color: 'var(--t2)', fontFamily: 'var(--fK)', marginBottom: 12 }}>
{expiredList.length > 0 && (
<><span style={{ color: 'var(--red)', fontWeight: 700 }}> {expiredList.length}:</span> {expiredList.map(r => r.shipName).join(', ')}<br /></>
)}
{soonList.length > 0 && (
<><span style={{ color: 'var(--yellow)', fontWeight: 700 }}> (30) {soonList.length}:</span> {soonList.map(r => r.shipName).join(', ')}</>
)}
</div>
)}
</>
)}
{/* ── API 연동 정보 푸터 ── */}
<div style={{ marginTop: 16, padding: '12px 16px', background: 'var(--bg3)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.7 }}>
<span style={{ color: 'var(--t2)', fontWeight: 700 }}> :</span> (KSA) · haewoon.or.kr<br />
<span style={{ color: 'var(--t2)', fontWeight: 700 }}> :</span> REST API (JSON) · · TTL 1
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)' }}> :</span>
<span style={{ fontSize: 10, color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{lastSync}</span>
<button onClick={handleFullSync} style={{ padding: '4px 10px', background: 'var(--bg0)', color: 'var(--t2)', border: '1px solid var(--bd)', borderRadius: 4, fontSize: 10, cursor: 'pointer', fontFamily: 'var(--fK)' }}> </button>
</div>
</div>
</div>
)
}
export default ShipInsurance

파일 보기

@ -0,0 +1,755 @@
import type { AssetOrg, InsuranceRow } from './assetTypes'
export const organizations: AssetOrg[] = [
// ── 중부지방해양경찰청 ──
{
id: 1, type: '해경관할', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천해양경찰서',
address: '인천광역시 중구 북성동1가 80-8', vessel: 19, skimmer: 30, pump: 18, vehicle: 2, sprayer: 15, totalAssets: 234, phone: '010-4779-4191',
lat: 37.4563, lng: 126.5922, pinSize: 'hq',
equipment: [
{ category: '방제선', icon: '🚢', count: 19 }, { category: '유회수기', icon: '⚙', count: 30 }, { category: '비치크리너', icon: '🏖', count: 2 },
{ category: '이송펌프', icon: '🔧', count: 18 }, { category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 26 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 14 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 19 }, { category: '발전기', icon: '⚡', count: 9 },
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 9 }, { category: '장비부품', icon: '🔗', count: 46 },
{ category: '경비함정방제', icon: '⚓', count: 18 }, { category: '살포장치', icon: '🌊', count: 15 },
],
contacts: [{ role: '방제과장', name: '김○○', phone: '032-835-0001' }, { role: '방제담당', name: '이○○', phone: '032-835-0002' }],
},
{
id: 2, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택해양경찰서',
address: '평택시 만호리 706번지', vessel: 14, skimmer: 27, pump: 33, vehicle: 3, sprayer: 22, totalAssets: 193, phone: '010-9812-8102',
lat: 36.9694, lng: 126.8300, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 14 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 1 },
{ category: '이송펌프', icon: '🔧', count: 33 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 12 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 2 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 35 }, { category: '발전기', icon: '⚡', count: 9 },
{ category: '지원장비', icon: '🔩', count: 10 }, { category: '장비부품', icon: '🔗', count: 4 },
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 22 },
],
contacts: [{ role: '방제담당', name: '박○○', phone: '031-682-0001' }],
},
{
id: 3, type: '해경경찰서', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안해양경찰서',
address: '충남 태안군 근흥면 신진부두길2', vessel: 10, skimmer: 27, pump: 21, vehicle: 8, sprayer: 15, totalAssets: 185, phone: '010-2965-4423',
lat: 36.7456, lng: 126.2978, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 27 }, { category: '비치크리너', icon: '🏖', count: 4 },
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 8 }, { category: '해안운반차', icon: '🚜', count: 8 },
{ category: '고압세척기', icon: '💧', count: 14 }, { category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 28 }, { category: '발전기', icon: '⚡', count: 11 },
{ category: '지원장비', icon: '🔩', count: 16 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 15 },
],
contacts: [{ role: '방제담당', name: '최○○', phone: '041-674-0001' }],
},
{
id: 4, type: '파출소', jurisdiction: '중부지방해양경찰청', area: '보령', name: '보령해양경찰서',
address: '보령시 해안로 740', vessel: 3, skimmer: 8, pump: 5, vehicle: 3, sprayer: 11, totalAssets: 80, phone: '010-2940-6343',
lat: 36.3335, lng: 126.5874, pinSize: 'md',
equipment: [
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 5 },
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 22 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 6 },
{ category: '장비부품', icon: '🔗', count: 4 }, { category: '경비함정방제', icon: '⚓', count: 6 }, { category: '살포장치', icon: '🌊', count: 11 },
],
contacts: [{ role: '방제담당', name: '정○○', phone: '041-931-0001' }],
},
// ── 서해지방해양경찰청 ──
{
id: 5, type: '해경관할', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수해양경찰서',
address: '광양시 항만9로 89', vessel: 55, skimmer: 92, pump: 63, vehicle: 12, sprayer: 47, totalAssets: 464, phone: '010-2785-2493',
lat: 34.7407, lng: 127.7385, pinSize: 'hq',
equipment: [
{ category: '방제선', icon: '🚢', count: 55 }, { category: '유회수기', icon: '⚙', count: 92 }, { category: '비치크리너', icon: '🏖', count: 5 },
{ category: '이송펌프', icon: '🔧', count: 63 }, { category: '방제차량', icon: '🚛', count: 12 }, { category: '해안운반차', icon: '🚜', count: 4 },
{ category: '고압세척기', icon: '💧', count: 48 }, { category: '저압세척기', icon: '🚿', count: 7 }, { category: '동력분무기', icon: '💨', count: 25 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 37 }, { category: '발전기', icon: '⚡', count: 16 },
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 14 }, { category: '장비부품', icon: '🔗', count: 14 },
{ category: '경비함정방제', icon: '⚓', count: 22 }, { category: '살포장치', icon: '🌊', count: 47 },
],
contacts: [{ role: '방제과장', name: '윤○○', phone: '061-660-0001' }, { role: '방제담당', name: '장○○', phone: '061-660-0002' }],
},
{
id: 6, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포해양경찰서',
address: '목포시 고하대로 597번길 99-64', vessel: 10, skimmer: 19, pump: 18, vehicle: 3, sprayer: 16, totalAssets: 169, phone: '010-9812-8439',
lat: 34.7936, lng: 126.3839, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 19 }, { category: '이송펌프', icon: '🔧', count: 18 },
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 7 },
{ category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '지원장비', icon: '🔩', count: 31 },
{ category: '장비부품', icon: '🔗', count: 17 }, { category: '경비함정방제', icon: '⚓', count: 15 }, { category: '살포장치', icon: '🌊', count: 16 },
],
contacts: [{ role: '방제담당', name: '조○○', phone: '061-244-0001' }],
},
{
id: 7, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산해양경찰서',
address: '전북 군산시 오식도동 506', vessel: 6, skimmer: 22, pump: 12, vehicle: 3, sprayer: 17, totalAssets: 155, phone: '010-2618-3406',
lat: 35.9900, lng: 126.7133, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '비치크리너', icon: '🏖', count: 2 },
{ category: '이송펌프', icon: '🔧', count: 12 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 4 }, { category: '동력분무기', icon: '💨', count: 2 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 5 },
{ category: '현장지휘소', icon: '🏕', count: 3 }, { category: '지원장비', icon: '🔩', count: 11 }, { category: '장비부품', icon: '🔗', count: 50 },
{ category: '경비함정방제', icon: '⚓', count: 5 }, { category: '살포장치', icon: '🌊', count: 17 },
],
contacts: [{ role: '방제담당', name: '한○○', phone: '063-462-0001' }],
},
{
id: 8, type: '해경경찰서', jurisdiction: '서해지방해양경찰청', area: '완도', name: '완도해양경찰서',
address: '완도군 완도읍 장보고대로 383', vessel: 3, skimmer: 9, pump: 7, vehicle: 3, sprayer: 11, totalAssets: 75, phone: '061-550-2183',
lat: 34.3110, lng: 126.7550, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 7 },
{ category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 2 },
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 11 },
],
contacts: [{ role: '방제담당', name: '이○○', phone: '061-550-0001' }],
},
{
id: 9, type: '파출소', jurisdiction: '서해지방해양경찰청', area: '부안', name: '부안해양경찰서',
address: '전북 군산시 오식도동 506', vessel: 2, skimmer: 8, pump: 7, vehicle: 2, sprayer: 7, totalAssets: 66, phone: '063-928-xxxx',
lat: 35.7316, lng: 126.7328, pinSize: 'md',
equipment: [
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 8 }, { category: '이송펌프', icon: '🔧', count: 7 },
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '현장지휘소', icon: '🏕', count: 2 },
{ category: '지원장비', icon: '🔩', count: 6 }, { category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 7 },
],
contacts: [{ role: '방제담당', name: '김○○', phone: '063-928-0001' }],
},
// ── 남해지방해양경찰청 ──
{
id: 10, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산해양경찰서',
address: '부산시 영도구 해양로 293', vessel: 108, skimmer: 22, pump: 25, vehicle: 10, sprayer: 24, totalAssets: 313, phone: '010-2609-1456',
lat: 35.0746, lng: 129.0686, pinSize: 'hq',
equipment: [
{ category: '방제선', icon: '🚢', count: 108 }, { category: '유회수기', icon: '⚙', count: 22 }, { category: '이송펌프', icon: '🔧', count: 25 },
{ category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 38 },
{ category: '저압세척기', icon: '🚿', count: 8 }, { category: '동력분무기', icon: '💨', count: 6 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 11 }, { category: '현장지휘소', icon: '🏕', count: 2 },
{ category: '지원장비', icon: '🔩', count: 20 }, { category: '경비함정방제', icon: '⚓', count: 16 }, { category: '살포장치', icon: '🌊', count: 24 },
],
contacts: [{ role: '방제과장', name: '임○○', phone: '051-400-0001' }],
},
{
id: 11, type: '해경관할', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산해양경찰서',
address: '울산광역시 남구 장생포 고래로 166', vessel: 46, skimmer: 69, pump: 26, vehicle: 11, sprayer: 20, totalAssets: 311, phone: '010-9812-8210',
lat: 35.5008, lng: 129.3824, pinSize: 'hq',
equipment: [
{ category: '방제선', icon: '🚢', count: 46 }, { category: '유회수기', icon: '⚙', count: 69 }, { category: '비치크리너', icon: '🏖', count: 4 },
{ category: '이송펌프', icon: '🔧', count: 26 }, { category: '방제차량', icon: '🚛', count: 11 }, { category: '해안운반차', icon: '🚜', count: 5 },
{ category: '고압세척기', icon: '💧', count: 23 }, { category: '저압세척기', icon: '🚿', count: 6 }, { category: '동력분무기', icon: '💨', count: 6 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 32 }, { category: '발전기', icon: '⚡', count: 7 },
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 40 },
{ category: '경비함정방제', icon: '⚓', count: 14 }, { category: '살포장치', icon: '🌊', count: 20 },
],
contacts: [{ role: '방제과장', name: '강○○', phone: '052-228-0001' }],
},
{
id: 12, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원해양경찰서',
address: '창원시 마산합포구 신포동 1가', vessel: 12, skimmer: 25, pump: 14, vehicle: 10, sprayer: 10, totalAssets: 139, phone: '010-4634-7364',
lat: 35.1796, lng: 128.5681, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 12 }, { category: '유회수기', icon: '⚙', count: 25 }, { category: '비치크리너', icon: '🏖', count: 2 },
{ category: '이송펌프', icon: '🔧', count: 14 }, { category: '방제차량', icon: '🚛', count: 10 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 21 }, { category: '발전기', icon: '⚡', count: 4 },
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '지원장비', icon: '🔩', count: 20 },
{ category: '경비함정방제', icon: '⚓', count: 7 }, { category: '살포장치', icon: '🌊', count: 10 },
],
contacts: [{ role: '방제담당', name: '송○○', phone: '055-220-0001' }],
},
{
id: 13, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '통영', name: '통영해양경찰서',
address: '통영시 광도면 죽림리 1564-4', vessel: 6, skimmer: 15, pump: 9, vehicle: 5, sprayer: 13, totalAssets: 104, phone: '010-9812-8495',
lat: 34.8544, lng: 128.4331, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 15 }, { category: '비치크리너', icon: '🏖', count: 2 },
{ category: '이송펌프', icon: '🔧', count: 9 }, { category: '방제차량', icon: '🚛', count: 5 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 18 }, { category: '발전기', icon: '⚡', count: 4 },
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 11 },
{ category: '경비함정방제', icon: '⚓', count: 12 }, { category: '살포장치', icon: '🌊', count: 13 },
],
contacts: [{ role: '방제담당', name: '서○○', phone: '055-640-0001' }],
},
{
id: 14, type: '해경경찰서', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천해양경찰서',
address: '사천시 신항만길 1길 17', vessel: 2, skimmer: 9, pump: 6, vehicle: 2, sprayer: 7, totalAssets: 80, phone: '010-9812-8352',
lat: 34.9310, lng: 128.0660, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '이송펌프', icon: '🔧', count: 6 },
{ category: '방제차량', icon: '🚛', count: 2 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 31 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 2 },
{ category: '지원장비', icon: '🔩', count: 1 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 7 },
],
contacts: [{ role: '방제담당', name: '박○○', phone: '055-830-0001' }],
},
// ── 동해지방해양경찰청 ──
{
id: 15, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해해양경찰서',
address: '동해시 임항로 130', vessel: 6, skimmer: 23, pump: 11, vehicle: 6, sprayer: 14, totalAssets: 156, phone: '010-9812-8073',
lat: 37.5247, lng: 129.1143, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 6 }, { category: '유회수기', icon: '⚙', count: 23 }, { category: '비치크리너', icon: '🏖', count: 1 },
{ category: '이송펌프', icon: '🔧', count: 11 }, { category: '방제차량', icon: '🚛', count: 6 }, { category: '해안운반차', icon: '🚜', count: 3 },
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 5 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 38 }, { category: '발전기', icon: '⚡', count: 2 },
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 }, { category: '장비부품', icon: '🔗', count: 10 },
{ category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 14 },
],
contacts: [{ role: '방제담당', name: '남○○', phone: '033-530-0001' }],
},
{
id: 16, type: '해경경찰서', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항해양경찰서',
address: '포항시 남구 희망대로 1341', vessel: 10, skimmer: 13, pump: 21, vehicle: 4, sprayer: 21, totalAssets: 135, phone: '010-3108-2183',
lat: 36.0190, lng: 129.3651, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 10 }, { category: '유회수기', icon: '⚙', count: 13 }, { category: '비치크리너', icon: '🏖', count: 1 },
{ category: '이송펌프', icon: '🔧', count: 21 }, { category: '방제차량', icon: '🚛', count: 4 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 7 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 3 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 15 }, { category: '발전기', icon: '⚡', count: 5 },
{ category: '현장지휘소', icon: '🏕', count: 1 }, { category: '지원장비', icon: '🔩', count: 20 },
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 21 },
],
contacts: [{ role: '방제담당', name: '오○○', phone: '054-244-0001' }],
},
{
id: 17, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '속초', name: '속초해양경찰서',
address: '속초시 설악금강대교로 206', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 17, totalAssets: 85, phone: '033-634-2186',
lat: 38.2070, lng: 128.5918, pinSize: 'md',
equipment: [
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 2 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 16 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 11 },
{ category: '장비부품', icon: '🔗', count: 11 }, { category: '경비함정방제', icon: '⚓', count: 8 }, { category: '살포장치', icon: '🌊', count: 17 },
],
contacts: [{ role: '방제담당', name: '양○○', phone: '033-633-0001' }],
},
{
id: 18, type: '파출소', jurisdiction: '동해지방해양경찰청', area: '울진', name: '울진해양경찰서',
address: '울진군 후포면 후포리 623-148', vessel: 2, skimmer: 6, pump: 4, vehicle: 1, sprayer: 8, totalAssets: 66, phone: '010-9812-8076',
lat: 36.9932, lng: 129.4003, pinSize: 'md',
equipment: [
{ category: '방제선', icon: '🚢', count: 2 }, { category: '유회수기', icon: '⚙', count: 6 }, { category: '이송펌프', icon: '🔧', count: 4 },
{ category: '방제차량', icon: '🚛', count: 1 }, { category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 3 },
{ category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 13 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '현장지휘소', icon: '🏕', count: 2 },
{ category: '지원장비', icon: '🔩', count: 4 }, { category: '장비부품', icon: '🔗', count: 4 },
{ category: '경비함정방제', icon: '⚓', count: 10 }, { category: '살포장치', icon: '🌊', count: 8 },
],
contacts: [{ role: '방제담당', name: '배○○', phone: '054-782-0001' }],
},
// ── 제주지방해양경찰청 ──
{
id: 19, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주해양경찰서',
address: '제주시 임항로 85', vessel: 4, skimmer: 21, pump: 17, vehicle: 3, sprayer: 16, totalAssets: 113, phone: '064-766-2691',
lat: 33.5154, lng: 126.5268, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 4 }, { category: '유회수기', icon: '⚙', count: 21 }, { category: '비치크리너', icon: '🏖', count: 2 },
{ category: '이송펌프', icon: '🔧', count: 17 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 3 }, { category: '동력분무기', icon: '💨', count: 4 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '방제창고', icon: '🏭', count: 24 }, { category: '발전기', icon: '⚡', count: 6 },
{ category: '현장지휘소', icon: '🏕', count: 2 }, { category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 16 },
],
contacts: [{ role: '방제담당', name: '문○○', phone: '064-750-0001' }],
},
{
id: 20, type: '해경경찰서', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '서귀포해양경찰서',
address: '서귀포시 안덕면 화순해안로69', vessel: 3, skimmer: 9, pump: 15, vehicle: 3, sprayer: 14, totalAssets: 67, phone: '064-793-2186',
lat: 33.2469, lng: 126.5600, pinSize: 'lg',
equipment: [
{ category: '방제선', icon: '🚢', count: 3 }, { category: '유회수기', icon: '⚙', count: 9 }, { category: '비치크리너', icon: '🏖', count: 1 },
{ category: '이송펌프', icon: '🔧', count: 15 }, { category: '방제차량', icon: '🚛', count: 3 }, { category: '해안운반차', icon: '🚜', count: 1 },
{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '유량계측기', icon: '📏', count: 1 },
{ category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 3 },
{ category: '경비함정방제', icon: '⚓', count: 4 }, { category: '살포장치', icon: '🌊', count: 14 },
],
contacts: [{ role: '방제담당', name: '고○○', phone: '064-730-0001' }],
},
// ── 중앙특수구조단 ──
{
id: 21, type: '관련기관', jurisdiction: '해양경찰청(중앙)', area: '중앙', name: '중앙특수구조단',
address: '부산광역시 영도구 해양로 301', vessel: 1, skimmer: 0, pump: 5, vehicle: 2, sprayer: 0, totalAssets: 39, phone: '051-580-2044',
lat: 35.0580, lng: 129.0590, pinSize: 'md',
equipment: [
{ category: '방제선', icon: '🚢', count: 1 }, { category: '이송펌프', icon: '🔧', count: 5 }, { category: '방제차량', icon: '🚛', count: 2 },
{ category: '유량계측기', icon: '📏', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '지원장비', icon: '🔩', count: 27 },
{ category: '경비함정방제', icon: '⚓', count: 1 },
],
contacts: [{ role: '구조단장', name: '김○○', phone: '051-580-2044' }],
},
// ── 기름저장시설 ──
{
id: 22, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-686-3611',
lat: 34.745, lng: 127.745, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
},
{
id: 23, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '부산', name: 'SK에너지 외 2개',
address: '부산시 영도구 해양로 1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '051-643-3331',
lat: 35.175, lng: 129.075, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '051-643-3331' }],
},
{
id: 24, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK지오센트릭 외 5개',
address: '울산광역시 남구 신여천로 2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '052-208-2851',
lat: 35.535, lng: 129.305, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 4 }, { category: '방제창고', icon: '🏭', count: 5 }],
contacts: [{ role: '담당', name: 'SK엔텀㈜', phone: '052-208-2851' }],
},
{
id: 25, type: '기름저장시설', jurisdiction: '남해지방해양경찰청', area: '통영', name: '한국가스공사 통영기지본부',
address: '통영시 광도면 안정로 770', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-640-6014',
lat: 35.05, lng: 128.41, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '한국가스공사', phone: '055-640-6014' }],
},
{
id: 26, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '동해', name: 'HD현대오일뱅크㈜ 외 4개',
address: '강릉시 옥계면 동해대로 206', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '033-534-2093',
lat: 37.52, lng: 129.11, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '033-534-2093' }],
},
{
id: 27, type: '기름저장시설', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포스코케미칼 외 1개',
address: '포항시 남구 동해안로 6262', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 2, phone: '054-290-8222',
lat: 37.73, lng: 129.01, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: 'OCI(주)', phone: '054-290-8222' }],
},
{
id: 28, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '목포', name: '흑산도내연발전소 외 2개',
address: '전남 신안군 흑산일주로70', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-351-2342',
lat: 35.04, lng: 126.58, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: '안마도내연발전소', phone: '061-351-2342' }],
},
{
id: 29, type: '기름저장시설', jurisdiction: '서해지방해양경찰청', area: '여수', name: '오일허브코리아여수㈜ 외 4개',
address: '전남 여수시 신덕동 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '061-686-3611',
lat: 34.75, lng: 127.735, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
contacts: [{ role: '담당', name: '오일허브코리아여수㈜', phone: '061-686-3611' }],
},
{
id: 30, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '인천', name: 'GS칼텍스㈜ 외 10개',
address: '인천광역시 중구 월미로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 13, phone: '010-8777-6922',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 7 }, { category: '동력분무기', icon: '💨', count: 6 }],
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '010-8777-6922' }],
},
{
id: 31, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대케미칼 외 4개',
address: '충남 서산시 대산읍 평신2로 26', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '041-924-1068',
lat: 36.91, lng: 126.415, pinSize: 'md',
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }],
contacts: [{ role: '담당', name: 'HD현대케미칼', phone: '041-924-1068' }],
},
{
id: 32, type: '기름저장시설', jurisdiction: '중부지방해양경찰청', area: '평택', name: '현대오일터미널(주) 외 4개',
address: '평택시 포승읍 포승공단순환로 11', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 4, phone: '031-683-5101',
lat: 36.985, lng: 126.835, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '(주)경동탱크터미널', phone: '031-683-5101' }],
},
// ── 기타 ──
{
id: 33, type: '기타', jurisdiction: '남해지방해양경찰청', area: '사천', name: '한국남동발전(주) 외 2개',
address: '고성군 하이면 하이로1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '070-4486-7474',
lat: 34.965, lng: 128.56, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }],
contacts: [{ role: '담당', name: '(주)고성그린파워', phone: '070-4486-7474' }],
},
{
id: 34, type: '기타', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'HD현대미포',
address: '울산광역시 동구 방어진순환도로100', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '052-250-3551',
lat: 35.53, lng: 129.315, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: 'HD현대미포', phone: '052-250-3551' }],
},
{
id: 35, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '055-630-5373',
lat: 35.05, lng: 128.41, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
},
{
id: 36, type: '기타', jurisdiction: '동해지방해양경찰청', area: '동해', name: '한국남부발전㈜',
address: '삼척시 원덕읍 삼척로 734', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '070-7713-5153',
lat: 37.45, lng: 129.17, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
contacts: [{ role: '담당', name: '한국남부발전㈜', phone: '070-7713-5153' }],
},
{
id: 37, type: '기타', jurisdiction: '동해지방해양경찰청', area: '울진', name: '한울원전',
address: '울진군 북면 울진북로 2040', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '054-785-4833',
lat: 37.065, lng: 129.39, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }, { category: '지원장비', icon: '🔩', count: 2 }],
contacts: [{ role: '담당', name: '한울원전', phone: '054-785-4833' }],
},
{
id: 38, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 16, phone: '061-791-0358',
lat: 34.755, lng: 127.73, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 7 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '지원장비', icon: '🔩', count: 3 }],
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
},
{
id: 39, type: '기타', jurisdiction: '중부지방해양경찰청', area: '인천', name: '삼광조선공업㈜ 외 1개',
address: '인천 동구 보세로42번길41', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '010-3321-2959',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }],
contacts: [{ role: '담당', name: '삼광조선공업㈜', phone: '010-3321-2959' }],
},
// ── 방제유창청소업체 ──
{
id: 40, type: '업체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '방제유창청소업체(㈜클린포트)',
address: '㈜클린포트', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '032-882-8279',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: '㈜클린포트', phone: '032-882-8279' }],
},
{
id: 41, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(대용환경㈜ 외 38개)',
address: '㈜태평양해양산업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 51, phone: '051-242-0622',
lat: 35.18, lng: 129.085, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 31 }, { category: '저압세척기', icon: '🚿', count: 5 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 6 }, { category: '현장지휘소', icon: '🏕', count: 1 }],
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
},
{
id: 42, type: '업체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '방제유창청소업체((주)한유마린서비스 외 8개)',
address: '대상해운(주)', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '010-5499-7401',
lat: 35.54, lng: 129.295, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 11 }, { category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '(주)골든씨', phone: '010-5499-7401' }],
},
{
id: 43, type: '업체', jurisdiction: '동해지방해양경찰청', area: '포항', name: '방제유창청소업체(블루씨 외 1개)',
address: '(주)블루씨', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 3, phone: '054-278-8200',
lat: 36.015, lng: 129.365, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '(주)블루씨', phone: '054-278-8200' }],
},
{
id: 44, type: '업체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '방제유창청소업체(㈜한국해운 외 1개)',
address: '㈜한국해운 목포지사', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '010-8615-4326',
lat: 35.04, lng: 126.58, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
contacts: [{ role: '담당', name: '㈜아라', phone: '010-8615-4326' }],
},
{
id: 45, type: '업체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '방제유창청소업체(마로해운 외 11개)',
address: '㈜우진실업', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 54, phone: '061-654-9603',
lat: 34.74, lng: 127.75, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 28 }, { category: '동력분무기', icon: '💨', count: 15 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 2 }],
contacts: [{ role: '담당', name: '(유)피케이엘', phone: '061-654-9603' }],
},
{
id: 46, type: '업체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '방제유창청소업체(우진해운㈜)',
address: '우진해운㈜', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '010-4384-6817',
lat: 36.905, lng: 126.42, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
contacts: [{ role: '담당', name: '우진해운㈜', phone: '010-4384-6817' }],
},
{
id: 47, type: '업체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '방제유창청소업체((주)씨앤 외 3개)',
address: '㈜씨앤', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '031-683-2389',
lat: 36.99, lng: 126.825, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }],
contacts: [{ role: '담당', name: '(주)소스코리아', phone: '031-683-2389' }],
},
{
id: 48, type: '업체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '방제유창청소업체(㈜지앤비마린서비스)',
address: '㈜지앤비마린서비스', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '051-242-0622',
lat: 35.185, lng: 129.07, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '(주)경원마린서비스', phone: '051-242-0622' }],
},
// ── 정유사 ──
{
id: 49, type: '정유사', jurisdiction: '남해지방해양경찰청', area: '울산', name: 'SK엔텀(주) 외 4개',
address: '울산광역시 남구 고사동 110-64', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '052-231-2318',
lat: 35.545, lng: 129.31, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
contacts: [{ role: '담당', name: 'S-OIL㈜', phone: '052-231-2318' }],
},
{
id: 50, type: '정유사', jurisdiction: '서해지방해양경찰청', area: '여수', name: 'GS칼텍스㈜',
address: '여수시 낙포단지길 251', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 20, totalAssets: 27, phone: '061-680-2121',
lat: 34.735, lng: 127.755, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '살포장치', icon: '🌊', count: 20 }],
contacts: [{ role: '담당', name: 'GS칼텍스㈜', phone: '061-680-2121' }],
},
{
id: 51, type: '정유사', jurisdiction: '중부지방해양경찰청', area: '태안', name: 'HD현대오일뱅크㈜',
address: '서산시 대산읍 평신2로 182', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-2050-5291',
lat: 36.915, lng: 126.41, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 2 }],
contacts: [{ role: '담당', name: 'HD현대오일뱅크㈜', phone: '010-2050-5291' }],
},
// ── 지자체 ──
{
id: 52, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산광역시 외 8개',
address: '부산광역시 동구 좌천동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 12, phone: '051-607-4484',
lat: 35.17, lng: 129.08, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 11 }],
contacts: [{ role: '담당', name: '남구청', phone: '051-607-4484' }],
},
{
id: 53, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '사천', name: '사천시 외 3개',
address: '사천시 신항로 3', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '055-670-2484',
lat: 34.935, lng: 128.075, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 6 }],
contacts: [{ role: '담당', name: '고성군', phone: '055-670-2484' }],
},
{
id: 54, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산북구청 외 2개',
address: '울산광역시 북구 구유동 654-2', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '051-709-4611',
lat: 35.55, lng: 129.32, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 4 }],
contacts: [{ role: '담당', name: '부산기장군청', phone: '051-709-4611' }],
},
{
id: 55, type: '지자체', jurisdiction: '남해지방해양경찰청', area: '창원', name: '창원 진해구 외 1개',
address: '창원시 진해구 천자로 105', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '051-970-4482',
lat: 35.055, lng: 128.645, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '부산 강서구', phone: '051-970-4482' }],
},
{
id: 56, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '동해', name: '삼척시 외 1개',
address: '삼척시 근덕면 덕산리 107-74', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 4, phone: '033-640-5284',
lat: 37.45, lng: 129.16, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 3 }],
contacts: [{ role: '담당', name: '강릉시', phone: '033-640-5284' }],
},
{
id: 57, type: '지자체', jurisdiction: '동해지방해양경찰청', area: '울진', name: '영덕군',
address: '남정면 장사리 74-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '054-730-6562',
lat: 36.35, lng: 129.4, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
contacts: [{ role: '담당', name: '영덕군', phone: '054-730-6562' }],
},
{
id: 58, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '목포', name: '영광군 외 1개',
address: '영광군 염산면 향화로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '061-270-3419',
lat: 35.04, lng: 126.58, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 5 }],
contacts: [{ role: '담당', name: '목포시', phone: '061-270-3419' }],
},
{
id: 59, type: '지자체', jurisdiction: '서해지방해양경찰청', area: '여수', name: '광양시 외 1개',
address: '순천시 진상면 성지로 8', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-797-2791',
lat: 34.76, lng: 127.725, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 3 }],
contacts: [{ role: '담당', name: '광양시', phone: '061-797-2791' }],
},
{
id: 60, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '인천', name: '옹진군청 외 4개',
address: '인천광역시 옹진군 덕적면 진리 387', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '010-2740-9388',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '동력분무기', icon: '💨', count: 4 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: '김포시청', phone: '010-2740-9388' }],
},
{
id: 61, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '태안', name: '태안군청',
address: '충남 태안군 근흥면 신진도리 75-36', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '041-670-2877',
lat: 36.745, lng: 126.305, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '태안군청', phone: '041-670-2877' }],
},
{
id: 62, type: '지자체', jurisdiction: '중부지방해양경찰청', area: '평택', name: '안산시청 외 2개',
address: '경기도 안산시 단원구 진두길 97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 6, phone: '041-350-4292',
lat: 37.32, lng: 126.83, pinSize: 'md',
equipment: [{ category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }],
contacts: [{ role: '담당', name: '당진시청', phone: '041-350-4292' }],
},
// ── 하역시설 ──
{
id: 63, type: '기타', jurisdiction: '서해지방해양경찰청', area: '여수', name: '㈜HR-PORT 외 5개',
address: '여수시 제철로', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '061-791-0358',
lat: 34.748, lng: 127.74, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '㈜ 한진', phone: '061-791-0358' }],
},
// ── 해군 ──
{
id: 64, type: '해군', jurisdiction: '동해지방해양경찰청', area: '동해', name: '해군1함대사령부 외 1개',
address: '동해시 대동로 430', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '033-539-7323',
lat: 37.525, lng: 129.115, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 1 }],
contacts: [{ role: '담당', name: '1함대 사령부', phone: '033-539-7323' }],
},
{
id: 65, type: '해군', jurisdiction: '중부지방해양경찰청', area: '인천', name: '해병대 제9518부대',
address: '인천광역시 옹진군', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '010-4801-3473',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '발전기', icon: '⚡', count: 2 }],
contacts: [{ role: '담당', name: '해병대 제9518부대', phone: '010-4801-3473' }],
},
// ── 해양환경공단 ──
{
id: 66, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '부산', name: '부산지사',
address: '창원시 진해구 안골동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 6, totalAssets: 14, phone: '051-466-3944',
lat: 35.105, lng: 128.715, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 6 }],
contacts: [{ role: '담당', name: '부산지사', phone: '051-466-3944' }],
},
{
id: 67, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '사천', name: '마산지사',
address: '사천시 신항만1길 23', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 9, phone: '010-3598-4202',
lat: 34.925, lng: 128.065, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: '마산지사', phone: '010-3598-4202' }],
},
{
id: 68, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '울산', name: '울산지사',
address: '울산광역시 남구 장생포고래로 276번길 27', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 16, phone: '052-238-7718',
lat: 35.538, lng: 129.3, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 6 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '울산지사', phone: '052-238-7718' }],
},
{
id: 69, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '창원', name: '마산지사',
address: '창원시 마산합포구 드림베이대로59', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 7, phone: '010-2265-3928',
lat: 35.055, lng: 128.645, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 4 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '마산지사', phone: '010-2265-3928' }],
},
{
id: 70, type: '해양환경공단', jurisdiction: '남해지방해양경찰청', area: '통영', name: '마산지사',
address: '거제시 장승로 112', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 8, phone: '010-2636-5313',
lat: 35.05, lng: 128.41, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 8 }],
contacts: [{ role: '담당', name: '마산지사', phone: '010-2636-5313' }],
},
{
id: 71, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '동해', name: '동해지사',
address: '동해시 대동로 210', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 17, phone: '010-7499-0257',
lat: 37.515, lng: 129.105, pinSize: 'md',
equipment: [{ category: '해안운반차', icon: '🚜', count: 2 }, { category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 8 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
contacts: [{ role: '담당', name: '동해지사', phone: '010-7499-0257' }],
},
{
id: 72, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '울진', name: '포항지사',
address: '울진군 죽변면 죽변리 36-88', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 2, phone: '054-273-5595',
lat: 37.06, lng: 129.42, pinSize: 'md',
equipment: [{ category: '방제창고', icon: '🏭', count: 2 }],
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
},
{
id: 73, type: '해양환경공단', jurisdiction: '동해지방해양경찰청', area: '포항', name: '포항지사',
address: '포항시 북구 해안로 44-10', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 2, totalAssets: 8, phone: '054-273-5595',
lat: 36.025, lng: 129.375, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 2 }, { category: '현장지휘소', icon: '🏕', count: 1 }, { category: '살포장치', icon: '🌊', count: 2 }],
contacts: [{ role: '담당', name: '포항지사', phone: '054-273-5595' }],
},
{
id: 74, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '군산', name: '군산지사',
address: '군산시 임해로 452', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 4, totalAssets: 12, phone: '063-443-4813',
lat: 35.975, lng: 126.715, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 2 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 4 }],
contacts: [{ role: '담당', name: '군산지사', phone: '063-443-4813' }],
},
{
id: 75, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '목포', name: '목포지사',
address: '전남 목포시 죽교동 683', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 10, phone: '061-242-9663',
lat: 35.04, lng: 126.58, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 6 }, { category: '발전기', icon: '⚡', count: 1 }],
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
},
{
id: 76, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '여수', name: '여수지사',
address: '여수시 덕충동', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 12, phone: '061-654-6431',
lat: 34.742, lng: 127.748, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 3 }],
contacts: [{ role: '담당', name: '여수지사', phone: '061-654-6431' }],
},
{
id: 77, type: '해양환경공단', jurisdiction: '서해지방해양경찰청', area: '완도', name: '목포지사 완도사업소',
address: '완도군 완도읍 해변공원로 20-1', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 3, phone: '061-242-9663',
lat: 34.315, lng: 126.755, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 2 }],
contacts: [{ role: '담당', name: '목포지사', phone: '061-242-9663' }],
},
{
id: 78, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '서귀포', name: '제주지사(서귀포)',
address: '서귀포시 칠십리로72번길 14', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 1, phone: '064-753-4356',
lat: 33.245, lng: 126.565, pinSize: 'md',
equipment: [{ category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
},
{
id: 79, type: '해양환경공단', jurisdiction: '제주지방해양경찰청', area: '제주', name: '제주지사(제주)',
address: '제주시 임항로97', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 3, totalAssets: 20, phone: '064-753-4356',
lat: 33.517, lng: 126.528, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 2 }, { category: '방제창고', icon: '🏭', count: 10 }, { category: '발전기', icon: '⚡', count: 1 }, { category: '살포장치', icon: '🌊', count: 3 }],
contacts: [{ role: '담당', name: '제주지사', phone: '064-753-4356' }],
},
{
id: 80, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '보령', name: '대산지사(보령)',
address: '보령시 해안로 740', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 5, phone: '041-664-9101',
lat: 36.333, lng: 126.612, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }, { category: '방제창고', icon: '🏭', count: 4 }],
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
},
{
id: 81, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '인천', name: '인천지사',
address: '인천광역시 중구 연안부두로 128번길 35', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 11, phone: '010-7133-2167',
lat: 37.45, lng: 126.505, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 3 }, { category: '발전기', icon: '⚡', count: 2 }],
contacts: [{ role: '담당', name: '인천지사', phone: '010-7133-2167' }],
},
{
id: 82, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '태안', name: '대산지사(태안)',
address: '서산시 대산읍 대죽1로 325', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 17, phone: '041-664-9101',
lat: 36.908, lng: 126.413, pinSize: 'md',
equipment: [{ category: '해안운반차', icon: '🚜', count: 1 }, { category: '고압세척기', icon: '💧', count: 5 }, { category: '저압세척기', icon: '🚿', count: 1 }, { category: '동력분무기', icon: '💨', count: 1 }, { category: '방제창고', icon: '🏭', count: 5 }, { category: '발전기', icon: '⚡', count: 3 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '대산지사', phone: '041-664-9101' }],
},
{
id: 83, type: '해양환경공단', jurisdiction: '중부지방해양경찰청', area: '평택', name: '평택지사',
address: '당진시 송악읍 고대공단2길', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 1, totalAssets: 13, phone: '031-683-7973',
lat: 36.905, lng: 126.635, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 3 }, { category: '저압세척기', icon: '🚿', count: 2 }, { category: '방제창고', icon: '🏭', count: 3 }, { category: '발전기', icon: '⚡', count: 4 }, { category: '살포장치', icon: '🌊', count: 1 }],
contacts: [{ role: '담당', name: '평택지사', phone: '031-683-7973' }],
},
// ── 수협 ──
{
id: 84, type: '기타', jurisdiction: '남해지방해양경찰청', area: '통영', name: '삼성중공업 외 1개',
address: '거제시 장평3로 80', vessel: 0, skimmer: 0, pump: 0, vehicle: 0, sprayer: 0, totalAssets: 1, phone: '055-630-5373',
lat: 35.05, lng: 128.41, pinSize: 'md',
equipment: [{ category: '고압세척기', icon: '💧', count: 1 }],
contacts: [{ role: '담당', name: '삼성중공업', phone: '055-630-5373' }],
},
]
export const insuranceDemoData: InsuranceRow[] = [
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
{ shipName: '화학물질운반선 제주호', mmsi: '440345678', imo: '9876002', insType: '선주책임보험', insurer: '삼성화재', policyNo: 'SF-2025-9012', start: '2025-09-16', expiry: '2026-09-15', limit: '80억' },
{ shipName: '방제선 OCEAN STAR', mmsi: '440123789', imo: '9876003', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-3456', start: '2025-11-21', expiry: '2026-11-20', limit: '120억' },
{ shipName: 'LNG운반선 부산호', mmsi: '440567890', imo: '9876004', insType: '해상보험', insurer: 'DB손해보험', policyNo: 'DB-2025-7890', start: '2025-08-02', expiry: '2026-08-01', limit: '200억' },
{ shipName: '유조선 백두호', mmsi: '440789012', imo: '9876005', insType: 'P&I 보험', insurer: 'SK해운보험', policyNo: 'MH-2025-5678', start: '2025-01-01', expiry: '2025-12-31', limit: '30억' },
]
export const uploadHistory = [
{ filename: '여수서_장비자재_2601.xlsx', date: '2026-01-25 14:30', uploader: '남해청_방제과', count: 45 },
{ filename: '인천서_오일펜스현황.xlsx', date: '2026-01-22 10:15', uploader: '중부청_방제과', count: 12 },
{ filename: '전체_방제정_현황.xlsx', date: '2026-01-20 09:00', uploader: '본청_방제과', count: 18 },
]

파일 보기

@ -0,0 +1,65 @@
export type AssetsTab = 'management' | 'upload' | 'theory' | 'insurance'
export interface AssetOrg {
id: number
type: string
jurisdiction: string
area: string
name: string
address: string
vessel: number
skimmer: number
pump: number
vehicle: number
sprayer: number
totalAssets: number
phone: string
lat: number
lng: number
pinSize: 'hq' | 'lg' | 'md'
equipment: { category: string; icon: string; count: number }[]
contacts: { role: string; name: string; phone: string }[]
}
export interface InsuranceRow {
shipName: string
mmsi: string
imo: string
insType: string
insurer: string
policyNo: string
start: string
expiry: string
limit: string
}
export const typeTagCls = (type: string) => {
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-status-red'
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-primary-blue'
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-status-green'
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-primary-purple'
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-primary-cyan'
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-status-orange'
if (type === '지자체') return 'bg-[rgba(236,72,153,0.1)] text-[#ec4899]'
if (type === '기름저장시설') return 'bg-[rgba(139,92,246,0.1)] text-[#8b5cf6]'
if (type === '정유사') return 'bg-[rgba(20,184,166,0.1)] text-[#14b8a6]'
if (type === '해군') return 'bg-[rgba(100,116,139,0.1)] text-[#64748b]'
if (type === '기타') return 'bg-[rgba(107,114,128,0.1)] text-[#6b7280]'
return 'bg-[rgba(156,163,175,0.1)] text-[#9ca3af]'
}
export const typeColor = (type: string) => {
switch (type) {
case '해경관할': return { bg: 'rgba(6,182,212,0.3)', border: '#06b6d4', selected: '#22d3ee' }
case '해경경찰서': return { bg: 'rgba(59,130,246,0.3)', border: '#3b82f6', selected: '#60a5fa' }
case '파출소': return { bg: 'rgba(34,197,94,0.3)', border: '#22c55e', selected: '#4ade80' }
case '관련기관': return { bg: 'rgba(168,85,247,0.3)', border: '#a855f7', selected: '#c084fc' }
case '해양환경공단': return { bg: 'rgba(20,184,166,0.3)', border: '#14b8a6', selected: '#2dd4bf' }
case '업체': return { bg: 'rgba(245,158,11,0.3)', border: '#f59e0b', selected: '#fbbf24' }
case '지자체': return { bg: 'rgba(236,72,153,0.3)', border: '#ec4899', selected: '#f472b6' }
case '기름저장시설': return { bg: 'rgba(139,92,246,0.3)', border: '#8b5cf6', selected: '#a78bfa' }
case '정유사': return { bg: 'rgba(13,148,136,0.3)', border: '#0d9488', selected: '#2dd4bf' }
case '해군': return { bg: 'rgba(100,116,139,0.3)', border: '#64748b', selected: '#94a3b8' }
default: return { bg: 'rgba(107,114,128,0.3)', border: '#6b7280', selected: '#9ca3af' }
}
}

파일 보기

@ -0,0 +1,196 @@
import { useState, useMemo } from 'react'
import { LayerTree } from '@common/components/layer/LayerTree'
import { useLayerTree } from '@common/hooks/useLayers'
import { layerData } from '../../../data/layerData'
import type { LayerNode } from '../../../data/layerData'
import type { Layer } from '../../../data/layerDatabase'
interface InfoLayerSectionProps {
expanded: boolean
onToggle: () => void
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
layerOpacity: number
onLayerOpacityChange: (val: number) => void
layerBrightness: number
onLayerBrightnessChange: (val: number) => void
}
const InfoLayerSection = ({
expanded,
onToggle,
enabledLayers,
onToggleLayer,
layerOpacity,
onLayerOpacityChange,
layerBrightness,
onLayerBrightnessChange,
}: InfoLayerSectionProps) => {
// API에서 레이어 트리 데이터 가져오기
const { data: layerTree, isLoading } = useLayerTree()
const [layerColors, setLayerColors] = useState<Record<string, string>>({})
// 정적 데이터를 Layer 형식으로 변환 (API 실패 시 폴백)
const staticLayers = useMemo(() => {
const convert = (node: LayerNode): Layer => ({
id: node.code,
parentId: node.parentCode,
name: node.name,
fullName: node.fullName,
level: node.level,
wmsLayer: node.layerName,
icon: node.icon,
count: node.count,
children: node.children?.map(convert),
})
return layerData.map(convert)
}, [])
// API 데이터 우선, 실패 시 정적 데이터 폴백
const effectiveLayers = (layerTree && layerTree.length > 0) ? layerTree : staticLayers
return (
<div className="border-b border-border">
<div
className="flex items-center justify-between p-4 hover:bg-[rgba(255,255,255,0.02)]"
>
<h3
onClick={onToggle}
className="text-[13px] font-bold text-text-1 font-korean cursor-pointer"
>
📂
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={(e) => {
e.stopPropagation()
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = []
layers?.forEach(layer => {
ids.push(layer.id)
if (layer.children) {
ids.push(...getAllLayerIds(layer.children))
}
})
return ids
}
const allIds = getAllLayerIds(effectiveLayers)
allIds.forEach(id => onToggleLayer(id, true))
}}
style={{
padding: '4px 8px',
fontSize: '10px',
fontWeight: 600,
fontFamily: 'var(--fK)',
border: '1px solid var(--cyan)',
borderRadius: 'var(--rS)',
background: 'transparent',
color: 'var(--cyan)',
cursor: 'pointer',
transition: '0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(6,182,212,0.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
</button>
<button
onClick={(e) => {
e.stopPropagation()
// Get all layer IDs from layerTree recursively
const getAllLayerIds = (layers: Layer[]): string[] => {
const ids: string[] = []
layers?.forEach(layer => {
ids.push(layer.id)
if (layer.children) {
ids.push(...getAllLayerIds(layer.children))
}
})
return ids
}
const allIds = getAllLayerIds(effectiveLayers)
allIds.forEach(id => onToggleLayer(id, false))
}}
style={{
padding: '4px 8px',
fontSize: '10px',
fontWeight: 600,
fontFamily: 'var(--fK)',
border: '1px solid var(--red)',
borderRadius: 'var(--rS)',
background: 'transparent',
color: 'var(--red)',
cursor: 'pointer',
transition: '0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239,68,68,0.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
</button>
<span
onClick={onToggle}
className="text-[10px] text-text-3 cursor-pointer"
>
{expanded ? '▼' : '▶'}
</span>
</div>
</div>
{expanded && (
<div className="px-4 pb-2">
{isLoading && effectiveLayers.length === 0 ? (
<p className="text-[11px] text-text-3 py-2"> ...</p>
) : effectiveLayers.length === 0 ? (
<p className="text-[11px] text-text-3 py-2"> .</p>
) : (
<LayerTree
layers={effectiveLayers}
enabledLayers={enabledLayers}
onToggleLayer={onToggleLayer}
layerColors={layerColors}
onColorChange={(id, color) => setLayerColors(prev => ({ ...prev, [id]: color }))}
/>
)}
{/* 레이어 스타일 조절 */}
<div className="lyr-style-box">
<div className="lyr-style-label"> </div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0} max={100} value={layerOpacity}
onChange={e => onLayerOpacityChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerOpacity}%</span>
</div>
<div className="lyr-style-row">
<span className="lyr-style-name"></span>
<input
type="range"
className="lyr-style-slider"
min={0} max={100} value={layerBrightness}
onChange={e => onLayerBrightnessChange(Number(e.target.value))}
/>
<span className="lyr-style-val">{layerBrightness}%</span>
</div>
</div>
</div>
)}
</div>
)
}
export default InfoLayerSection

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,548 @@
import { useState } from 'react'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import { generateAIBoomLines, runContainmentAnalysis, computePolylineLength, computeBearing } from '@common/utils/geo'
interface OilBoomSectionProps {
expanded: boolean
onToggle: () => void
boomLines: BoomLine[]
onBoomLinesChange: (lines: BoomLine[]) => void
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>
incidentCoord: { lon: number; lat: number }
algorithmSettings: AlgorithmSettings
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void
isDrawingBoom: boolean
onDrawingBoomChange: (drawing: boolean) => void
drawingPoints: BoomLineCoord[]
onDrawingPointsChange: (points: BoomLineCoord[]) => void
containmentResult: ContainmentResult | null
onContainmentResultChange: (result: ContainmentResult | null) => void
}
const OilBoomSection = ({
expanded,
onToggle,
boomLines,
onBoomLinesChange,
oilTrajectory,
incidentCoord,
algorithmSettings,
onAlgorithmSettingsChange,
isDrawingBoom,
onDrawingBoomChange,
drawingPoints,
onDrawingPointsChange,
containmentResult,
onContainmentResultChange,
}: OilBoomSectionProps) => {
const [boomPlacementTab, setBoomPlacementTab] = useState<'ai' | 'manual' | 'simulation'>('simulation')
return (
<div className="border-b border-border">
<div
onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
>
<h3 className="text-[13px] font-bold text-text-2 font-korean">
🛡
</h3>
<span className="text-[10px] text-text-3">
{expanded ? '▼' : '▶'}
</span>
</div>
{expanded && (
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Tab Buttons + Reset */}
<div style={{ display: 'flex', gap: '6px' }}>
{[
{ id: 'ai' as const, label: 'AI 자동 추천' },
{ id: 'manual' as const, label: '수동 배치' },
{ id: 'simulation' as const, label: '시뮬레이션' }
].map(tab => (
<button
key={tab.id}
onClick={() => setBoomPlacementTab(tab.id)}
style={{
flex: 1,
padding: '6px 8px',
fontSize: '10px',
fontWeight: 600,
fontFamily: 'var(--fK)',
borderRadius: 'var(--rS)',
border: boomPlacementTab === tab.id ? '1px solid var(--orange)' : '1px solid var(--bd)',
background: boomPlacementTab === tab.id ? 'rgba(245,158,11,0.1)' : 'var(--bg0)',
color: boomPlacementTab === tab.id ? 'var(--orange)' : 'var(--t3)',
cursor: 'pointer',
transition: '0.15s'
}}
>
{tab.label}
</button>
))}
<button
onClick={() => {
onBoomLinesChange([])
onDrawingBoomChange(false)
onDrawingPointsChange([])
onContainmentResultChange(null)
onAlgorithmSettingsChange({
currentOrthogonalCorrection: 15,
safetyMarginMinutes: 60,
minContainmentEfficiency: 80,
waveHeightCorrectionFactor: 1.0,
})
}}
disabled={boomLines.length === 0 && !isDrawingBoom && !containmentResult}
style={{
padding: '6px 10px',
fontSize: '10px',
fontWeight: 600,
fontFamily: 'var(--fK)',
borderRadius: 'var(--rS)',
border: '1px solid var(--bd)',
background: 'var(--bg0)',
color: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'var(--t3)' : 'var(--red)',
cursor: (boomLines.length === 0 && !isDrawingBoom && !containmentResult) ? 'not-allowed' : 'pointer',
transition: '0.15s',
flexShrink: 0,
}}
>
</button>
</div>
{/* Key Metrics (동적) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px' }}>
{[
{ value: String(boomLines.length), label: '배치 라인', color: 'var(--orange)' },
{ value: boomLines.length > 0 ? `${(boomLines.reduce((s, l) => s + l.length, 0) / 1000).toFixed(1)}km` : '0km', label: '총 길이', color: 'var(--cyan)' },
{ value: boomLines.length > 0 ? `${Math.round(boomLines.reduce((s, l) => s + l.efficiency, 0) / boomLines.length)}%` : '—', label: '평균 효율', color: 'var(--orange)' }
].map((metric, idx) => (
<div key={idx} style={{
padding: '10px 8px',
background: 'var(--bg0)',
border: '1px solid var(--bd)',
borderRadius: 'var(--rS)',
textAlign: 'center'
}}>
<div style={{ fontSize: '18px', fontWeight: 700, color: metric.color, fontFamily: 'var(--fM)', marginBottom: '2px' }}>
{metric.value}
</div>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
{metric.label}
</div>
</div>
))}
</div>
{/* ===== AI 자동 추천 탭 ===== */}
{boomPlacementTab === 'ai' && (
<>
<div style={{
padding: '12px',
background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)',
borderRadius: 'var(--rM)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '8px' }}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }} />
<span style={{ fontSize: '10px', fontWeight: 700, color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)', fontFamily: 'var(--fK)' }}>
{oilTrajectory.length > 0 ? '확산 데이터 준비 완료' : '확산 예측을 먼저 실행하세요'}
</span>
</div>
<h4 style={{ fontSize: '13px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)', marginBottom: '8px' }}>
</h4>
<p style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: '1.5', marginBottom: '10px' }}>
{oilTrajectory.length > 0
? '확산 궤적을 분석하여 해류 직교 방향 1차 방어선, U형 포위 2차 방어선, 연안 보호 3차 방어선을 자동 생성합니다.'
: '상단에서 확산 예측을 실행한 뒤 AI 배치를 적용할 수 있습니다.'
}
</p>
<button
onClick={() => {
const lines = generateAIBoomLines(
oilTrajectory,
{ lat: incidentCoord.lat, lon: incidentCoord.lon },
algorithmSettings
)
onBoomLinesChange(lines)
}}
disabled={oilTrajectory.length === 0}
style={{
width: '100%',
padding: '10px',
fontSize: '11px',
fontWeight: 700,
fontFamily: 'var(--fK)',
background: oilTrajectory.length > 0 ? 'rgba(245,158,11,0.15)' : 'var(--bg0)',
border: oilTrajectory.length > 0 ? '2px solid var(--orange)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: oilTrajectory.length > 0 ? 'var(--orange)' : 'var(--t3)',
cursor: oilTrajectory.length > 0 ? 'pointer' : 'not-allowed',
transition: '0.15s'
}}
>
🛡
</button>
</div>
{/* 알고리즘 설정 */}
<div>
<h4 style={{ fontSize: '11px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fK)', marginBottom: '8px', letterSpacing: '0.5px' }}>
📊
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{[
{ label: '해류 직교 보정', key: 'currentOrthogonalCorrection' as const, unit: '°', value: algorithmSettings.currentOrthogonalCorrection },
{ label: '안전 마진 (도달시간)', key: 'safetyMarginMinutes' as const, unit: '분', value: algorithmSettings.safetyMarginMinutes },
{ label: '최소 차단 효율', key: 'minContainmentEfficiency' as const, unit: '%', value: algorithmSettings.minContainmentEfficiency },
{ label: '파고 보정 계수', key: 'waveHeightCorrectionFactor' as const, unit: 'x', value: algorithmSettings.waveHeightCorrectionFactor },
].map((setting) => (
<div key={setting.key} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 8px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)'
}}>
<span style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}> {setting.label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
<input
type="number"
value={setting.value}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0
onAlgorithmSettingsChange({ ...algorithmSettings, [setting.key]: val })
}}
className="boom-setting-input"
step={setting.key === 'waveHeightCorrectionFactor' ? 0.1 : 1}
/>
<span style={{ fontSize: '9px', color: 'var(--orange)', fontFamily: 'var(--fK)' }}>{setting.unit}</span>
</div>
</div>
))}
</div>
</div>
</>
)}
{/* ===== 수동 배치 탭 ===== */}
{boomPlacementTab === 'manual' && (
<>
{/* 드로잉 컨트롤 */}
<div style={{ display: 'flex', gap: '6px' }}>
{!isDrawingBoom ? (
<button
onClick={() => { onDrawingBoomChange(true); onDrawingPointsChange([]) }}
style={{
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
background: 'rgba(245,158,11,0.15)', border: '2px solid var(--orange)',
borderRadius: 'var(--rS)', color: 'var(--orange)', cursor: 'pointer', transition: '0.15s'
}}
>
🛡
</button>
) : (
<>
<button
onClick={() => {
if (drawingPoints.length >= 2) {
const newLine: BoomLine = {
id: `boom-manual-${Date.now()}`,
name: `수동 방어선 ${boomLines.length + 1}`,
priority: 'HIGH',
type: '기타',
coords: [...drawingPoints],
length: computePolylineLength(drawingPoints),
angle: computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]),
efficiency: 0,
status: 'PLANNED',
}
onBoomLinesChange([...boomLines, newLine])
}
onDrawingBoomChange(false)
onDrawingPointsChange([])
}}
disabled={drawingPoints.length < 2}
style={{
flex: 1, padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
background: drawingPoints.length >= 2 ? 'rgba(34,197,94,0.15)' : 'var(--bg0)',
border: drawingPoints.length >= 2 ? '2px solid var(--green)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: drawingPoints.length >= 2 ? 'var(--green)' : 'var(--t3)',
cursor: drawingPoints.length >= 2 ? 'pointer' : 'not-allowed', transition: '0.15s'
}}
>
({drawingPoints.length})
</button>
<button
onClick={() => { onDrawingBoomChange(false); onDrawingPointsChange([]) }}
style={{
padding: '10px 14px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
background: 'rgba(239,68,68,0.1)', border: '1px solid var(--red)',
borderRadius: 'var(--rS)', color: 'var(--red)', cursor: 'pointer', transition: '0.15s'
}}
>
</button>
</>
)}
</div>
{/* 드로잉 실시간 정보 */}
{isDrawingBoom && drawingPoints.length > 0 && (
<div style={{
padding: '8px 10px', background: 'rgba(245,158,11,0.05)',
border: '1px solid rgba(245,158,11,0.3)', borderRadius: 'var(--rS)',
display: 'flex', gap: '12px', fontSize: '10px', fontFamily: 'var(--fK)', color: 'var(--t2)'
}}>
<span>: <strong style={{ color: 'var(--orange)', fontFamily: 'var(--fM)' }}>{drawingPoints.length}</strong></span>
<span>: <strong style={{ color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>{computePolylineLength(drawingPoints).toFixed(0)}m</strong></span>
{drawingPoints.length >= 2 && (
<span>: <strong style={{ color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{computeBearing(drawingPoints[0], drawingPoints[drawingPoints.length - 1]).toFixed(0)}°</strong></span>
)}
</div>
)}
{/* 배치된 라인 목록 */}
{boomLines.length === 0 ? (
<p style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', textAlign: 'center', padding: '16px 0' }}>
.
</p>
) : (
boomLines.map((line, idx) => (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)',
borderLeft: `3px solid ${line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'}`,
borderRadius: 'var(--rS)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '6px' }}>
<input
type="text"
value={line.name}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], name: e.target.value }
onBoomLinesChange(updated)
}}
style={{
flex: 1, fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
background: 'transparent', border: 'none', color: 'var(--t1)', outline: 'none'
}}
/>
<button
onClick={() => onBoomLinesChange(boomLines.filter(l => l.id !== line.id))}
style={{
fontSize: '10px', color: 'var(--red)', background: 'none', border: 'none',
cursor: 'pointer', padding: '2px 6px'
}}
>
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '6px', fontSize: '9px', fontFamily: 'var(--fK)' }}>
<div>
<span style={{ color: 'var(--t3)' }}></span>
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.length.toFixed(0)}m</div>
</div>
<div>
<span style={{ color: 'var(--t3)' }}></span>
<div style={{ fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{line.angle.toFixed(0)}°</div>
</div>
<div>
<span style={{ color: 'var(--t3)' }}></span>
<select
value={line.priority}
onChange={(e) => {
const updated = [...boomLines]
updated[idx] = { ...updated[idx], priority: e.target.value as BoomLine['priority'] }
onBoomLinesChange(updated)
}}
style={{
width: '100%', fontSize: '10px', fontWeight: 600, fontFamily: 'var(--fK)',
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: '3px',
color: 'var(--t1)', padding: '2px', outline: 'none'
}}
>
<option value="CRITICAL"></option>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
</select>
</div>
</div>
</div>
))
)}
</>
)}
{/* ===== 시뮬레이션 탭 ===== */}
{boomPlacementTab === 'simulation' && (
<>
{/* 전제조건 체크 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
fontSize: '10px', fontFamily: 'var(--fK)'
}}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: oilTrajectory.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{oilTrajectory.length > 0 ? `(${oilTrajectory.length}개 입자)` : '없음'}
</span>
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 10px',
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
fontSize: '10px', fontFamily: 'var(--fK)'
}}>
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: boomLines.length > 0 ? 'var(--green)' : 'var(--red)' }} />
<span style={{ color: boomLines.length > 0 ? 'var(--green)' : 'var(--t3)' }}>
{boomLines.length > 0 ? `(${boomLines.length}개 배치)` : '없음'}
</span>
</div>
</div>
{/* 실행 버튼 */}
<button
onClick={() => {
const result = runContainmentAnalysis(oilTrajectory, boomLines)
onContainmentResultChange(result)
}}
disabled={oilTrajectory.length === 0 || boomLines.length === 0}
style={{
width: '100%', padding: '10px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
background: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'rgba(6,182,212,0.15)' : 'var(--bg0)',
border: (oilTrajectory.length > 0 && boomLines.length > 0) ? '2px solid var(--cyan)' : '1px solid var(--bd)',
borderRadius: 'var(--rS)',
color: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'var(--cyan)' : 'var(--t3)',
cursor: (oilTrajectory.length > 0 && boomLines.length > 0) ? 'pointer' : 'not-allowed',
transition: '0.15s'
}}
>
🔬
</button>
{/* 시뮬레이션 결과 */}
{containmentResult && containmentResult.totalParticles > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{/* 전체 효율 */}
<div style={{
padding: '16px', background: 'rgba(6,182,212,0.05)',
border: '1px solid rgba(6,182,212,0.3)', borderRadius: 'var(--rM)', textAlign: 'center'
}}>
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fM)' }}>
{containmentResult.overallEfficiency}%
</div>
<div style={{ fontSize: '10px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
</div>
</div>
{/* 차단/통과 카운트 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px' }}>
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--green)', fontFamily: 'var(--fM)' }}>
{containmentResult.blockedParticles}
</div>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}> </div>
</div>
<div style={{ padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)', textAlign: 'center' }}>
<div style={{ fontSize: '16px', fontWeight: 700, color: 'var(--red)', fontFamily: 'var(--fM)' }}>
{containmentResult.passedParticles}
</div>
<div style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}> </div>
</div>
</div>
{/* 효율 바 */}
<div className="boom-eff-bar">
<div className="boom-eff-fill" style={{
width: `${containmentResult.overallEfficiency}%`,
background: containmentResult.overallEfficiency >= 80 ? 'var(--green)' : containmentResult.overallEfficiency >= 50 ? 'var(--orange)' : 'var(--red)'
}} />
</div>
{/* 라인별 분석 */}
<div>
<h4 style={{ fontSize: '10px', fontWeight: 700, color: 'var(--t3)', fontFamily: 'var(--fK)', marginBottom: '6px' }}>
</h4>
{containmentResult.perLineResults.map((r) => (
<div key={r.boomLineId} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 8px', marginBottom: '4px',
background: 'var(--bg0)', border: '1px solid var(--bd)', borderRadius: 'var(--rS)',
fontSize: '9px', fontFamily: 'var(--fK)'
}}>
<span style={{ color: 'var(--t2)', flex: 1 }}>{r.boomLineName}</span>
<span style={{ fontWeight: 700, color: r.efficiency >= 50 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fM)', marginLeft: '8px' }}>
{r.blocked} / {r.efficiency}%
</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 배치된 방어선 카드 (AI/수동 공통 표시) */}
{boomPlacementTab !== 'simulation' && boomLines.length > 0 && boomPlacementTab === 'ai' && (
<>
{boomLines.map((line, idx) => {
const priorityColor = line.priority === 'CRITICAL' ? 'var(--red)' : line.priority === 'HIGH' ? 'var(--orange)' : 'var(--yellow)'
const priorityLabel = line.priority === 'CRITICAL' ? '긴급' : line.priority === 'HIGH' ? '중요' : '보통'
return (
<div key={line.id} style={{
padding: '10px', background: 'var(--bg0)', border: '1px solid var(--bd)',
borderLeft: `3px solid ${priorityColor}`, borderRadius: 'var(--rS)'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontSize: '11px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
🛡 {idx + 1} ({line.type})
</span>
<span style={{
padding: '2px 6px', fontSize: '8px', fontWeight: 700, fontFamily: 'var(--fK)',
background: `${priorityColor}20`, border: `1px solid ${priorityColor}`,
borderRadius: '3px', color: priorityColor
}}>
{priorityLabel}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', marginBottom: '6px' }}>
<div>
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}></span>
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
{line.length.toFixed(0)}m
</div>
</div>
<div>
<span style={{ fontSize: '8px', color: 'var(--t3)', fontFamily: 'var(--fK)' }}></span>
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>
{line.angle.toFixed(0)}°
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)' }} />
<span style={{ fontSize: '9px', fontWeight: 600, color: line.efficiency >= 80 ? 'var(--green)' : 'var(--orange)', fontFamily: 'var(--fK)' }}>
{line.efficiency}%
</span>
</div>
</div>
)
})}
</>
)}
</div>
)}
</div>
)
}
export default OilBoomSection

파일 보기

@ -0,0 +1,426 @@
import { useState } from 'react'
import { decimalToDMS } from '@common/utils/coordinates'
import { ComboBox } from '@common/components/ui/ComboBox'
import { ALL_MODELS } from './OilSpillView'
import type { PredictionModel } from './OilSpillView'
interface PredictionInputSectionProps {
expanded: boolean
onToggle: () => void
incidentCoord: { lon: number; lat: number }
onCoordChange: (coord: { lon: number; lat: number }) => void
onMapSelectClick: () => void
onRunSimulation: () => void
isRunningSimulation: boolean
selectedModels: Set<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void
predictionTime: number
onPredictionTimeChange: (time: number) => void
spillType: string
onSpillTypeChange: (type: string) => void
oilType: string
onOilTypeChange: (type: string) => void
spillAmount: number
onSpillAmountChange: (amount: number) => void
}
const PredictionInputSection = ({
expanded,
onToggle,
incidentCoord,
onCoordChange,
onMapSelectClick,
onRunSimulation,
isRunningSimulation,
selectedModels,
onModelsChange,
predictionTime,
onPredictionTimeChange,
spillType,
onSpillTypeChange,
oilType,
onOilTypeChange,
spillAmount,
onSpillAmountChange,
}: PredictionInputSectionProps) => {
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [uploadedFileName, setUploadedFileName] = useState<string>('')
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setUploadedFileName(file.name)
const reader = new FileReader()
reader.onload = (event) => {
setUploadedImage(event.target?.result as string)
}
reader.readAsDataURL(file)
}
}
const removeUploadedImage = () => {
setUploadedImage(null)
setUploadedFileName('')
}
return (
<div className="border-b border-border">
<div
onClick={onToggle}
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
>
<h3 className="text-[13px] font-bold text-text-2 font-korean">
</h3>
<span className="text-[10px] text-text-3">
{expanded ? '▼' : '▶'}
</span>
</div>
{expanded && (
<div className="px-4 pb-4" style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{/* Input Mode Selection */}
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', fontSize: '11px', color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
<input
type="radio"
name="prdType"
checked={inputMode === 'direct'}
onChange={() => setInputMode('direct')}
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '3px', cursor: 'pointer' }}>
<input
type="radio"
name="prdType"
checked={inputMode === 'upload'}
onChange={() => setInputMode('upload')}
style={{ accentColor: 'var(--cyan)', margin: 0, width: '11px', height: '11px' }}
/>
</label>
</div>
{/* Direct Input Mode */}
{inputMode === 'direct' && (
<>
<input className="prd-i" placeholder="사고명 직접 입력" />
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" />
</>
)}
{/* Image Upload Mode */}
{inputMode === 'upload' && (
<>
<input className="prd-i" placeholder="여수 유조선 충돌" />
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '여수 유조선 충돌 (INC-0042)' },
{ value: 'INC-0042', label: '여수 유조선 충돌 (INC-0042)' }
]}
placeholder="사고 선택"
/>
{/* Upload Success Message */}
{uploadedImage && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 8px',
background: 'rgba(34,197,94,0.1)',
border: '1px solid rgba(34,197,94,0.3)',
borderRadius: 'var(--rS)',
fontSize: '10px',
color: '#22c55e',
fontFamily: 'var(--fK)',
fontWeight: 600
}}>
<span style={{ fontSize: '12px' }}></span>
</div>
)}
{/* File Upload Area */}
{!uploadedImage ? (
<label style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
background: 'var(--bg0)',
border: '2px dashed var(--bd)',
borderRadius: 'var(--rS)',
cursor: 'pointer',
transition: '0.15s',
fontSize: '11px',
color: 'var(--t3)',
fontFamily: 'var(--fK)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--cyan)'
e.currentTarget.style.background = 'rgba(6,182,212,0.05)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--bd)'
e.currentTarget.style.background = 'var(--bg0)'
}}>
📁
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
style={{ display: 'none' }}
/>
</label>
) : (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background: 'var(--bg0)',
border: '1px solid var(--bd)',
borderRadius: 'var(--rS)',
fontSize: '10px',
fontFamily: 'var(--fM)'
}}>
<span style={{ color: 'var(--t2)' }}>📄 {uploadedFileName || 'example_plot_0.gif'}</span>
<button
onClick={removeUploadedImage}
style={{
padding: '2px 6px',
fontSize: '10px',
color: 'var(--t3)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
transition: '0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--red)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--t3)'
}}
>
</button>
</div>
)}
{/* Dropdowns */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '유출회사' },
{ value: 'company1', label: '회사A' },
{ value: 'company2', label: '회사B' }
]}
placeholder="유출회사"
/>
<ComboBox
className="prd-i"
value=""
onChange={() => {}}
options={[
{ value: '', label: '예상시각' },
{ value: '09:00', label: '09:00' },
{ value: '12:00', label: '12:00' }
]}
placeholder="예상시각"
/>
</div>
</>
)}
{/* Coordinates + Map Button */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '4px', alignItems: 'center' }}>
<input
className="prd-i"
type="number"
step="0.0001"
value={incidentCoord?.lat ?? ''}
onChange={(e) => {
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
onCoordChange({ ...incidentCoord, lat: isNaN(value) ? 0 : value })
}}
placeholder="위도°"
/>
<input
className="prd-i"
type="number"
step="0.0001"
value={incidentCoord?.lon ?? ''}
onChange={(e) => {
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
onCoordChange({ ...incidentCoord, lon: isNaN(value) ? 0 : value })
}}
placeholder="경도°"
/>
<button className="prd-map-btn" onClick={onMapSelectClick}>📍 </button>
</div>
{/* 도분초 표시 */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
<div style={{
fontSize: '9px',
color: 'var(--t3)',
fontFamily: 'var(--fM)',
padding: '4px 8px',
background: 'var(--bg0)',
borderRadius: 'var(--rS)',
border: '1px solid var(--bd)'
}}>
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
</div>
)}
</div>
{/* Oil Type + Oil Kind */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}>
<ComboBox
className="prd-i"
value={spillType}
onChange={onSpillTypeChange}
options={[
{ value: '연속', label: '연속' },
{ value: '비연속', label: '비연속' },
{ value: '순간 유출', label: '순간 유출' }
]}
/>
<ComboBox
className="prd-i"
value={oilType}
onChange={onOilTypeChange}
options={[
{ value: '벙커C유', label: '벙커C유' },
{ value: '경유', label: '경유' },
{ value: '원유', label: '원유' },
{ value: '중유', label: '중유' },
{ value: '등유', label: '등유' },
{ value: '휘발유', label: '휘발유' }
]}
/>
</div>
{/* Volume + Unit + Duration */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 65px 1fr', gap: '4px', alignItems: 'center' }}>
<input
className="prd-i"
placeholder="유출량"
type="number"
min="1"
step="1"
value={spillAmount}
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
/>
<ComboBox
className="prd-i"
value="kL"
onChange={() => {}}
options={[
{ value: 'kL', label: 'kL' },
{ value: 'ton', label: 'Ton' },
{ value: 'barrel', label: '배럴' }
]}
/>
<ComboBox
className="prd-i"
value={predictionTime}
onChange={(v) => onPredictionTimeChange(parseInt(v))}
options={[
{ value: '6', label: '6시간' },
{ value: '12', label: '12시간' },
{ value: '24', label: '24시간' },
{ value: '48', label: '48시간' },
{ value: '72', label: '72시간' }
]}
/>
</div>
{/* Image Analysis Note (Upload Mode Only) */}
{inputMode === 'upload' && uploadedImage && (
<div style={{
padding: '8px',
background: 'rgba(59,130,246,0.08)',
border: '1px solid rgba(59,130,246,0.2)',
borderRadius: 'var(--rS)',
fontSize: '9px',
color: 'var(--t3)',
fontFamily: 'var(--fK)',
lineHeight: '1.4'
}}>
📊 . .
</div>
)}
{/* Divider */}
<div style={{ height: '1px', background: 'var(--bd)', margin: '2px 0' }} />
{/* Model Selection (다중 선택) */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '3px' }}>
{([
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
] as const).map(m => (
<div
key={m.id}
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''}`}
onClick={() => {
const next = new Set(selectedModels)
if (next.has(m.id)) {
next.delete(m.id)
} else {
next.add(m.id)
}
onModelsChange(next)
}}
style={{ cursor: 'pointer' }}
>
<span className="prd-md" style={{ background: m.color }} />
{m.id}
</div>
))}
<div
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''}`}
onClick={() => {
if (selectedModels.size === ALL_MODELS.length) {
onModelsChange(new Set(['KOSPS']))
} else {
onModelsChange(new Set(ALL_MODELS))
}
}}
style={{ cursor: 'pointer' }}
>
<span className="prd-md" style={{ background: 'var(--purple)' }} />
</div>
</div>
{/* Run Button */}
<button
className="prd-btn pri"
style={{ padding: '7px', fontSize: '11px', marginTop: '2px' }}
onClick={onRunSimulation}
disabled={isRunningSimulation}
>
{isRunningSimulation ? '⏳ 실행 중...' : '🔬 확산예측 실행'}
</button>
</div>
)}
</div>
)
}
export default PredictionInputSection

파일 보기

@ -0,0 +1,49 @@
import type { PredictionModel } from './OilSpillView'
import type { BoomLine, BoomLineCoord, AlgorithmSettings, ContainmentResult } from '@common/types/boomLine'
import type { Analysis } from './AnalysisListTable'
export interface LeftPanelProps {
selectedAnalysis?: Analysis | null
enabledLayers: Set<string>
onToggleLayer: (layerId: string, enabled: boolean) => void
incidentCoord: { lon: number; lat: number }
onCoordChange: (coord: { lon: number; lat: number }) => void
onMapSelectClick: () => void
onRunSimulation: () => void
isRunningSimulation: boolean
selectedModels: Set<PredictionModel>
onModelsChange: (models: Set<PredictionModel>) => void
predictionTime: number
onPredictionTimeChange: (time: number) => void
spillType: string
onSpillTypeChange: (type: string) => void
oilType: string
onOilTypeChange: (type: string) => void
spillAmount: number
onSpillAmountChange: (amount: number) => void
// 오일펜스 배치 관련
boomLines: BoomLine[]
onBoomLinesChange: (lines: BoomLine[]) => void
oilTrajectory: Array<{ lat: number; lon: number; time: number; particle?: number }>
algorithmSettings: AlgorithmSettings
onAlgorithmSettingsChange: (settings: AlgorithmSettings) => void
isDrawingBoom: boolean
onDrawingBoomChange: (drawing: boolean) => void
drawingPoints: BoomLineCoord[]
onDrawingPointsChange: (points: BoomLineCoord[]) => void
containmentResult: ContainmentResult | null
onContainmentResultChange: (result: ContainmentResult | null) => void
// 레이어 스타일
layerOpacity: number
onLayerOpacityChange: (val: number) => void
layerBrightness: number
onLayerBrightnessChange: (val: number) => void
}
export interface ExpandedSections {
predictionInput: boolean
incident: boolean
impactResources: boolean
infoLayer: boolean
oilBoom: boolean
}

파일 보기

@ -0,0 +1,532 @@
import { useState, useEffect } from 'react';
import {
createEmptyReport,
saveReportToStorage,
} from './OilSpillReportTemplate';
import { consumeReportGenCategory } from '@common/hooks/useSubMenu';
import {
CATEGORIES,
sampleOilData,
sampleHnsData,
sampleRescueData,
type ReportCategory,
type ReportSection,
} from './reportTypes';
import { exportAsPDF } from './reportUtils';
interface ReportGeneratorProps {
onSave: () => void;
}
function ReportGenerator({ onSave }: ReportGeneratorProps) {
const [activeCat, setActiveCat] = useState<ReportCategory>(() => {
const hint = consumeReportGenCategory()
return (hint === 0 || hint === 1 || hint === 2) ? hint : 0
})
const [selectedTemplate, setSelectedTemplate] = useState(0)
const [sectionsMap, setSectionsMap] = useState<Record<number, ReportSection[]>>(() => ({
0: CATEGORIES[0].sections.map(s => ({ ...s })),
1: CATEGORIES[1].sections.map(s => ({ ...s })),
2: CATEGORIES[2].sections.map(s => ({ ...s })),
}))
// 외부에서 카테고리 힌트가 변경되면 반영
useEffect(() => {
const hint = consumeReportGenCategory()
if (hint === 0 || hint === 1 || hint === 2) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveCat(hint)
setSelectedTemplate(0)
}
}, [])
const cat = CATEGORIES[activeCat]
const sections = sectionsMap[activeCat]
const activeSections = sections.filter(s => s.checked)
const toggleSection = (id: string) => {
setSectionsMap(prev => ({
...prev,
[activeCat]: prev[activeCat].map(s => s.id === id ? { ...s, checked: !s.checked } : s),
}))
}
const handleSave = () => {
const report = createEmptyReport()
report.reportType = activeCat === 0 ? '예측보고서' : activeCat === 1 ? '종합보고서' : '초기보고서'
report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난'
report.title = cat.reportName
report.status = '완료'
report.author = '시스템 자동생성'
if (activeCat === 0) {
report.incident.pollutant = sampleOilData.pollution.oilType
report.incident.spillAmount = sampleOilData.pollution.spillAmount
}
saveReportToStorage(report)
onSave()
}
const handleDownload = () => {
const sectionHTML = activeSections.map(sec => {
return `<h3 style="color:${cat.color === 'var(--cyan)' ? '#06b6d4' : cat.color === 'var(--orange)' ? '#f97316' : '#ef4444'};font-size:14px;margin:20px 0 8px;">${sec.icon} ${sec.title}</h3><p style="font-size:12px;color:#666;">${sec.desc}</p>`
}).join('')
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${cat.reportName}</title><style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style></head><body><div style="text-align:center;margin-bottom:30px"><h1 style="font-size:20px;margin:0">해양환경 위기대응 통합지원시스템</h1><h2 style="font-size:16px;color:#0891b2;margin:8px 0">${cat.reportName}</h2></div>${sectionHTML}</body></html>`
exportAsPDF(html, cat.reportName)
}
return (
<div className="flex flex-col h-full w-full">
{/* Header */}
<div className="px-6 py-4 border-b border-border bg-bg-1">
<h2 className="text-[16px] font-bold text-text-1 font-korean"> </h2>
<p className="text-[11px] text-text-3 font-korean mt-1"> .</p>
{/* 3 카테고리 카드 */}
<div style={{ display: 'flex', gap: '14px', marginTop: '16px' }}>
{CATEGORIES.map((c, i) => {
const isActive = activeCat === i
return (
<button
key={i}
onClick={() => { setActiveCat(i as ReportCategory); setSelectedTemplate(0) }}
style={{
flex: 1, padding: '14px 16px', borderRadius: '10px', cursor: 'pointer',
textAlign: 'center', transition: '0.2s',
border: `1px solid ${isActive ? c.borderColor : 'var(--bd)'}`,
background: isActive ? c.bgActive : 'var(--bg3)',
}}
>
<div style={{ fontSize: '22px', marginBottom: '4px' }}>{c.icon}</div>
<div style={{
fontSize: '12px', fontWeight: 700, fontFamily: 'var(--fK)',
color: isActive ? c.color : 'var(--t3)',
}}>
{c.label}
</div>
<div style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
{c.desc}
</div>
</button>
)
})}
</div>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Left Sidebar - Template + Sections */}
<div className="w-[250px] min-w-[250px] border-r border-border bg-bg-1 flex flex-col overflow-y-auto shrink-0">
{/* 템플릿 선택 */}
<div className="px-4 py-3 border-b border-border">
<h3 className="text-[11px] font-bold text-text-2 font-korean flex items-center gap-2">📄 릿</h3>
</div>
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border">
{cat.templates.map((tmpl, i) => (
<button
key={i}
onClick={() => setSelectedTemplate(i)}
className="flex items-center gap-2 px-3 py-2.5 rounded-lg transition-all text-left"
style={{
border: `1px solid ${selectedTemplate === i ? cat.borderColor : 'var(--bd)'}`,
background: selectedTemplate === i ? cat.bgActive : 'var(--bg2)',
}}
>
<span style={{ fontSize: '14px' }}>{tmpl.icon}</span>
<span style={{
fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)',
color: selectedTemplate === i ? cat.color : 'var(--t2)',
}}>
{tmpl.label}
</span>
</button>
))}
</div>
{/* 섹션 체크 */}
<div className="px-4 py-3 border-b border-border">
<h3 className="text-[11px] font-bold text-text-2 font-korean flex items-center gap-2">📋 </h3>
</div>
<div className="flex flex-col gap-1.5 p-3">
{sections.map(sec => (
<button
key={sec.id}
onClick={() => toggleSection(sec.id)}
className="flex items-start gap-2.5 p-2.5 rounded-lg border transition-all text-left"
style={{
borderColor: sec.checked ? cat.borderColor : 'var(--bd)',
background: sec.checked ? cat.bgActive : 'var(--bg2)',
opacity: sec.checked ? 1 : 0.55,
}}
>
<div style={{
width: '18px', height: '18px', borderRadius: '4px', flexShrink: 0, marginTop: '1px',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '10px',
background: sec.checked ? cat.color : 'var(--bg3)',
color: sec.checked ? '#fff' : 'transparent',
border: sec.checked ? 'none' : '1px solid var(--bd)',
}}>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px' }}>{sec.icon}</span>
<span style={{
fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)',
color: sec.checked ? 'var(--t1)' : 'var(--t3)',
}}>
{sec.title}
</span>
</div>
<p style={{ fontSize: '9px', color: 'var(--t3)', fontFamily: 'var(--fK)', marginTop: '2px' }}>
{sec.desc}
</p>
</div>
</button>
))}
</div>
</div>
{/* Right - Report Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Preview Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-bg-1">
<h3 className="text-[13px] font-bold text-text-1 font-korean flex items-center gap-2">
📄
<span style={{
fontSize: '10px', fontWeight: 600, padding: '2px 8px', borderRadius: '4px',
background: cat.bgActive, color: cat.color, fontFamily: 'var(--fK)',
}}>
{cat.templates[selectedTemplate].label}
</span>
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
className="px-3 py-1.5 text-[11px] font-semibold rounded transition-all font-korean flex items-center gap-1.5"
style={{ background: cat.bgActive, border: `1px solid ${cat.borderColor}`, color: cat.color }}
>
📥
</button>
<button
onClick={handleSave}
className="px-3 py-1.5 text-[11px] font-semibold rounded border border-border bg-bg-2 text-text-1 hover:bg-bg-hover transition-all font-korean flex items-center gap-1.5"
>
💾
</button>
</div>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Report Header */}
<div className="rounded-lg border border-border p-8 mb-6" style={{ background: 'var(--bg2)' }}>
<div className="text-center">
<p className="text-[10px] text-text-3 font-korean mb-2"> </p>
<h2 className="text-[20px] font-bold text-text-1 font-korean mb-2">{cat.reportName}</h2>
<p className="text-[12px] font-korean" style={{ color: cat.color }}>{cat.templates[selectedTemplate].label}</p>
</div>
</div>
{/* Dynamic Sections */}
{activeSections.map(sec => (
<div key={sec.id} className="rounded-lg border border-border mb-4 overflow-hidden" style={{ background: 'var(--bg2)' }}>
<div className="px-5 py-3 border-b border-border">
<h4 className="text-[13px] font-bold text-text-1 font-korean flex items-center gap-2">
{sec.icon} {sec.title}
</h4>
</div>
<div className="p-5">
{/* ── 유출유 확산예측 섹션들 ── */}
{sec.id === 'oil-spread' && (
<>
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
[ - ]
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' },
{ label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' },
{ label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' },
].map((m, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
</div>
))}
</div>
</>
)}
{sec.id === 'oil-pollution' && (
<table className="w-full table-fixed" style={{ borderCollapse: 'collapse' }}>
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
<tbody>
{[
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered],
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea],
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength],
].map((row, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
</tr>
))}
</tbody>
</table>
)}
{sec.id === 'oil-sensitive' && (
<>
<p className="text-[11px] text-text-3 font-korean mb-3"> 10 NM </p>
<div className="flex flex-wrap gap-2">
{sampleOilData.sensitive.map((item, i) => (
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md bg-bg-3 border border-border text-text-2 font-korean">{item.label}</span>
))}
</div>
</>
)}
{sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{sampleOilData.coastal.coastLength}</span>
</p>
)}
{sec.id === 'oil-defense' && (
<div className="text-[12px] text-text-3 font-korean">
<p className="mb-2"> .</p>
<div className="w-full h-[100px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean">
[ ]
</div>
</div>
)}
{sec.id === 'oil-tide' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
</p>
)}
{/* ── HNS 대기확산 섹션들 ── */}
{sec.id === 'hns-atm' && (
<>
<div className="w-full h-[140px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean mb-4">
[ ]
</div>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'ALOHA', value: sampleHnsData.atm.aloha, color: '#f97316' },
{ label: 'WRF-Chem', value: sampleHnsData.atm.wrfChem, color: '#22c55e' },
].map((m, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label} </p>
<p className="text-[20px] font-bold font-mono" style={{ color: m.color }}>{m.value}</p>
</div>
))}
</div>
</>
)}
{sec.id === 'hns-hazard' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: 'ERPG-2 구역', value: sampleHnsData.hazard.erpg2, color: '#f97316', desc: '건강 영향' },
{ label: 'ERPG-3 구역', value: sampleHnsData.hazard.erpg3, color: '#ef4444', desc: '생명 위협' },
{ label: '대피 권고 범위', value: sampleHnsData.hazard.evacuation, color: '#a855f7', desc: '안전거리' },
].map((h, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] font-korean mb-1" style={{ color: h.color, fontWeight: 700 }}>{h.label}</p>
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>{h.value}</p>
<p className="text-[8px] text-text-3 font-korean mt-1">{h.desc}</p>
</div>
))}
</div>
)}
{sec.id === 'hns-substance' && (
<div className="grid grid-cols-2 gap-2" style={{ fontSize: '11px' }}>
{[
{ k: '물질명', v: sampleHnsData.substance.name },
{ k: 'UN번호', v: sampleHnsData.substance.un },
{ k: 'CAS번호', v: sampleHnsData.substance.cas },
{ k: '위험등급', v: sampleHnsData.substance.class },
].map((r, i) => (
<div key={i} className="flex justify-between px-3 py-2 bg-bg-1 rounded border border-border">
<span className="text-text-3 font-korean">{r.k}</span>
<span className="text-text-1 font-semibold font-mono">{r.v}</span>
</div>
))}
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-1 rounded border border-border" style={{ borderColor: 'rgba(239,68,68,0.3)' }}>
<span className="text-text-3 font-korean"></span>
<span style={{ color: 'var(--red)', fontWeight: 600, fontFamily: 'var(--fM)', fontSize: '10px' }}>{sampleHnsData.substance.toxicity}</span>
</div>
</div>
)}
{sec.id === 'hns-ppe' && (
<div className="flex flex-wrap gap-2">
{sampleHnsData.ppe.map((item, i) => (
<span key={i} className="px-3 py-1.5 text-[11px] font-semibold rounded-md border text-text-2 font-korean" style={{ background: 'rgba(249,115,22,0.06)', borderColor: 'rgba(249,115,22,0.2)' }}>
🛡 {item}
</span>
))}
</div>
)}
{sec.id === 'hns-facility' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: '인근 학교', value: `${sampleHnsData.facility.schools}개소`, icon: '🏫' },
{ label: '의료시설', value: `${sampleHnsData.facility.hospitals}개소`, icon: '🏥' },
{ label: '주변 인구', value: sampleHnsData.facility.population, icon: '👥' },
].map((f, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<div style={{ fontSize: '18px', marginBottom: '4px' }}>{f.icon}</div>
<p className="text-[14px] font-bold text-text-1 font-mono">{f.value}</p>
<p className="text-[9px] text-text-3 font-korean mt-1">{f.label}</p>
</div>
))}
</div>
)}
{sec.id === 'hns-3d' && (
<div className="w-full h-[160px] bg-bg-3 border border-border rounded-lg flex items-center justify-center text-text-3 text-[12px] font-korean">
[3D ]
</div>
)}
{sec.id === 'hns-weather' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '풍향', value: 'NE 42°', icon: '🌬' },
{ label: '풍속', value: '5.2 m/s', icon: '💨' },
{ label: '대기안정도', value: 'D (중립)', icon: '🌡' },
{ label: '기온', value: '8.5°C', icon: '☀️' },
].map((w, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{w.icon}</div>
<p className="text-[13px] font-bold text-text-1 font-mono">{w.value}</p>
<p className="text-[8px] text-text-3 font-korean mt-1">{w.label}</p>
</div>
))}
</div>
)}
{/* ── 긴급구난 섹션들 ── */}
{sec.id === 'rescue-safety' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'GM (복원력)', value: sampleRescueData.safety.gm, color: '#f97316' },
{ label: '경사각 (Heel)', value: sampleRescueData.safety.heel, color: '#ef4444' },
{ label: '트림 (Trim)', value: sampleRescueData.safety.trim, color: '#06b6d4' },
{ label: '안전 상태', value: sampleRescueData.safety.status, color: '#f97316' },
].map((s, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{s.label}</p>
<p className="text-[16px] font-bold font-mono" style={{ color: s.color }}>{s.value}</p>
</div>
))}
</div>
)}
{sec.id === 'rescue-timeline' && (
<div className="flex flex-col gap-2">
{[
{ time: '06:28', event: '충돌 발생 — ORIENTAL GLORY ↔ HAI FENG 168', color: '#ef4444' },
{ time: '06:30', event: 'No.1P 탱크 파공, 벙커C유 유출 개시', color: '#f97316' },
{ time: '06:35', event: 'VHF Ch.16 조난통신, 해경 출동 요청', color: '#eab308' },
{ time: '07:15', event: '해경 3009함 현장 도착, 방제 개시', color: '#06b6d4' },
].map((e, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2 bg-bg-1 rounded border border-border">
<span className="font-mono text-[11px] font-bold" style={{ color: e.color, minWidth: '40px' }}>{e.time}</span>
<span className="text-[11px] text-text-2 font-korean">{e.event}</span>
</div>
))}
</div>
)}
{sec.id === 'rescue-casualty' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '총원', value: sampleRescueData.casualty.total, color: 'var(--t1)' },
{ label: '구조완료', value: sampleRescueData.casualty.rescued, color: '#22c55e' },
{ label: '실종', value: sampleRescueData.casualty.missing, color: '#ef4444' },
{ label: '부상', value: sampleRescueData.casualty.injured, color: '#f97316' },
].map((c, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{c.label}</p>
<p className="text-[24px] font-bold font-mono" style={{ color: c.color }}>{c.value}</p>
<p className="text-[8px] text-text-3 font-korean mt-0.5"></p>
</div>
))}
</div>
)}
{sec.id === 'rescue-resource' && (
<table className="w-full" style={{ borderCollapse: 'collapse', fontSize: '11px' }}>
<thead>
<tr className="border-b border-border">
<th className="px-3 py-2 text-left text-text-3 font-korean"></th>
<th className="px-3 py-2 text-left text-text-3 font-korean">/</th>
<th className="px-3 py-2 text-center text-text-3 font-korean"></th>
<th className="px-3 py-2 text-center text-text-3 font-korean"></th>
</tr>
</thead>
<tbody>
{sampleRescueData.resources.map((r, i) => (
<tr key={i} className="border-b border-border">
<td className="px-3 py-2 text-text-2 font-korean">{r.type}</td>
<td className="px-3 py-2 text-text-1 font-mono font-semibold">{r.name}</td>
<td className="px-3 py-2 text-text-2 text-center font-mono">{r.eta}</td>
<td className="px-3 py-2 text-center">
<span className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean" style={{
background: r.status === '투입중' ? 'rgba(34,197,94,0.15)' : r.status === '이동중' ? 'rgba(249,115,22,0.15)' : 'rgba(138,150,168,0.15)',
color: r.status === '투입중' ? '#22c55e' : r.status === '이동중' ? '#f97316' : '#8a96a8',
}}>
{r.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{sec.id === 'rescue-grounding' && (
<div className="grid grid-cols-3 gap-3">
{[
{ label: '좌초 위험도', value: sampleRescueData.grounding.risk, color: '#ef4444' },
{ label: '최근 천해', value: sampleRescueData.grounding.nearestShallow, color: '#f97316' },
{ label: '현재 수심', value: sampleRescueData.grounding.depth, color: '#06b6d4' },
].map((g, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[9px] text-text-3 font-korean mb-1">{g.label}</p>
<p className="text-[14px] font-bold font-mono" style={{ color: g.color }}>{g.value}</p>
</div>
))}
</div>
)}
{sec.id === 'rescue-weather' && (
<div className="grid grid-cols-4 gap-3">
{[
{ label: '파고', value: '1.5 m', icon: '🌊' },
{ label: '풍속', value: '5.2 m/s', icon: '🌬' },
{ label: '조류', value: '1.2 kts NE', icon: '🌀' },
{ label: '시정', value: '8 km', icon: '👁' },
].map((w, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-3 text-center">
<div style={{ fontSize: '16px', marginBottom: '2px' }}>{w.icon}</div>
<p className="text-[13px] font-bold text-text-1 font-mono">{w.value}</p>
<p className="text-[8px] text-text-3 font-korean mt-1">{w.label}</p>
</div>
))}
</div>
)}
</div>
</div>
))}
{activeSections.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-text-3">
<div className="text-4xl mb-4">📋</div>
<p className="text-sm font-korean"> </p>
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default ReportGenerator;

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,301 @@
import { useState } from 'react';
import {
createEmptyReport,
saveReportToStorage,
type ReportType,
type Jurisdiction,
} from './OilSpillReportTemplate';
import { templateTypes } from './reportTypes';
import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils';
interface TemplateFormEditorProps {
onSave: () => void;
onBack: () => void;
}
function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
const [selectedType, setSelectedType] = useState<ReportType>('초기보고서')
const [formData, setFormData] = useState<Record<string, string>>({})
const [reportMeta, setReportMeta] = useState(() => {
const now = new Date()
return {
title: '',
author: '',
jurisdiction: '남해청' as Jurisdiction,
writeTime: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`,
}
})
const [autoSave, setAutoSave] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const template = templateTypes.find(t => t.id === selectedType)!
const getVal = (key: string) => {
if (key === 'author') return reportMeta.author
if (key === 'incident.writeTime') return reportMeta.writeTime
return formData[key] || ''
}
const setVal = (key: string, val: string) => {
if (key === 'author') { setReportMeta(p => ({ ...p, author: val })); return }
if (key === 'incident.writeTime') { setReportMeta(p => ({ ...p, writeTime: val })); return }
setFormData(p => ({ ...p, [key]: val }))
}
const handleSave = () => {
const report = createEmptyReport()
report.reportType = selectedType
report.jurisdiction = reportMeta.jurisdiction
report.author = reportMeta.author
report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}`
report.status = '완료'
report.incident.writeTime = reportMeta.writeTime
// Map all incident fields
const incFields = ['name', 'occurTime', 'location', 'shipName', 'accidentType', 'pollutant', 'spillAmount', 'lat', 'lon', 'depth', 'seabed'] as const
incFields.forEach(f => {
const val = formData[`incident.${f}`]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (val) (report.incident as any)[f] = val
})
report.analysis = formData['spreadAnalysis'] || formData['initialResponse'] || formData['responseStatus'] || formData['responseDetail'] || ''
saveReportToStorage(report)
onSave()
}
const doExport = (format: 'pdf' | 'hwp') => {
const html = generateReportHTML(
template.label,
{ writeTime: reportMeta.writeTime, author: reportMeta.author, jurisdiction: reportMeta.jurisdiction },
template.sections,
getVal
)
const filename = formData['incident.name'] || `${template.label}_${reportMeta.writeTime.replace(/[\s:]/g, '_')}`
if (format === 'pdf') exportAsPDF(html, filename)
else exportAsHWP(html, filename)
}
return (
<div className="flex h-full">
{/* Left Sidebar - Template Selection */}
<div className="w-60 min-w-[240px] border-r border-border bg-bg-1 flex flex-col py-4 px-3 gap-2 overflow-y-auto shrink-0">
<div className="px-1 mb-2">
<h3 className="text-[13px] font-bold text-text-1 font-korean"> 릿 </h3>
<p className="text-[9px] text-text-3 font-korean mt-1">릿 .</p>
</div>
{templateTypes.map(t => (
<button
key={t.id}
onClick={() => setSelectedType(t.id)}
className={`flex flex-col items-start p-3 rounded-lg border transition-all text-left ${
selectedType === t.id
? 'border-primary-cyan bg-[rgba(6,182,212,0.08)]'
: 'border-border bg-bg-2 hover:border-border-light'
}`}
>
<span className="text-lg mb-1">{t.icon}</span>
<span className={`text-[12px] font-bold font-korean ${selectedType === t.id ? 'text-primary-cyan' : 'text-text-1'}`}>{t.label}</span>
<span className="text-[9px] text-text-3 font-korean mt-0.5">{t.desc}</span>
</button>
))}
</div>
{/* Right - Form */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Form Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-bg-1">
<div className="flex items-center gap-3">
<span className="text-lg">{template.icon}</span>
<span className="text-[15px] font-bold text-text-1 font-korean">{template.label}</span>
<span className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean" style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}></span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-text-3 font-korean">:</span>
<button
onClick={() => setAutoSave(!autoSave)}
className={`relative w-9 h-[18px] rounded-full transition-all ${autoSave ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'}`}
>
<span className={`absolute top-[2px] w-3.5 h-3.5 rounded-full bg-white shadow transition-all ${autoSave ? 'left-[18px]' : 'left-[2px]'}`} />
</button>
<span className="text-[10px] font-semibold font-korean" style={{ color: autoSave ? '#06b6d4' : 'var(--t3)' }}>{autoSave ? 'ON' : 'OFF'}</span>
</div>
</div>
{/* Form Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{template.sections.map((section, sIdx) => (
<div key={sIdx} className="mb-6 w-full">
<h4 className="text-[13px] font-bold font-korean mb-3" style={{ color: '#06b6d4' }}>{section.title}</h4>
<table className="w-full table-fixed border-collapse">
<colgroup>
<col style={{ width: '180px' }} />
<col />
</colgroup>
<tbody>
{section.fields.map((field, fIdx) => (
<tr key={fIdx} className="border-b border-border">
{field.label ? (
<>
<td className="px-4 py-3 text-[11px] font-semibold text-text-3 font-korean bg-[rgba(255,255,255,0.03)] align-middle">
{field.label}
</td>
<td className="px-4 py-2 align-middle">
{field.type === 'text' && (
<input
value={getVal(field.key)}
onChange={e => setVal(field.key, e.target.value)}
placeholder={`${field.label} 입력`}
className="w-full bg-transparent text-[12px] text-text-1 font-korean outline-none placeholder-text-3"
/>
)}
{field.type === 'checkbox-group' && field.options && (
<div className="flex items-center gap-4">
{field.options.map(opt => (
<label key={opt} className="flex items-center gap-1.5 text-[11px] text-text-2 font-korean cursor-pointer">
<input type="checkbox" className="accent-[#06b6d4] w-3.5 h-3.5" />
{opt}
</label>
))}
</div>
)}
</td>
</>
) : (
<td colSpan={2} className="px-4 py-3">
<textarea
value={getVal(field.key)}
onChange={e => setVal(field.key, e.target.value)}
placeholder="내용을 입력하세요..."
className="w-full min-h-[120px] bg-bg-2 border border-border rounded-md px-3 py-2 text-[12px] text-text-1 font-korean outline-none placeholder-text-3 resize-y focus:border-primary-cyan"
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
{/* Bottom Action Bar */}
<div className="flex items-center justify-between px-6 py-3 border-t border-border bg-bg-1">
<div className="flex items-center gap-2">
<button onClick={() => doExport('pdf')} className="px-3 py-2 text-[11px] font-semibold rounded bg-status-red text-white hover:opacity-90 transition-all">PDF</button>
<button onClick={() => doExport('hwp')} className="px-3 py-2 text-[11px] font-semibold rounded bg-[#2563eb] text-white hover:opacity-90 transition-all">HWP</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="px-4 py-2 text-[11px] font-semibold rounded text-text-3 hover:text-text-1 transition-all font-korean"
>
</button>
<button
onClick={() => setShowPreview(true)}
className="px-4 py-2 text-[11px] font-semibold rounded border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
>
</button>
<button
onClick={handleSave}
className="px-5 py-2 text-[11px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
>
</button>
</div>
</div>
</div>
{/* Preview Modal */}
{showPreview && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="bg-bg-0 border border-border rounded-xl shadow-2xl flex flex-col" style={{ width: 'calc(100vw - 48px)', height: 'calc(100vh - 48px)' }}>
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<span className="text-lg">{template.icon}</span>
<span className="text-[15px] font-bold text-text-1 font-korean">{template.label}</span>
<span className="px-2 py-0.5 text-[9px] font-semibold rounded font-korean" style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4' }}></span>
</div>
<button
onClick={() => setShowPreview(false)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-text-3 hover:text-text-1 hover:bg-bg-2 transition-all text-lg"
>
</button>
</div>
{/* Modal Body - Report Preview */}
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="w-full">
{/* Report Title */}
<div className="text-center mb-8">
<h2 className="text-[18px] font-bold text-text-1 font-korean mb-1"></h2>
<h3 className="text-[15px] font-semibold font-korean" style={{ color: '#06b6d4' }}>
{formData['incident.name'] || template.label}
</h3>
<p className="text-[11px] text-text-3 font-korean mt-2">
: {reportMeta.writeTime} | : {reportMeta.author || '-'} | : {reportMeta.jurisdiction}
</p>
</div>
{/* Sections */}
{template.sections.map((section, sIdx) => (
<div key={sIdx} className="mb-5">
<h4 className="text-[13px] font-bold font-korean mb-2 px-2 py-1.5 rounded" style={{ color: '#06b6d4', background: 'rgba(6,182,212,0.06)' }}>
{section.title}
</h4>
<table className="w-full table-fixed border-collapse border border-border">
<colgroup>
<col style={{ width: '200px' }} />
<col />
</colgroup>
<tbody>
{section.fields.map((field, fIdx) => {
const val = getVal(field.key)
return field.label ? (
<tr key={fIdx} className="border-b border-border">
<td className="px-4 py-2.5 text-[11px] font-semibold text-text-3 font-korean bg-[rgba(255,255,255,0.03)] border-r border-border align-middle">
{field.label}
</td>
<td className="px-4 py-2.5 text-[12px] text-text-1 font-korean align-middle">
{val || <span className="text-text-3">-</span>}
</td>
</tr>
) : (
<tr key={fIdx} className="border-b border-border">
<td colSpan={2} className="px-4 py-3 text-[12px] text-text-1 font-korean whitespace-pre-wrap">
{val || <span className="text-text-3"> </span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
))}
</div>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-3 border-t border-border">
<button
onClick={() => setShowPreview(false)}
className="px-4 py-2 text-[11px] font-semibold rounded text-text-3 hover:text-text-1 transition-all font-korean"
>
</button>
<button
onClick={() => { setShowPreview(false); handleSave() }}
className="px-5 py-2 text-[11px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default TemplateFormEditor;

파일 보기

@ -0,0 +1,331 @@
import type { ReportType } from './OilSpillReportTemplate';
// ─── Template definitions ────────────────────────────────
export interface TemplateType {
id: ReportType
icon: string
label: string
desc: string
sections: { title: string; fields: { key: string; label: string; type: 'text' | 'textarea' | 'date' | 'checkbox-group'; options?: string[] }[] }[]
}
export const templateTypes: TemplateType[] = [
{
id: '초기보고서', icon: '📋', label: '초기보고서', desc: '사고 발생 직후 초기 상황 보고',
sections: [
{ title: '1. 기본정보', fields: [
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
{ key: 'author', label: '작성자', type: 'text' },
{ key: 'targets', label: '보고대상', type: 'checkbox-group', options: ['본청', '지방청', '유관기서'] },
]},
{ title: '2. 사고개요', fields: [
{ key: 'incident.name', label: '사고명', type: 'text' },
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
{ key: 'incident.location', label: '발생위치', type: 'text' },
{ key: 'incident.shipName', label: '사고선박', type: 'text' },
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
]},
{ title: '3. 유출현황', fields: [
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
{ key: 'spillPattern', label: '유출형태', type: 'text' },
{ key: 'spreadStatus', label: '확산현황', type: 'text' },
]},
{ title: '4. 초동조치 사항', fields: [
{ key: 'initialResponse', label: '', type: 'textarea' },
]},
{ title: '5. 향후 대응계획', fields: [
{ key: 'futurePlan', label: '', type: 'textarea' },
]},
]
},
{
id: '지휘부 보고', icon: '📊', label: '지휘부 보고', desc: '지휘부 보고용 요약 문서',
sections: [
{ title: '1. 기본정보', fields: [
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
{ key: 'author', label: '작성자', type: 'text' },
]},
{ title: '2. 사고 요약', fields: [
{ key: 'incident.name', label: '사고명', type: 'text' },
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
{ key: 'incident.location', label: '발생위치', type: 'text' },
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
]},
{ title: '3. 대응현황', fields: [
{ key: 'responseStatus', label: '', type: 'textarea' },
]},
{ title: '4. 건의사항', fields: [
{ key: 'suggestions', label: '', type: 'textarea' },
]},
]
},
{
id: '예측보고서', icon: '📈', label: '예측보고서', desc: '확산예측 결과 및 민감자원 분석',
sections: [
{ title: '1. 기본정보', fields: [
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
{ key: 'author', label: '작성자', type: 'text' },
]},
{ title: '2. 사고개요', fields: [
{ key: 'incident.name', label: '사고명', type: 'text' },
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
{ key: 'incident.location', label: '발생위치', type: 'text' },
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
]},
{ title: '3. 해양기상 현황', fields: [
{ key: 'weatherSummary', label: '', type: 'textarea' },
]},
{ title: '4. 확산예측 결과', fields: [
{ key: 'spreadResult', label: '', type: 'textarea' },
]},
{ title: '5. 민감자원 영향', fields: [
{ key: 'sensitiveImpact', label: '', type: 'textarea' },
]},
]
},
{
id: '종합보고서', icon: '📑', label: '종합보고서', desc: '전체 사고 대응 종합 보고 문서',
sections: [
{ title: '1. 기본정보', fields: [
{ key: 'incident.writeTime', label: '보고일시', type: 'text' },
{ key: 'author', label: '작성자', type: 'text' },
]},
{ title: '2. 사고개요', fields: [
{ key: 'incident.name', label: '사고명', type: 'text' },
{ key: 'incident.occurTime', label: '발생일시', type: 'text' },
{ key: 'incident.location', label: '발생위치', type: 'text' },
{ key: 'incident.shipName', label: '사고선박', type: 'text' },
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
]},
{ title: '3. 유출 및 확산현황', fields: [
{ key: 'incident.pollutant', label: '유출유종', type: 'text' },
{ key: 'incident.spillAmount', label: '유출량', type: 'text' },
{ key: 'spreadSummary', label: '확산현황', type: 'textarea' },
]},
{ title: '4. 대응현황', fields: [
{ key: 'responseDetail', label: '', type: 'textarea' },
]},
{ title: '5. 피해현황', fields: [
{ key: 'damageReport', label: '', type: 'textarea' },
]},
{ title: '6. 향후계획', fields: [
{ key: 'futurePlanDetail', label: '', type: 'textarea' },
]},
]
},
{
id: '유출유 보고', icon: '🛢️', label: '유출유 보고', desc: '유류오염사고 대응지원 상황도',
sections: [
// Page 1: 사고 정보
{ title: '1. 사고 정보', fields: [
{ key: 'incident.name', label: '사고명', type: 'text' },
{ key: 'incident.writeTime', label: '작성시간', type: 'text' },
{ key: 'incident.shipName', label: '선명(시설명)', type: 'text' },
{ key: 'agent', label: '제원', type: 'text' },
{ key: 'incident.location', label: '사고위치', type: 'text' },
{ key: 'incident.lat', label: '좌표(위도)', type: 'text' },
{ key: 'incident.lon', label: '좌표(경도)', type: 'text' },
{ key: 'incident.occurTime', label: '발생시각', type: 'text' },
{ key: 'incident.accidentType', label: '사고유형', type: 'text' },
{ key: 'incident.pollutant', label: '오염물질', type: 'text' },
{ key: 'incident.spillAmount', label: '유출 추정량(㎘)', type: 'text' },
{ key: 'incident.depth', label: '수심', type: 'text' },
{ key: 'incident.seabed', label: '저질', type: 'text' },
]},
// Page 2: 해양기상정보
{ title: '2. 해양기상정보 - 조석정보', fields: [
{ key: 'tideDate', label: '일자', type: 'text' },
{ key: 'tideName', label: '물때', type: 'text' },
{ key: 'lowTide1', label: '저조(1차)', type: 'text' },
{ key: 'highTide1', label: '고조(1차)', type: 'text' },
{ key: 'lowTide2', label: '저조(2차)', type: 'text' },
{ key: 'highTide2', label: '고조(2차)', type: 'text' },
]},
{ title: '2. 해양기상정보 - 기상예보', fields: [
{ key: 'weatherTime', label: '기상 예측시간', type: 'text' },
{ key: 'sunrise', label: '일출', type: 'text' },
{ key: 'sunset', label: '일몰', type: 'text' },
{ key: 'windDir', label: '풍향', type: 'text' },
{ key: 'windSpeed', label: '풍속(m/s)', type: 'text' },
{ key: 'currentDir', label: '유향', type: 'text' },
{ key: 'currentSpeed', label: '유속(knot)', type: 'text' },
{ key: 'waveHeight', label: '파고(m)', type: 'text' },
]},
// Page 3: 유출유 확산예측
{ title: '3. 유출유 확산예측 - 시간별 상세정보', fields: [
{ key: 'spread3h_weathered', label: '3시간 풍화량(kL)', type: 'text' },
{ key: 'spread3h_seaRemain', label: '3시간 해상잔존량(kL)', type: 'text' },
{ key: 'spread3h_coastAttach', label: '3시간 연안부착량(kL)', type: 'text' },
{ key: 'spread3h_area', label: '3시간 오염해역면적(km²)', type: 'text' },
{ key: 'spread6h_weathered', label: '6시간 풍화량(kL)', type: 'text' },
{ key: 'spread6h_seaRemain', label: '6시간 해상잔존량(kL)', type: 'text' },
{ key: 'spread6h_coastAttach', label: '6시간 연안부착량(kL)', type: 'text' },
{ key: 'spread6h_area', label: '6시간 오염해역면적(km²)', type: 'text' },
]},
{ title: '3. 분석', fields: [
{ key: 'spreadAnalysis', label: '', type: 'textarea' },
]},
// Page 4-5: 민감자원
{ title: '4. 민감자원 - 양식장 분포(10km 내)', fields: [
{ key: 'aquaculture', label: '', type: 'textarea' },
]},
{ title: '4. 민감자원 - 해수욕장/수산시장/해수취수시설', fields: [
{ key: 'beaches', label: '', type: 'textarea' },
]},
// Page 5: 해안선/생물종
{ title: '4. 해안선(ESI) 분포', fields: [
{ key: 'esi1_vertical', label: 'ESI 1 수직암반(km)', type: 'text' },
{ key: 'esi2_horizontal', label: 'ESI 2 수평암반(km)', type: 'text' },
{ key: 'esi3_finesand', label: 'ESI 3 세립질 모래(km)', type: 'text' },
{ key: 'esi4_coarsesand', label: 'ESI 4 조립질 모래(km)', type: 'text' },
{ key: 'esi5_sandgravel', label: 'ESI 5 모래·자갈(km)', type: 'text' },
{ key: 'esi6a_gravel', label: 'ESI 6A 자갈(km)', type: 'text' },
{ key: 'esi6b_riprap', label: 'ESI 6B 투과성 사석(km)', type: 'text' },
{ key: 'esi7_semiclosed', label: 'ESI 7 반폐쇄성 해안(km)', type: 'text' },
{ key: 'esi8a_mudflat', label: 'ESI 8A 갯벌(km)', type: 'text' },
{ key: 'esi8b_saltmarsh', label: 'ESI 8B 염습지(km)', type: 'text' },
]},
{ title: '4. 생물종(보호종) / 서식지 분포', fields: [
{ key: 'bioSpecies', label: '', type: 'textarea' },
]},
// Page 6: 통합민감도
{ title: '4. 통합민감도 평가', fields: [
{ key: 'sens_veryHigh', label: '매우 높음(km²)', type: 'text' },
{ key: 'sens_high', label: '높음(km²)', type: 'text' },
{ key: 'sens_medium', label: '보통(km²)', type: 'text' },
{ key: 'sens_low', label: '낮음(km²)', type: 'text' },
]},
// Page 7: 방제전략
{ title: '5. 방제전략 - 방제자원 배치 현황(반경 30km)', fields: [
{ key: 'defenseShips', label: '', type: 'textarea' },
]},
{ title: '5. 기타 장비', fields: [
{ key: 'otherEquipment', label: '', type: 'textarea' },
]},
// Page 8: 동원결과
{ title: '6. 방제선/자원 동원 결과', fields: [
{ key: 'oilRecovery', label: '', type: 'textarea' },
]},
{ title: '6. 동원 방제선 내역', fields: [
{ key: 'totalSpill', label: '유출량(kL)', type: 'text' },
{ key: 'totalWeathered', label: '누적풍화량(kL)', type: 'text' },
{ key: 'totalRecovered', label: '누적회수량(kL)', type: 'text' },
{ key: 'totalSeaRemain', label: '누적해상잔존량(kL)', type: 'text' },
{ key: 'totalCoastAttach', label: '누적연안부착량(kL)', type: 'text' },
]},
]
},
]
// ─── Report Generator types & data ────────────────────────
export interface ReportSection {
id: string
icon: string
title: string
desc: string
checked: boolean
}
export type ReportCategory = 0 | 1 | 2
export interface CategoryDef {
icon: string
label: string
desc: string
color: string
borderColor: string
bgActive: string
reportName: string
templates: { icon: string; label: string }[]
sections: ReportSection[]
}
export const CATEGORIES: CategoryDef[] = [
{
icon: '🛢', label: '유출유 확산예측', desc: 'KOSPS · OpenDrift · POSEIDON',
color: 'var(--cyan)', borderColor: 'rgba(6,182,212,0.4)', bgActive: 'rgba(6,182,212,0.08)',
reportName: '유출유 확산예측 보고서',
templates: [
{ icon: '🔬', label: '예측보고서' },
{ icon: '📋', label: '초기보고서' },
{ icon: '📊', label: '지휘부 보고' },
{ icon: '📑', label: '종합보고서' },
],
sections: [
{ id: 'oil-spread', icon: '🌊', title: '유출유 확산예측 결과', desc: 'KOSPS/OpenDrift/POSEIDON 예측 결과 및 비교', checked: true },
{ id: 'oil-pollution', icon: '📊', title: '오염종합상황', desc: '유출량, 풍화량, 해상잔유량, 오염면적', checked: true },
{ id: 'oil-sensitive', icon: '🐟', title: '민감자원 현황', desc: '어장정보, 양식업, 환경생태 자원', checked: true },
{ id: 'oil-coastal', icon: '🏖', title: '해안부착 현황', desc: '해안 부착 위치, 시간, 오염 길이', checked: true },
{ id: 'oil-defense', icon: '🛡', title: '방제전략·자원배치', desc: '방제방법, 방제자원 배치 계획', checked: true },
{ id: 'oil-tide', icon: '🌊', title: '조석·기상정보', desc: '사고 해역 조석·기상 현황', checked: true },
],
},
{
icon: '🧪', label: 'HNS 대기확산', desc: 'ALOHA · WRF-Chem',
color: 'var(--orange)', borderColor: 'rgba(249,115,22,0.4)', bgActive: 'rgba(249,115,22,0.08)',
reportName: 'HNS 대기확산 예측보고서',
templates: [
{ icon: '🧪', label: 'HNS 예측보고서' },
{ icon: '📋', label: '초기보고서' },
{ icon: '📊', label: '지휘부 보고' },
{ icon: '🆘', label: 'EmS 대응보고' },
],
sections: [
{ id: 'hns-atm', icon: '💨', title: '대기확산 예측 결과', desc: 'ALOHA/WRF-Chem 모델 확산 결과', checked: true },
{ id: 'hns-hazard', icon: '⚠️', title: '위험구역·방제거리', desc: 'ERPGs 기준 위험구역 범위', checked: true },
{ id: 'hns-substance', icon: '🧬', title: 'HNS 물질정보', desc: '물질 특성, 독성, UN번호', checked: true },
{ id: 'hns-ppe', icon: '🛡', title: 'PPE·대응장비', desc: '개인보호장비, 대응장비 배치', checked: true },
{ id: 'hns-facility', icon: '🏫', title: '영향시설·대피현황', desc: '주변 시설, 인구, 대피 계획', checked: true },
{ id: 'hns-3d', icon: '📐', title: '3D 공간분포', desc: '수직·수평 농도 분포', checked: false },
{ id: 'hns-weather', icon: '🌊', title: '기상·해상 조건', desc: '풍향·풍속, 대기안정도', checked: true },
],
},
{
icon: '🚨', label: '긴급구난', desc: '복원성 · 좌초위험 분석',
color: 'var(--red)', borderColor: 'rgba(239,68,68,0.4)', bgActive: 'rgba(239,68,68,0.08)',
reportName: '긴급구난 상황보고서',
templates: [
{ icon: '🚨', label: '긴급구난 상황보고' },
{ icon: '📋', label: '초기보고서' },
{ icon: '📊', label: '지휘부 보고' },
{ icon: '📑', label: '종합보고서' },
],
sections: [
{ id: 'rescue-safety', icon: '🚢', title: '선박 안전성 평가', desc: 'GM, 경사각, 트림 분석', checked: true },
{ id: 'rescue-timeline', icon: '⚡', title: '사고 유형·경과', desc: '사고 유형별 타임라인', checked: true },
{ id: 'rescue-casualty', icon: '👥', title: '인명현황', desc: '인원 현황, 구조 상태', checked: true },
{ id: 'rescue-resource', icon: '🛟', title: '구난 자원 현황', desc: '예인선, 헬기, 구난 장비 배치', checked: true },
{ id: 'rescue-grounding', icon: '🗺', title: '좌초위험 해역', desc: '좌초 위험 구역 분석', checked: true },
{ id: 'rescue-weather', icon: '🌊', title: '기상·해상 조건', desc: '파고, 풍속, 조류 현황', checked: true },
],
},
]
// 카테고리별 샘플 데이터
export const sampleOilData = {
spread: { kosps: '10.2 km', openDrift: '25.1 km', poseidon: '9.1 km' },
pollution: { spillAmount: '1.00 k', weathered: '0.09 k', seaRemain: '0.23 k', pollutionArea: '3.93 km²', coastAttach: '0.67 k', coastLength: '0.45 km', oilType: 'BUNKER_C' },
sensitive: [{ label: '마을어업 131ha' }, { label: '복합양식업 35ha' }, { label: '어류등양식업 36ha' }, { label: '갯벌 4,013ha' }],
coastal: { firstTime: '14:30', coastLength: '0.45 km' },
tide: { highTide1: '06:24 (1.82m)', lowTide: '12:51 (0.34m)', highTide2: '18:47 (1.95m)' },
}
export const sampleHnsData = {
substance: { name: '벤젠 (Benzene)', un: 'UN 1114', cas: '71-43-2', class: '3류 인화성 액체', toxicity: 'ERPG-2: 50 ppm / ERPG-3: 150 ppm' },
hazard: { erpg2: '1.8 km', erpg3: '0.6 km', evacuation: '2.5 km' },
atm: { aloha: '2.1 km', wrfChem: '1.8 km' },
ppe: ['Level B 화학복', 'SCBA 공기호흡기', '내화학장갑', '화학보호장화'],
facility: { schools: 2, hospitals: 1, population: '약 12,400명' },
}
export const sampleRescueData = {
safety: { gm: '0.82 m', heel: '8.5°', trim: '1.2 m (선미)', status: '주의' },
casualty: { total: 25, rescued: 22, missing: 2, injured: 1 },
resources: [
{ type: '예인선', name: '해양1호', eta: '현장 도착', status: '투입중' },
{ type: '구난선', name: 'SMIT Salvage', eta: '02-10 14:00', status: '이동중' },
{ type: '헬기', name: '해경 B-512', eta: '30분', status: '대기중' },
],
grounding: { risk: '높음', nearestShallow: '1.2 NM (SE방향)', depth: '12.5 m' },
}

파일 보기

@ -0,0 +1,89 @@
import { sanitizeHtml } from '@common/utils/sanitize';
import type { OilSpillReportData } from './OilSpillReportTemplate';
// ─── Report Export Helpers ──────────────────────────────
export function generateReportHTML(
templateLabel: string,
meta: { writeTime: string; author: string; jurisdiction: string },
sections: { title: string; fields: { key: string; label: string }[] }[],
getVal: (key: string) => string
) {
const rows = sections.map(section => {
const fieldRows = section.fields.map(f => {
if (f.label) {
return `<tr><td style="background:#f0f4f8;padding:8px 12px;border:1px solid #d1d5db;font-weight:600;width:200px;font-size:12px;">${f.label}</td><td style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;">${getVal(f.key) || '-'}</td></tr>`
}
return `<tr><td colspan="2" style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;white-space:pre-wrap;">${getVal(f.key) || '-'}</td></tr>`
}).join('')
return `<h3 style="color:#0891b2;font-size:14px;margin:20px 0 8px;">${section.title}</h3><table style="width:100%;border-collapse:collapse;">${fieldRows}</table>`
}).join('')
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${templateLabel}</title>
<style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕','Malgun Gothic',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style>
</head><body>
<div style="text-align:center;margin-bottom:30px">
<h1 style="font-size:20px;margin:0"></h1>
<h2 style="font-size:16px;color:#0891b2;margin:8px 0">${templateLabel}</h2>
<p style="font-size:11px;color:#666">작성일시: ${meta.writeTime} | 작성자: ${meta.author || '-'} | 관할: ${meta.jurisdiction}</p>
</div>${rows}</body></html>`
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function exportAsPDF(html: string, _filename: string) {
const sanitizedHtml = sanitizeHtml(html)
const blob = new Blob([sanitizedHtml], { type: 'text/html; charset=utf-8' })
const url = URL.createObjectURL(blob)
const win = window.open(url, '_blank')
if (win) {
win.addEventListener('afterprint', () => URL.revokeObjectURL(url))
setTimeout(() => win.print(), 500)
}
setTimeout(() => URL.revokeObjectURL(url), 30000)
}
export function exportAsHWP(html: string, filename: string) {
const blob = new Blob([html], { type: 'application/msword;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.doc`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export type ViewState =
| { screen: 'list' }
| { screen: 'templates' }
| { screen: 'generate' }
| { screen: 'view'; data: OilSpillReportData }
| { screen: 'edit'; data: OilSpillReportData }
export const typeColors: Record<string, { bg: string; text: string }> = {
'초기보고서': { bg: 'rgba(6,182,212,0.15)', text: '#06b6d4' },
'지휘부 보고': { bg: 'rgba(168,85,247,0.15)', text: '#a855f7' },
'예측보고서': { bg: 'rgba(59,130,246,0.15)', text: '#3b82f6' },
'종합보고서': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
'유출유 보고': { bg: 'rgba(234,179,8,0.15)', text: '#eab308' },
}
export const statusColors: Record<string, { bg: string; text: string }> = {
'완료': { bg: 'rgba(34,197,94,0.15)', text: '#22c55e' },
'수행중': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
'테스트': { bg: 'rgba(138,150,168,0.15)', text: '#8a96a8' },
}
export const analysisCatColors: Record<string, { bg: string; text: string; icon: string }> = {
'유출유 확산예측': { bg: 'rgba(6,182,212,0.12)', text: '#06b6d4', icon: '🛢' },
'HNS 대기확산': { bg: 'rgba(249,115,22,0.12)', text: '#f97316', icon: '🧪' },
'긴급구난': { bg: 'rgba(239,68,68,0.12)', text: '#ef4444', icon: '🚨' },
}
export function inferAnalysisCategory(report: OilSpillReportData): string {
if (report.analysisCategory) return report.analysisCategory
const t = (report.title || '').toLowerCase()
const rt = report.reportType || ''
if (t.includes('hns') || t.includes('대기확산') || t.includes('화학') || t.includes('aloha')) return 'HNS 대기확산'
if (t.includes('구난') || t.includes('구조') || t.includes('긴급') || t.includes('salvage') || t.includes('rescue')) return '긴급구난'
if (t.includes('유출유') || t.includes('확산예측') || t.includes('민감자원') || t.includes('유출사고') || t.includes('오염') || t.includes('방제') || rt === '유출유 보고' || rt === '예측보고서') return '유출유 확산예측'
return ''
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -0,0 +1,155 @@
import type { ScatSegment } from './scatTypes'
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants'
interface ScatLeftPanelProps {
segments: ScatSegment[]
selectedSeg: ScatSegment
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
jurisdictionFilter: string
onJurisdictionChange: (v: string) => void
areaFilter: string
onAreaChange: (v: string) => void
phaseFilter: string
onPhaseChange: (v: string) => void
statusFilter: string
onStatusChange: (v: string) => void
searchTerm: string
onSearchChange: (v: string) => void
}
function ScatLeftPanel({
segments,
selectedSeg,
onSelectSeg,
onOpenPopup,
jurisdictionFilter,
onJurisdictionChange,
areaFilter,
onAreaChange,
phaseFilter,
onPhaseChange,
statusFilter,
onStatusChange,
searchTerm,
onSearchChange,
}: ScatLeftPanelProps) {
const filtered = segments.filter(s => {
if (areaFilter !== '전체' && !s.area.includes(areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''))) return false
if (statusFilter !== '전체' && s.status !== statusFilter) return false
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false
return true
})
return (
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
{/* Filters */}
<div className="p-3.5 border-b border-border">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-text-1 mb-3">
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label>
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
<option> ()</option>
<option></option>
<option></option>
</select>
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label>
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
<option></option>
{scatAreas.map(a => (
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} </option>
))}
</select>
</div>
<div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label>
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
<option>Pre-SCAT ()</option>
<option>SCAT ( )</option>
<option>Post-SCAT ( )</option>
</select>
</div>
<div className="flex gap-1.5 mt-1">
<input
type="text"
placeholder="🔍 구간 검색..."
value={searchTerm}
onChange={e => onSearchChange(e.target.value)}
className="prd-i flex-1"
/>
<select value={statusFilter} onChange={e => onStatusChange(e.target.value)} className="prd-i w-[70px]">
<option></option>
<option></option>
<option></option>
<option></option>
</select>
</div>
</div>
{/* Segment List */}
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider text-text-1 mb-2.5">
<span className="flex items-center gap-1.5">
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
</span>
<span className="text-primary-cyan font-mono text-[10px]"> {filtered.length} </span>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
{filtered.map(seg => {
const lvl = esiLevel(seg.esiNum)
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green'
const isSelected = selectedSeg.id === seg.id
return (
<div
key={seg.id}
onClick={() => { onSelectSeg(seg); onOpenPopup(seg.id % scatDetailData.length) }}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected ? 'border-status-green bg-[rgba(34,197,94,0.05)]' : 'hover:border-border-light hover:bg-bg-hover'
}`}
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area}
</span>
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white" style={{ background: esiColor(seg.esiNum) }}>
ESI {seg.esi}
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>{seg.sensitivity}</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>{seg.status}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
export default ScatLeftPanel

파일 보기

@ -0,0 +1,276 @@
import { useState, useEffect, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { ScatSegment } from './scatTypes'
import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants'
interface ScatMapProps {
segments: ScatSegment[]
selectedSeg: ScatSegment
onSelectSeg: (s: ScatSegment) => void
onOpenPopup: (idx: number) => void
}
function ScatMap({
segments,
selectedSeg,
onSelectSeg,
onOpenPopup,
}: ScatMapProps) {
const mapContainerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<L.LayerGroup | null>(null)
const [zoom, setZoom] = useState(10)
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return
const map = L.map(mapContainerRef.current, {
center: [33.38, 126.55],
zoom: 10,
zoomControl: false,
attributionControl: false,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map)
L.control.zoom({ position: 'bottomright' }).addTo(map)
L.control.attribution({ position: 'bottomleft' }).addAttribution(
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'
).addTo(map)
map.on('zoomend', () => setZoom(map.getZoom()))
mapRef.current = map
markersRef.current = L.layerGroup().addTo(map)
setTimeout(() => map.invalidateSize(), 100)
return () => {
map.remove()
mapRef.current = null
markersRef.current = null
}
}, [])
useEffect(() => {
if (!mapRef.current || !markersRef.current) return
markersRef.current.clearLayers()
// 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업)
const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14
const polyWeight = 1 + zScale * 4 // 1 ~ 5
const selPolyWeight = 2 + zScale * 5 // 2 ~ 7
const glowWeight = 4 + zScale * 14 // 4 ~ 18
const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0
const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px
const markerBorder = zoom >= 13 ? 2 : 1
const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px
const showStatusMarker = zoom >= 11
const showStatusText = zoom >= 13
// 제주도 해안선 레퍼런스 라인
const coastline = L.polyline(jejuCoastCoords as [number, number][], {
color: 'rgba(6, 182, 212, 0.18)',
weight: 1.5,
dashArray: '8, 6',
})
markersRef.current.addLayer(coastline)
segments.forEach(seg => {
const isSelected = selectedSeg.id === seg.id
const color = esiColor(seg.esiNum)
// 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
const coastIdx = seg.id % (jejuCoastCoords.length - 1)
const [clat1, clng1] = jejuCoastCoords[coastIdx]
const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length]
const dlat = clat2 - clat1
const dlng = clng2 - clng1
const dist = Math.sqrt(dlat * dlat + dlng * dlng)
const nDlat = dist > 0 ? dlat / dist : 0
const nDlng = dist > 0 ? dlng / dist : 1
// 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링)
const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale
// 해안선 방향을 따라 폴리라인 좌표 생성
const segCoords: [number, number][] = [
[seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen],
[seg.lat, seg.lng],
[seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen],
]
// 선택된 구간 글로우 효과
if (isSelected) {
const glow = L.polyline(segCoords, {
color: '#22c55e',
weight: glowWeight,
opacity: 0.15,
lineCap: 'round',
})
markersRef.current!.addLayer(glow)
}
// ESI 색상 구간 폴리라인
const polyline = L.polyline(segCoords, {
color: isSelected ? '#22c55e' : color,
weight: isSelected ? selPolyWeight : polyWeight,
opacity: isSelected ? 0.95 : 0.7,
lineCap: 'round',
lineJoin: 'round',
})
const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
polyline.bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${seg.code} ${seg.area}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${seg.esi} · ${seg.length} · ${statusIcon} ${seg.status}</div>
</div>`,
{
permanent: isSelected,
direction: 'top',
offset: [0, -10],
className: 'scat-map-tooltip',
}
)
polyline.on('click', () => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
})
markersRef.current!.addLayer(polyline)
// 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절
if (showStatusMarker) {
const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b'
const stBg = seg.status === '완료' ? 'rgba(34,197,94,0.2)' : seg.status === '진행중' ? 'rgba(234,179,8,0.2)' : 'rgba(100,116,139,0.2)'
const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—'
const half = Math.round(markerSize / 2)
const statusMarker = L.marker([seg.lat, seg.lng], {
icon: L.divIcon({
className: '',
html: `<div style="width:${markerSize}px;height:${markerSize}px;border-radius:50%;background:${stBg};border:${markerBorder}px solid ${stColor};display:flex;align-items:center;justify-content:center;font-size:${markerFontSize}px;color:${stColor};transform:translate(-${half}px,-${half}px);backdrop-filter:blur(4px);box-shadow:0 0 ${Math.round(markerSize / 3)}px ${stBg}">${showStatusText ? stText : ''}</div>`,
iconSize: [0, 0],
}),
})
statusMarker.on('click', () => {
onSelectSeg(seg)
onOpenPopup(seg.id % scatDetailData.length)
})
markersRef.current!.addLayer(statusMarker)
}
})
}, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom])
useEffect(() => {
if (!mapRef.current) return
mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 })
}, [selectedSeg])
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0)
const highSens = segments.filter(s => s.sensitivity === '최상' || s.sensitivity === '상').reduce((a, s) => a + s.lengthM, 0)
const donePct = Math.round(doneCount / segments.length * 100)
const progPct = Math.round(progCount / segments.length * 100)
const notPct = 100 - donePct - progPct
return (
<div className="absolute inset-0 overflow-hidden">
<style>{`
.scat-map-tooltip {
background: rgba(15,21,36,0.92) !important;
border: 1px solid rgba(30,42,66,0.8) !important;
color: #e4e8f1 !important;
border-radius: 6px !important;
padding: 4px 8px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important;
}
.scat-map-tooltip::before {
border-top-color: rgba(15,21,36,0.92) !important;
}
`}</style>
<div ref={mapContainerRef} className="w-full h-full" />
{/* Status chips */}
<div className="absolute top-3.5 left-3.5 flex gap-2 z-[1000]">
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
<span className="w-1.5 h-1.5 rounded-full bg-status-green shadow-[0_0_6px_var(--green)]" />
Pre-SCAT
</div>
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-full text-[11px] text-text-2 font-korean">
· {segments.length}
</div>
</div>
{/* Right info cards */}
<div className="absolute top-3.5 right-3.5 w-[260px] flex flex-col gap-2 z-[1000] max-h-[calc(100%-100px)] overflow-y-auto scrollbar-thin">
{/* ESI Legend */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5">ESI </div>
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
{ esi: 'ESI 8', label: '쉘터 암반 해안', color: '#dc2626' },
{ esi: 'ESI 7', label: '노출 갯벌', color: '#ef4444' },
{ esi: 'ESI 6', label: '자갈·혼합 해안', color: '#f97316' },
{ esi: 'ESI 5', label: '혼합 모래/자갈', color: '#fb923c' },
{ esi: 'ESI 3-4', label: '모래 해안', color: '#facc15' },
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
<div key={i} className="flex items-center gap-2 py-1 text-[11px]">
<span className="w-3.5 h-1.5 rounded-sm flex-shrink-0" style={{ background: item.color }} />
<span className="text-text-2 font-korean">{item.label}</span>
<span className="ml-auto font-mono text-[10px] text-text-1">{item.esi}</span>
</div>
))}
</div>
{/* Progress */}
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-border rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<div className="text-[10px] font-bold uppercase tracking-wider text-text-3 mb-2.5"> </div>
<div className="flex gap-0.5 h-3 rounded overflow-hidden mb-1">
<div className="h-full transition-all duration-500" style={{ width: `${donePct}%`, background: 'var(--green)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--orange)' }} />
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--bd)' }} />
</div>
<div className="flex justify-between mt-1">
<span className="text-[9px] font-mono" style={{ color: 'var(--green)' }}> {donePct}%</span>
<span className="text-[9px] font-mono" style={{ color: 'var(--orange)' }}> {progPct}%</span>
<span className="text-[9px] font-mono text-text-3"> {notPct}%</span>
</div>
<div className="mt-2.5">
{[
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}`, 'var(--orange)'],
].map(([label, val, color], i) => (
<div key={i} className="flex justify-between py-1.5 border-b border-[rgba(30,42,74,0.3)] last:border-b-0 text-[11px]">
<span className="text-text-2 font-korean">{label}</span>
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>{val}</span>
</div>
))}
</div>
</div>
</div>
{/* Coordinates */}
<div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-border rounded-sm px-3 py-1.5 font-mono text-[11px] text-text-2 flex gap-3.5">
<span> <span className="text-status-green font-medium">{selectedSeg.lat.toFixed(4)}°N</span></span>
<span> <span className="text-status-green font-medium">{selectedSeg.lng.toFixed(4)}°E</span></span>
<span> <span className="text-status-green font-medium">1:25,000</span></span>
</div>
</div>
)
}
export default ScatMap

파일 보기

@ -0,0 +1,326 @@
import { useState, useEffect, useRef } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import type { ScatDetail } from './scatTypes'
// ═══ Popup Map (Leaflet) ═══
function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
useEffect(() => {
if (!containerRef.current) return
// 이전 맵 제거
if (mapRef.current) { mapRef.current.remove(); mapRef.current = null }
const map = L.map(containerRef.current, {
center: [lat, lng],
zoom: 15,
zoomControl: false,
attributionControl: false,
})
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map)
L.control.zoom({ position: 'topright' }).addTo(map)
// 해안 구간 라인 (시뮬레이션)
const segLine: [number, number][] = [
[lat - 0.002, lng - 0.004],
[lat - 0.001, lng - 0.002],
[lat, lng],
[lat + 0.001, lng + 0.002],
[lat + 0.002, lng + 0.004],
]
L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map)
// 조사 경로 라인
const surveyRoute: [number, number][] = [
[lat - 0.0015, lng - 0.003],
[lat - 0.0005, lng - 0.001],
[lat + 0.0005, lng + 0.001],
[lat + 0.0015, lng + 0.003],
]
L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map)
// 메인 마커
L.circleMarker([lat, lng], {
radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9,
}).bindTooltip(
`<div style="text-align:center;font-family:'Noto Sans KR',sans-serif;">
<div style="font-weight:700;font-size:11px;">${code} ${name}</div>
<div style="font-size:10px;opacity:0.7;">ESI ${esi}</div>
</div>`,
{ permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' }
).addTo(map)
// 접근 포인트
L.circleMarker([lat - 0.0015, lng - 0.003], {
radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7,
}).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map)
mapRef.current = map
return () => { map.remove(); mapRef.current = null }
}, [lat, lng, esi, esiCol, code, name])
return <div ref={containerRef} className="w-full h-full" />
}
// ═══ SCAT Popup Modal ═══
interface ScatPopupProps {
data: ScatDetail | null
segCode: string
onClose: () => void
}
function ScatPopup({
data,
segCode,
onClose,
}: ScatPopupProps) {
const [popTab, setPopTab] = useState(0)
useEffect(() => {
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
if (!data) return null
return (
<div className="fixed inset-0 bg-[rgba(5,8,18,0.75)] backdrop-blur-md z-[9999] flex items-center justify-center" onClick={onClose}>
<div
className="w-[92%] max-w-[1200px] h-[90vh] bg-bg-1 border border-border rounded-xl shadow-[0_24px_64px_rgba(0,0,0,0.5)] flex flex-col overflow-hidden"
style={{ animation: 'spIn 0.3s ease' }}
onClick={e => e.stopPropagation()}
>
<style>{`
@keyframes spIn { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
`}</style>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2.5">
<span className="text-[13px] font-bold text-status-green font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">{data.code}</span>
<span className="text-base font-bold font-korean">{data.name}</span>
<span className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white" style={{ background: data.esiColor }}>ESI {data.esi}</span>
</div>
<button onClick={onClose} className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-status-red hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"></button>
</div>
{/* Tabs */}
<div className="flex border-b border-border px-6 flex-shrink-0">
{['해안정보', '조사 이력'].map((label, i) => (
<button
key={i}
onClick={() => setPopTab(i)}
className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
popTab === i ? 'text-status-green border-status-green' : 'text-text-3 border-transparent hover:text-text-2'
}`}
>
{label}
</button>
))}
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-hidden">
{popTab === 0 && (
<div className="flex h-full overflow-hidden">
{/* Left column */}
<div className="flex-1 overflow-y-auto border-r border-border p-5 px-6 scrollbar-thin">
{/* 해안 조사 사진 */}
<div className="w-full bg-bg-0 border border-border rounded-md mb-4 relative overflow-hidden">
<img
src={`/scat-photos/${segCode}-1.png`}
alt={`${segCode} 해안 조사 사진`}
className="w-full h-auto object-contain"
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
const fallback = target.nextElementSibling as HTMLElement
if (fallback) fallback.style.display = 'flex'
}}
/>
<div className="w-full aspect-video flex-col items-center justify-center text-text-3 text-xs font-korean hidden">
<span className="text-[40px]">📷</span>
<span> </span>
</div>
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[rgba(10,14,26,0.8)] border border-[rgba(255,255,255,0.1)] rounded text-[10px] font-bold text-white font-mono backdrop-blur-sm">
{segCode}
</div>
</div>
{/* Survey Info */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🏖
</div>
{[
['유형', data.type, ''],
['기질', data.substrate, data.esiColor === '#dc2626' || data.esiColor === '#991b1b' ? 'text-status-red' : data.esiColor === '#f97316' ? 'text-status-orange' : ''],
['구간 길이', data.length, ''],
['민감도', data.sensitivity, data.sensitivity === '상' || data.sensitivity === '최상' ? 'text-status-red' : data.sensitivity === '중' ? 'text-status-orange' : 'text-status-green'],
['조사 상태', data.status, data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : ''],
['접근성', data.access, ''],
['접근 포인트', data.accessPt, ''],
].map(([k, v, cls], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
<span className="text-text-2 font-korean">{k}</span>
<span className={`text-white font-semibold font-korean ${cls}`}>{v}</span>
</div>
))}
</div>
{/* Sensitive Resources */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🌿
</div>
{data.sensitive.map((s, i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.06)] last:border-b-0">
<span className="text-text-2 font-korean">{s.t}</span>
<span className="text-white font-semibold font-korean">{s.v}</span>
</div>
))}
</div>
{/* Cleanup Methods */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
🧹
</div>
<div className="flex flex-wrap gap-1">
{data.cleanup.map((c, i) => (
<span key={i} className="inline-flex items-center gap-1 px-2 py-0.5 bg-[rgba(255,255,255,0.06)] border border-[rgba(255,255,255,0.10)] rounded text-[10px] text-text-1 font-medium font-korean">{c}</span>
))}
</div>
</div>
{/* End Criteria */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
{data.endCriteria.map((e, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-status-yellow"></span>
{e}
</div>
))}
</div>
</div>
{/* Notes */}
<div>
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
📝
</div>
<div className="px-3 py-2.5 bg-[rgba(234,179,8,0.06)] border border-[rgba(234,179,8,0.15)] rounded-sm text-[11px] text-text-2 leading-[1.7] font-korean">
{data.notes.map((n, i) => (
<div key={i} className="pl-3.5 relative mb-1">
<span className="absolute left-0 text-status-yellow"></span>
{n}
</div>
))}
</div>
</div>
</div>
{/* Right column - Satellite map */}
<div className="flex-1 overflow-y-auto p-5 px-6 scrollbar-thin">
{/* Leaflet Map */}
<div className="w-full aspect-[4/3] bg-bg-0 border border-border rounded-md mb-4 overflow-hidden relative">
<PopupMap lat={data.lat} lng={data.lng} esi={data.esi} esiCol={data.esiColor} code={data.code} name={data.name} />
</div>
{/* Legend */}
<div className="flex flex-wrap gap-1.5 mb-4">
{[
{ color: '#dc2626', label: 'ESI 고위험 구간' },
{ color: '#f97316', label: 'ESI 중위험 구간' },
{ color: '#22c55e', label: 'ESI 저위험 구간' },
{ color: '#3b82f6', label: '조사 경로' },
{ color: '#eab308', label: '접근 포인트' },
].map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-[10px] text-text-2 font-korean">
<span className="w-5 h-1 rounded-sm" style={{ background: item.color }} />
{item.label}
</div>
))}
</div>
{/* Coordinates */}
<div className="mb-4">
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
📍
</div>
{[
['시작점 위도', `${data.lat.toFixed(4)}°N`],
['시작점 경도', `${data.lng.toFixed(4)}°E`],
['끝점 위도', `${(data.lat + 0.005).toFixed(4)}°N`],
['끝점 경도', `${(data.lng + 0.008).toFixed(4)}°E`],
].map(([k, v], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
<span className="text-text-3 font-korean">{k}</span>
<span className="text-status-green font-mono font-medium">{v}</span>
</div>
))}
</div>
{/* Survey parameters */}
<div>
<div className="text-[11px] font-bold text-status-green uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[rgba(34,197,94,0.15)] font-korean flex items-center gap-1.5">
</div>
{[
['조사 일시', '2026-01-15 10:30'],
['조사팀', '제주해경 방제과'],
['기상 상태', '맑음, 풍속 3.2m/s'],
['조위', '중조 (TP +1.2m)'],
['파고', '0.5-1.0m'],
['수온', '14.2°C'],
].map(([k, v], i) => (
<div key={i} className="flex justify-between py-1.5 text-xs border-b border-[rgba(255,255,255,0.03)] last:border-b-0">
<span className="text-text-3 font-korean">{k}</span>
<span className="text-text-1 font-medium font-korean">{v}</span>
</div>
))}
</div>
</div>
</div>
)}
{popTab === 1 && (
<div className="p-6 overflow-y-auto h-full scrollbar-thin">
<div className="text-sm font-bold font-korean mb-4">{data.code} {data.name} </div>
<div className="flex flex-col gap-3">
{[
{ date: '2026-01-15', team: '제주해경 방제과', type: 'Pre-SCAT', status: '완료', note: '초기 사전조사 실시. ESI 확인.' },
].map((h, i) => (
<div key={i} className="bg-bg-3 border border-border rounded-md p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold font-mono">{h.date}</span>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-lg ${
h.type === 'Pre-SCAT' ? 'bg-[rgba(34,197,94,0.15)] text-status-green' : 'bg-[rgba(59,130,246,0.15)] text-primary-blue'
}`}>
{h.type}
</span>
</div>
<div className="text-[11px] text-text-2 font-korean mb-1">: {h.team}</div>
<div className="text-[11px] text-text-3 font-korean">{h.note}</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default ScatPopup

파일 보기

@ -0,0 +1,144 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { ScatSegment } from './scatTypes'
interface ScatTimelineProps {
segments: ScatSegment[]
currentIdx: number
onSeek: (idx: number) => void
}
function ScatTimeline({
segments,
currentIdx,
onSeek,
}: ScatTimelineProps) {
const [playing, setPlaying] = useState(false)
const [speed, setSpeed] = useState(1)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const total = Math.min(segments.length, 12)
const displaySegs = segments.slice(0, total)
const pct = ((currentIdx + 1) / total) * 100
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlaying(false)
}, [])
const play = useCallback(() => {
stop()
setPlaying(true)
intervalRef.current = setInterval(() => {
onSeek(-1) // signal to advance
}, 800 / speed)
}, [speed, stop, onSeek])
const togglePlay = () => {
if (playing) stop()
else play()
}
useEffect(() => {
if (playing && intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
onSeek(-1)
}, 800 / speed)
}
}, [speed, playing, onSeek])
useEffect(() => {
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [])
const cycleSpeed = () => {
const speeds = [1, 2, 4]
setSpeed(s => speeds[(speeds.indexOf(s) + 1) % speeds.length])
}
const doneCount = segments.filter(s => s.status === '완료').length
const progCount = segments.filter(s => s.status === '진행중').length
const totalLen = segments.reduce((a, s) => a + s.lengthM, 0)
return (
<div className="absolute bottom-0 left-0 right-0 h-[72px] bg-[rgba(15,21,36,0.95)] backdrop-blur-2xl border-t border-border flex items-center px-5 gap-4 z-[40]">
{/* Controls */}
<div className="flex gap-1 flex-shrink-0">
<button onClick={() => onSeek(0)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm"></button>
<button onClick={() => onSeek(Math.max(0, currentIdx - 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm"></button>
<button onClick={togglePlay} className={`w-[34px] h-[34px] rounded-sm border flex items-center justify-center cursor-pointer text-sm ${playing ? 'bg-status-green text-black border-status-green' : 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover'}`}>
{playing ? '⏸' : '▶'}
</button>
<button onClick={() => onSeek(Math.min(total - 1, currentIdx + 1))} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm"></button>
<button onClick={() => onSeek(total - 1)} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-sm"></button>
<div className="w-2" />
<button onClick={cycleSpeed} className="w-[34px] h-[34px] rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center cursor-pointer hover:bg-bg-hover text-xs font-mono font-bold">{speed}×</button>
</div>
{/* Progress */}
<div className="flex-1 flex flex-col gap-1.5">
<div className="flex justify-between px-1">
{displaySegs.map((s, i) => (
<span
key={i}
className={`text-[10px] font-mono cursor-pointer ${i === currentIdx ? 'text-status-green font-semibold' : 'text-text-3'}`}
onClick={() => onSeek(i)}
>
{s.code}
</span>
))}
</div>
<div className="relative h-6 flex items-center">
<div className="w-full h-1 bg-border rounded relative">
<div className="absolute top-0 left-0 h-full rounded transition-all duration-300" style={{ width: `${pct}%`, background: 'linear-gradient(90deg, var(--green), #4ade80)' }} />
{/* Markers */}
{displaySegs.map((s, i) => {
const x = ((i + 0.5) / total) * 100
return (
<div key={i} className="absolute w-0.5 bg-border-light" style={{ left: `${x}%`, top: -3, height: i % 3 === 0 ? 14 : 10 }}>
{s.status === '완료' && (
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px] cursor-pointer" style={{ filter: 'drop-shadow(0 0 4px rgba(34,197,94,0.5))' }}></span>
)}
{s.status === '진행중' && (
<span className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px]"></span>
)}
</div>
)
})}
</div>
{/* Thumb */}
<div
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-status-green border-[3px] border-bg-0 rounded-full cursor-grab shadow-[0_0_10px_rgba(34,197,94,0.4)] z-[2] transition-all duration-300"
style={{ left: `${pct}%`, transform: `translate(-50%, -50%)` }}
/>
</div>
</div>
{/* Info */}
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[210px]">
<span className="text-sm font-semibold text-status-green font-mono">
{displaySegs[currentIdx]?.code || 'S-001'} / {total}
</span>
<div className="flex gap-3.5">
<span className="flex items-center gap-1.5 text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--green)' }}>{doneCount}/{segments.length}</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]">
<span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-semibold font-mono" style={{ color: 'var(--orange)' }}>{progCount}/{segments.length}</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]">
<span className="text-text-2 font-korean"> </span>
<span className="text-text-1 font-semibold font-mono">{(totalLen / 1000).toFixed(1)} km</span>
</span>
</div>
</div>
</div>
)
}
export default ScatTimeline

파일 보기

@ -0,0 +1,387 @@
import type { ScatSegment, ScatDetail } from './scatTypes'
// ═══ ESI 색상 ═══
export const esiColor = (n: number): string => {
if (n >= 10) return '#991b1b'
if (n >= 9) return '#b91c1c'
if (n >= 8) return '#dc2626'
if (n >= 7) return '#ef4444'
if (n >= 6) return '#f97316'
if (n >= 5) return '#fb923c'
if (n >= 4) return '#facc15'
if (n >= 3) return '#a3e635'
if (n >= 2) return '#22c55e'
return '#4ade80'
}
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': 'var(--red)', '중': 'var(--orange)', '하': 'var(--green)' }
export const statusColor: Record<string, string> = { '완료': 'var(--green)', '진행중': 'var(--orange)', '미조사': 'var(--t3)' }
export const esiLevel = (n: number) => n >= 8 ? 'h' : n >= 5 ? 'm' : 'l'
// ═══ Mock Data ═══
// --- 서귀포시 (서귀포해양경비안전서 관할) ---
export const sgAreas = [
{ area: '성산읍', code: 'SGSS', cnt: 99, villages: ['시흥리', '오조리', '성산리', '고성리', '온평리', '신산리', '삼달리', '신풍리', '신천리'], jurisdiction: '서귀포' },
{ area: '표선면', code: 'SGPS', cnt: 41, villages: ['하천리', '표선리', '세화리'], jurisdiction: '서귀포' },
{ area: '남원읍', code: 'SGNW', cnt: 73, villages: ['신흥리', '태흥리', '남원리', '위미리', '신례리'], jurisdiction: '서귀포' },
{ area: '하효동·보목동', code: 'SGHY', cnt: 8, villages: ['하효동', '보목동'], jurisdiction: '서귀포' },
{ area: '토평동·동흥동', code: 'SGTP', cnt: 12, villages: ['토평동', '동흥동'], jurisdiction: '서귀포' },
{ area: '서귀동·서홍동', code: 'SGSG', cnt: 20, villages: ['서귀동', '서홍동'], jurisdiction: '서귀포' },
{ area: '호근동·법환동', code: 'SGHG', cnt: 6, villages: ['호근동', '서호동', '법환동'], jurisdiction: '서귀포' },
{ area: '강정동', code: 'SGGJ', cnt: 21, villages: ['강정동'], jurisdiction: '서귀포' },
{ area: '월평동·대포동', code: 'SGWP', cnt: 4, villages: ['월평동', '하원동', '대포동'], jurisdiction: '서귀포' },
{ area: '중문동', code: 'SGJM', cnt: 8, villages: ['중문동'], jurisdiction: '서귀포' },
{ area: '색달동·하예동', code: 'SGSE', cnt: 8, villages: ['색달동', '하예동'], jurisdiction: '서귀포' },
{ area: '안덕면', code: 'SGAD', cnt: 38, villages: ['감산리', '사계리', '덕수리', '창천리', '대평리', '화순리'], jurisdiction: '서귀포' },
{ area: '대정읍', code: 'SGDJ', cnt: 79, villages: ['상모리', '하모리', '영락리', '인성리', '보성리', '무릉리', '신도리'], jurisdiction: '서귀포' },
]
// --- 제주시 (제주해양경비안전서 관할) ---
export const jjAreas = [
{ area: '한경면', code: 'JJHG', cnt: 81, villages: ['고산리', '금등리', '두모리', '신창리', '용수리', '판포리'], jurisdiction: '제주' },
{ area: '한림읍', code: 'JJHL', cnt: 87, villages: ['귀덕리', '금능리', '수원리', '옹포리', '월령리', '한림리', '한수리', '협재리'], jurisdiction: '제주' },
{ area: '애월읍', code: 'JJAW', cnt: 89, villages: ['고내리', '곽지리', '구엄리', '금성리', '신엄리', '애월리', '하귀1리', '하귀2리'], jurisdiction: '제주' },
{ area: '외도이동', code: 'JJOD', cnt: 19, villages: ['외도이동'], jurisdiction: '제주' },
{ area: '내도동', code: 'JJND', cnt: 7, villages: ['내도동'], jurisdiction: '제주' },
{ area: '이호일동', code: 'JJIH', cnt: 20, villages: ['이호일동'], jurisdiction: '제주' },
{ area: '도두동', code: 'JJDD', cnt: 17, villages: ['도두일동', '도두이동'], jurisdiction: '제주' },
{ area: '용담동', code: 'JJYD', cnt: 19, villages: ['용담삼동', '용담이동', '용담일동'], jurisdiction: '제주' },
{ area: '삼도이동', code: 'JJSD', cnt: 2, villages: ['삼도2동'], jurisdiction: '제주' },
{ area: '건입동', code: 'JJGI', cnt: 26, villages: ['건입동'], jurisdiction: '제주' },
{ area: '화북일동', code: 'JJHB', cnt: 23, villages: ['화북일동'], jurisdiction: '제주' },
{ area: '삼양삼동', code: 'JJYN', cnt: 19, villages: ['삼양삼동', '삼양이동', '삼양일동'], jurisdiction: '제주' },
{ area: '삼양일동', code: 'JJSY', cnt: 24, villages: ['삼양이동', '삼양일동'], jurisdiction: '제주' },
{ area: '조천읍', code: 'JJJC', cnt: 95, villages: ['북촌리', '신촌리', '신흥리', '조천리', '함덕리'], jurisdiction: '제주' },
{ area: '구좌읍', code: 'JJGJ', cnt: 147, villages: ['김녕리', '동복리', '상도리', '월정리', '종달리', '평대리', '하도리', '한동리', '행원리'], jurisdiction: '제주' },
]
export const scatAreas = [...sgAreas, ...jjAreas]
export const scatSubstrates = ['투과성 인공호안', '수직호안', '모래', '모래자갈혼합', '자갈·왕자갈', '수평암반', '수직암반']
export const substrateESI: Record<string, { esi: string; n: number }> = {
'투과성 인공호안': { esi: '6B', n: 6 }, '수직호안': { esi: '1B', n: 1 },
'모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 },
'자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 },
}
export const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']]
const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하'
const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사']
// 지역별 좌표 범위 (제주도 전체 해안)
export const areaCoords: Record<string, { latC: number; lngC: number; latR: number; lngR: number }> = {
// 서귀포시 (남부 해안)
SGSS: { latC: 33.39, lngC: 126.89, latR: 0.07, lngR: 0.05 },
SGPS: { latC: 33.33, lngC: 126.81, latR: 0.03, lngR: 0.04 },
SGNW: { latC: 33.26, lngC: 126.63, latR: 0.02, lngR: 0.05 },
SGHY: { latC: 33.245, lngC: 126.59, latR: 0.005, lngR: 0.02 },
SGTP: { latC: 33.245, lngC: 126.555, latR: 0.005, lngR: 0.015 },
SGSG: { latC: 33.245, lngC: 126.53, latR: 0.005, lngR: 0.015 },
SGHG: { latC: 33.245, lngC: 126.50, latR: 0.005, lngR: 0.02 },
SGGJ: { latC: 33.245, lngC: 126.45, latR: 0.005, lngR: 0.03 },
SGWP: { latC: 33.245, lngC: 126.40, latR: 0.005, lngR: 0.02 },
SGJM: { latC: 33.245, lngC: 126.37, latR: 0.005, lngR: 0.015 },
SGSE: { latC: 33.245, lngC: 126.34, latR: 0.005, lngR: 0.015 },
SGAD: { latC: 33.24, lngC: 126.29, latR: 0.01, lngR: 0.035 },
SGDJ: { latC: 33.25, lngC: 126.21, latR: 0.035, lngR: 0.05 },
// 제주시 (북부 해안)
JJHG: { latC: 33.31, lngC: 126.19, latR: 0.04, lngR: 0.04 },
JJHL: { latC: 33.39, lngC: 126.26, latR: 0.04, lngR: 0.05 },
JJAW: { latC: 33.46, lngC: 126.35, latR: 0.04, lngR: 0.06 },
JJOD: { latC: 33.505, lngC: 126.43, latR: 0.005, lngR: 0.015 },
JJND: { latC: 33.505, lngC: 126.44, latR: 0.003, lngR: 0.008 },
JJIH: { latC: 33.50, lngC: 126.46, latR: 0.005, lngR: 0.012 },
JJDD: { latC: 33.51, lngC: 126.49, latR: 0.005, lngR: 0.012 },
JJYD: { latC: 33.515, lngC: 126.52, latR: 0.005, lngR: 0.015 },
JJSD: { latC: 33.515, lngC: 126.525, latR: 0.003, lngR: 0.005 },
JJGI: { latC: 33.52, lngC: 126.545, latR: 0.005, lngR: 0.015 },
JJHB: { latC: 33.52, lngC: 126.565, latR: 0.005, lngR: 0.012 },
JJYN: { latC: 33.52, lngC: 126.585, latR: 0.005, lngR: 0.012 },
JJSY: { latC: 33.52, lngC: 126.59, latR: 0.005, lngR: 0.012 },
JJJC: { latC: 33.535, lngC: 126.64, latR: 0.015, lngR: 0.04 },
JJGJ: { latC: 33.53, lngC: 126.78, latR: 0.03, lngR: 0.10 },
}
// 제주도 전체 해안선 좌표 (시계방향: 대정읍→서귀포→성산→조천→구좌→한경)
export const jejuCoastCoords: [number, number][] = [
// 서부 (대정읍~한경면)
[33.2800, 126.1600], [33.2600, 126.1800], [33.2400, 126.2000],
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
[33.2300, 126.2300], [33.2350, 126.2600], [33.2400, 126.2900], [33.2450, 126.3200],
[33.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300],
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300],
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200],
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800],
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800],
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310],
// 동부 (성산~구좌)
[33.4700, 126.9200], [33.4900, 126.9100], [33.5100, 126.8700],
[33.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900],
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
[33.5500, 126.7600], [33.5500, 126.7300], [33.5450, 126.7000],
[33.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200],
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400],
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600],
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800],
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900],
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200],
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620],
]
function seededRandom(seed: number) {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const generateSegments = (): ScatSegment[] => {
const segs: ScatSegment[] = []
let idx = 0
scatAreas.forEach(a => {
const ac = areaCoords[a.code]
for (let i = 0; i < a.cnt; i++) {
const seed = idx * 137 + 42
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)]
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)]
const { esi: esiStr, n: esiNum } = substrateESI[substrate]
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100
// 지역 좌표 범위 내 분포
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003
segs.push({
id: idx,
code: `${a.code}-${i + 1}`,
area: a.area,
name: `${village} 해안`,
type: substrate,
esi: esiStr,
esiNum,
length: `${lengthM.toLocaleString()}.0 m`,
lengthM,
sensitivity: sensFromESI(esiNum),
status: statusArr[Math.floor(seededRandom(seed + 5) * statusArr.length)],
lat, lng,
tags: scatTagSets[Math.floor(seededRandom(seed + 8) * scatTagSets.length)],
jurisdiction: a.jurisdiction,
})
idx++
}
})
return segs
}
export const allSegments = generateSegments()
export const scatDetailData: ScatDetail[] = [
// ═══ 서귀포시 (남부 해안) ═══
// SGSS-1: 성산읍 시흥리 — 투과성 인공호안
{
code: 'SGSS-1', name: '서귀포시 성산읍 시흥리', esi: '6B', esiColor: '#f97316', lat: 33.4610, lng: 126.9310,
type: '폐쇄형', substrate: '투과성 인공호안', length: '846.4m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1270-1, 12-64',
sensitive: [{ t: '사회·경제적', v: '올레길1코스, 파래양식장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGSS-6: 성산읍 시흥리 — 모래
{
code: 'SGSS-6', name: '서귀포시 성산읍 시흥리', esi: '3A', esiColor: '#a3e635', lat: 33.4580, lng: 126.9200,
type: '폐쇄형', substrate: '모래', length: '131.3m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1',
sensitive: [{ t: '사회·경제적', v: '숙박시설, 조가비박물관' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
},
// SGSS-10: 성산읍 오조리 — 수평암반
{
code: 'SGSS-10', name: '서귀포시 성산읍 오조리', esi: '8A', esiColor: '#dc2626', lat: 33.4500, lng: 126.9050,
type: '개방형', substrate: '수평암반', length: '433.6m', sensitivity: '상', status: '완료',
access: '도보로 접근 가능, 인근구획에서 접근', accessPt: '서귀포시 성산읍 오조리 391',
sensitive: [{ t: '사회·경제적', v: '교육시설(성산고등학교)' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// SGPS-6: 표선면 표선리 — 모래 (표선해수욕장)
{
code: 'SGPS-6', name: '서귀포시 표선면 표선리', esi: '3A', esiColor: '#a3e635', lat: 33.3270, lng: 126.8320,
type: '폐쇄형', substrate: '모래', length: '827.9m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 표선면 표선리 464-1',
sensitive: [{ t: '사회·경제적', v: '표선해수욕장, 올레길3코스, 숙박시설, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// SGNW-5: 남원읍 태흥리 — 수평암반
{
code: 'SGNW-5', name: '서귀포시 남원읍 태흥리', esi: '8A', esiColor: '#dc2626', lat: 33.2510, lng: 126.6650,
type: '개방형', substrate: '수평암반', length: '432.8m', sensitivity: '상', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 7',
sensitive: [{ t: '사회·경제적', v: '육상양식장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// SGNW-12: 남원읍 태흥리 — 모래자갈혼합
{
code: 'SGNW-12', name: '서귀포시 남원읍 태흥리', esi: '5', esiColor: '#fb923c', lat: 33.2480, lng: 126.6400,
type: '폐쇄형', substrate: '모래자갈혼합', length: '237.3m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 364-2',
sensitive: [{ t: '사회·경제적', v: '올레길4코스, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능'],
},
// SGTP-5: 서귀동 — 투과성 인공호안 (서귀포항)
{
code: 'SGTP-5', name: '서귀포시 서귀동', esi: '6B', esiColor: '#f97316', lat: 33.2400, lng: 126.5550,
type: '개방형', substrate: '투과성 인공호안', length: '701.6m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 서귀동 758-5',
sensitive: [{ t: '사회·경제적', v: '서귀포항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGGJ-5: 강정동 — 수직호안
{
code: 'SGGJ-5', name: '서귀포시 강정동', esi: '1B', esiColor: '#4ade80', lat: 33.2430, lng: 126.4500,
type: '폐쇄형', substrate: '수직호안', length: '380.0m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 강정동 산1',
sensitive: [{ t: '사회·경제적', v: '강정항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적'],
},
// SGAD-5: 안덕면 감산리 — 수직호안 (대평항)
{
code: 'SGAD-5', name: '서귀포시 안덕면 감산리', esi: '1B', esiColor: '#4ade80', lat: 33.2400, lng: 126.2950,
type: '폐쇄형', substrate: '수직호안', length: '246.9m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 982-1',
sensitive: [{ t: '사회·경제적', v: '대평항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGAD-7: 안덕면 감산리 — 자갈·왕자갈
{
code: 'SGAD-7', name: '서귀포시 안덕면 감산리', esi: '6A', esiColor: '#f97316', lat: 33.2380, lng: 126.2850,
type: '폐쇄형', substrate: '자갈·왕자갈', length: '154.2m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 985',
sensitive: [{ t: '사회·경제적', v: '올레길8코스(해안로), 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
},
// SGDJ-5: 대정읍 상모리 — 수직호안 (산이수동항)
{
code: 'SGDJ-5', name: '서귀포시 대정읍 상모리', esi: '1B', esiColor: '#4ade80', lat: 33.2300, lng: 126.2350,
type: '개방형', substrate: '수직호안', length: '202.0m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 대정읍 상모리 133',
sensitive: [{ t: '사회·경제적', v: '산이수동항' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGDJ-7: 대정읍 상모리 — 모래 (송악산)
{
code: 'SGDJ-7', name: '서귀포시 대정읍 상모리', esi: '3A', esiColor: '#a3e635', lat: 33.2280, lng: 126.2280,
type: '개방형', substrate: '모래', length: '179.6m', sensitivity: '하', status: '미조사',
access: '도보로 접근 가능', accessPt: '서귀포시 대정읍 상모리 179-3',
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// SGDJ-8: 대정읍 상모리 — 수직암반 (송악산)
{
code: 'SGDJ-8', name: '서귀포시 대정읍 상모리', esi: '1A', esiColor: '#4ade80', lat: 33.2260, lng: 126.2200,
type: '개방형', substrate: '수직암반', length: '585.1m', sensitivity: '하', status: '완료',
access: '선박을 이용하여 접근', accessPt: '서귀포시 대정읍 상모리 179-3',
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// ═══ 제주시 (북부 해안) ═══
// JJHG-1: 한경면 고산리 — 수평암반
{
code: 'JJHG-1', name: '제주시 한경면 고산리', esi: '8A', esiColor: '#dc2626', lat: 33.2930, lng: 126.1620,
type: '개방형', substrate: '수평암반', length: '306.0m', sensitivity: '상', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한경면 고산리 3987',
sensitive: [{ t: '사회·경제적', v: '육상양식장(도로 주변 농사구역)' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이', '부분적으로 모래와 암반이 형성되어 있음'],
},
// JJHG-8: 한경면 고산리 — 투과성 인공호안 (차귀도항)
{
code: 'JJHG-8', name: '제주시 한경면 고산리', esi: '6B', esiColor: '#f97316', lat: 33.3100, lng: 126.1750,
type: '개방형', substrate: '투과성 인공호안', length: '201.8m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 소형선박 이용 방제작업 가능', accessPt: '제주시 한경면 고산리 3616-10',
sensitive: [{ t: '사회·경제적', v: '차귀도항, 잠수함 매표소' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업(흡착제,걸레)에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJHL-4: 한림읍 월령리 — 모래
{
code: 'JJHL-4', name: '제주시 한림읍 월령리', esi: '3A', esiColor: '#a3e635', lat: 33.3900, lng: 126.2400,
type: '폐쇄형', substrate: '모래', length: '100.2m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한림읍 월령리 3855',
sensitive: [{ t: '사회·경제적', v: '월령항, 숙박시설' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
},
// JJAW-8: 애월읍 곽지리 — 모래 (곽지해수욕장)
{
code: 'JJAW-8', name: '제주시 애월읍 곽지리', esi: '3A', esiColor: '#a3e635', lat: 33.4700, lng: 126.3400,
type: '개방형', substrate: '모래', length: '573.6m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 애월읍 곽지리 3855',
sensitive: [{ t: '사회·경제적', v: '곽지해수욕장, 캠핑장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// JJGI-3: 건입동 — 수직호안 (제주항)
{
code: 'JJGI-3', name: '제주시 건입동', esi: '1B', esiColor: '#4ade80', lat: 33.5200, lng: 126.5450,
type: '폐쇄형', substrate: '수직호안', length: '365.8m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 건입동 3855',
sensitive: [{ t: '사회·경제적', v: '제주항, 제주조선' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 호안의 거친 표면에 쉽게 표착될 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJJC-4: 조천읍 신촌리 — 자갈·왕자갈
{
code: 'JJJC-4', name: '제주시 조천읍 신촌리', esi: '6A', esiColor: '#f97316', lat: 33.5380, lng: 126.6400,
type: '폐쇄형', substrate: '자갈·왕자갈', length: '360.4m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 조천읍 신촌리 3855',
sensitive: [{ t: '사회·경제적', v: '정치망어장(전면 270m)' }, { t: '생물자원', v: '폐류 서식지' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
},
// JJGJ-2: 구좌읍 동복리 — 투과성 인공호안
{
code: 'JJGJ-2', name: '제주시 구좌읍 동복리', esi: '6B', esiColor: '#f97316', lat: 33.5500, lng: 126.7300,
type: '폐쇄형', substrate: '투과성 인공호안', length: '219.2m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
sensitive: [{ t: '사회·경제적', v: '접안시설' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJGJ-3: 구좌읍 동복리 — 수평암반
{
code: 'JJGJ-3', name: '제주시 구좌읍 동복리', esi: '8A', esiColor: '#dc2626', lat: 33.5480, lng: 126.7350,
type: '개방형', substrate: '수평암반', length: '197.4m', sensitivity: '상', status: '미조사',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
sensitive: [{ t: '사회·경제적', v: '산책로, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
]

파일 보기

@ -0,0 +1,37 @@
export interface ScatSegment {
id: number
code: string
area: string
name: string
type: string
esi: string
esiNum: number
length: string
lengthM: number
sensitivity: '최상' | '상' | '중' | '하'
status: '완료' | '진행중' | '미조사'
lat: number
lng: number
tags: string[]
jurisdiction: string
}
export interface ScatDetail {
code: string
name: string
esi: string
esiColor: string
lat: number
lng: number
type: string
substrate: string
length: string
sensitivity: string
status: string
access: string
accessPt: string
sensitive: { t: string; v: string }[]
cleanup: string[]
endCriteria: string[]
notes: string[]
}

파일 보기

@ -0,0 +1 @@
export { PreScatView } from './components/PreScatView'