feat(design): 디자인 시스템 토큰 적용 및 Float 카탈로그 추가
This commit is contained in:
부모
afa1d16b6b
커밋
109c0d2480
19
CLAUDE.md
19
CLAUDE.md
@ -125,6 +125,25 @@ wing/
|
||||
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
|
||||
- 개별 탭 개발자는 공통 가이드를 참조하여 연동 구현
|
||||
|
||||
## 진행 중 작업 (완료 후 삭제)
|
||||
|
||||
### 디자인 시스템 폰트+색상 통일 작업
|
||||
|
||||
compact 후 반드시 `memory/design-system-work.md`를 읽고 작업 상태(완료/미완료 컴포넌트)를 확인할 것.
|
||||
|
||||
**색상 규칙:**
|
||||
- 하드코딩 색상(`#ef4444`, `#a855f7` 등) → CSS 변수 전환
|
||||
- `rgba(59,130,246,...)` 등 비-accent 계열 → `rgba(6,182,212,...)` (accent cyan)
|
||||
- 시맨틱 컬러(`color-accent`, `color-info`, `color-caution` 등)는 다양하게 사용 가능하되, 강조 색상은 **최대 2가지**로 제한
|
||||
- `linear-gradient` → 단색으로 단순화
|
||||
- 장식용 `border-top`, `border-left` → 제거 여부를 유저에게 확인 후 진행
|
||||
|
||||
**폰트 규칙:**
|
||||
- 하드코딩 `fontSize`/`fontWeight` → Tailwind 토큰 (`text-title-2`, `text-caption` 등)
|
||||
- `fontFamily: monospace` → `var(--font-mono)`
|
||||
- `fontFamily: sans-serif` / `'Noto Sans KR'` → `var(--font-korean)`
|
||||
- 인라인 `style={{ fontSize, padding }}` → Tailwind 클래스 전환 (가능한 범위)
|
||||
|
||||
## 환경 설정
|
||||
|
||||
- Node.js 20 (`.node-version`, fnm 사용)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M4 12 Q16 0 28 12 Q22 15 16 13 Q10 15 4 12 Z" fill="#06b6d4"/>
|
||||
<path d="M4 19 Q10 15 16 19 T28 19 L28 22 Q22 26 16 22 T4 22 Z" fill="#06b6d4"/>
|
||||
<path d="M4 25 Q10 21 16 25 T28 25 L28 28 Q22 32 16 28 T4 28 Z" fill="#06b6d4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | 크기: 320 B |
@ -113,7 +113,7 @@ export function LoginPage() {
|
||||
{/* User ID */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
|
||||
className="block text-caption font-semibold text-fg-disabled mb-1.5"
|
||||
style={{ letterSpacing: '0.3px' }}
|
||||
>
|
||||
아이디
|
||||
@ -147,7 +147,7 @@ export function LoginPage() {
|
||||
placeholder="사용자 아이디 입력"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
@ -167,7 +167,7 @@ export function LoginPage() {
|
||||
{/* Password */}
|
||||
<div className="mb-5">
|
||||
<label
|
||||
className="block text-[10px] font-semibold text-fg-disabled mb-1.5"
|
||||
className="block text-caption font-semibold text-fg-disabled mb-1.5"
|
||||
style={{ letterSpacing: '0.3px' }}
|
||||
>
|
||||
비밀번호
|
||||
@ -200,7 +200,7 @@ export function LoginPage() {
|
||||
}}
|
||||
placeholder="비밀번호 입력"
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-[13px] outline-none"
|
||||
className="w-full bg-bg-elevated border border-stroke rounded-md text-title-4 outline-none"
|
||||
style={{
|
||||
padding: '11px 14px 11px 38px',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
@ -219,7 +219,7 @@ export function LoginPage() {
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-disabled cursor-pointer">
|
||||
<label className="flex items-center gap-1.5 text-label-2 text-fg-disabled cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
@ -230,7 +230,7 @@ export function LoginPage() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-color-accent cursor-pointer bg-transparent border-none"
|
||||
className="text-label-2 text-color-accent cursor-pointer bg-transparent border-none"
|
||||
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||
>
|
||||
@ -241,7 +241,7 @@ export function LoginPage() {
|
||||
{/* Pending approval */}
|
||||
{pendingMessage && (
|
||||
<div
|
||||
className="flex items-start gap-2 text-[11px] rounded-sm mb-4"
|
||||
className="flex items-start gap-2 text-label-2 rounded-sm mb-4"
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
@ -271,7 +271,7 @@ export function LoginPage() {
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-[11px] rounded-sm mb-4"
|
||||
className="flex items-center gap-1.5 text-label-2 rounded-sm mb-4"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
@ -279,7 +279,7 @@ export function LoginPage() {
|
||||
color: '#f87171',
|
||||
}}
|
||||
>
|
||||
<span className="text-[13px]">
|
||||
<span className="text-title-4">
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
@ -353,7 +353,7 @@ export function LoginPage() {
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 my-6">
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
<span className="text-[9px] text-fg-disabled">또는</span>
|
||||
<span className="text-caption text-fg-disabled">또는</span>
|
||||
<div className="flex-1 bg-border h-px" />
|
||||
</div>
|
||||
|
||||
@ -375,7 +375,7 @@ export function LoginPage() {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-[11px] font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
|
||||
className="w-full rounded-md bg-bg-card border border-stroke text-fg-sub text-label-2 font-semibold cursor-pointer flex items-center justify-center gap-1.5 px-[10px] py-[10px]"
|
||||
style={{ transition: 'background 0.15s' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-surface-hover)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
|
||||
@ -407,7 +407,7 @@ export function LoginPage() {
|
||||
border: '1px solid rgba(6,182,212,0.08)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[9px] font-bold text-color-accent mb-1.5">데모 계정</div>
|
||||
<div className="text-caption font-bold text-color-accent mb-1.5">데모 계정</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{DEMO_ACCOUNTS.map((acc) => (
|
||||
<div
|
||||
@ -427,10 +427,10 @@ export function LoginPage() {
|
||||
}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span className="text-[9px] text-fg-sub font-mono">
|
||||
<span className="text-caption text-fg-sub font-mono">
|
||||
{acc.id} / {acc.password}
|
||||
</span>
|
||||
<span className="text-[8px] text-fg-disabled">{acc.label}</span>
|
||||
<span className="text-caption text-fg-disabled">{acc.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -440,7 +440,7 @@ export function LoginPage() {
|
||||
{/* end form card */}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center text-[9px] text-fg-disabled mt-6 leading-[1.6]">
|
||||
<div className="text-center text-caption text-fg-disabled mt-6 leading-[1.6]">
|
||||
<div>WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템</div>
|
||||
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
|
||||
© 2026 Korea Coast Guard. All rights reserved.
|
||||
|
||||
@ -42,7 +42,7 @@ export function LayerTree({
|
||||
return (
|
||||
<div className="px-1">
|
||||
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-stroke">
|
||||
<span className="text-[10px] font-semibold text-fg-disabled">전체 레이어</span>
|
||||
<span className="text-caption font-semibold text-fg-disabled">전체 레이어</span>
|
||||
<div
|
||||
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
|
||||
onClick={handleToggleAll}
|
||||
@ -260,7 +260,7 @@ function LayerNode({
|
||||
<div>
|
||||
<div className="lyr-t gap-1.5">
|
||||
<span
|
||||
className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-[7px] w-[10px] text-center`}
|
||||
className={`lyr-arr ${expanded ? 'open' : ''} cursor-pointer text-caption w-[10px] text-center`}
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
▶
|
||||
|
||||
@ -216,7 +216,7 @@ export function BacktrackReplayBar({
|
||||
{/* Collision marker */}
|
||||
{collisionEvent && (
|
||||
<div
|
||||
className="absolute text-[10px] cursor-pointer"
|
||||
className="absolute text-caption cursor-pointer"
|
||||
style={{
|
||||
top: '-14px',
|
||||
left: `${collisionEvent.progressPercent}%`,
|
||||
@ -244,7 +244,7 @@ export function BacktrackReplayBar({
|
||||
</div>
|
||||
|
||||
{/* Time labels */}
|
||||
<div className="flex justify-between text-[9px] font-mono">
|
||||
<div className="flex justify-between text-caption font-mono">
|
||||
<span className="text-fg-disabled">{startLabel}</span>
|
||||
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
||||
<span className="text-fg-disabled">{endLabel}</span>
|
||||
@ -257,13 +257,13 @@ export function BacktrackReplayBar({
|
||||
{replayShips.map((ship) => (
|
||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||||
<span className="text-[9px] text-fg-sub font-mono">{ship.vesselName}</span>
|
||||
<span className="text-caption text-fg-sub font-mono">{ship.vesselName}</span>
|
||||
</div>
|
||||
))}
|
||||
{hasBackwardParticles && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#a855f7', opacity: 0.8 }} />
|
||||
<span className="text-[9px] text-fg-sub font-mono">역방향 예측</span>
|
||||
<span className="text-caption text-fg-sub font-mono">역방향 예측</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -26,7 +26,7 @@ export function MeasureOverlay() {
|
||||
e.stopPropagation();
|
||||
removeMeasurement(mk.id);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] font-semibold text-white bg-[rgba(239,68,68,0.85)] hover:bg-[rgba(239,68,68,1)] rounded shadow-lg border border-[rgba(255,255,255,0.2)] cursor-pointer font-korean"
|
||||
className="px-2 py-0.5 text-label-2 font-semibold text-white bg-[rgba(239,68,68,0.85)] hover:bg-[rgba(239,68,68,1)] rounded shadow-lg border border-[rgba(255,255,255,0.2)] cursor-pointer font-korean"
|
||||
>
|
||||
지우기
|
||||
</button>
|
||||
|
||||
@ -39,7 +39,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
||||
>
|
||||
<span>{displayText}</span>
|
||||
<span
|
||||
className="text-[8px] text-fg-disabled"
|
||||
className="text-caption text-fg-disabled"
|
||||
style={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
@ -67,7 +67,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="text-[11px] cursor-pointer"
|
||||
className="text-label-2 cursor-pointer"
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||
|
||||
@ -1125,11 +1125,11 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-bold text-[15px]" style={{ color: '#e2e8f0' }}>
|
||||
<span className="font-bold text-subtitle" style={{ color: '#e2e8f0' }}>
|
||||
Wing 사용자 매뉴얼
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] px-2 py-0.5 rounded font-mono"
|
||||
className="text-label-2 px-2 py-0.5 rounded font-mono"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
color: '#06b6d4',
|
||||
@ -1141,7 +1141,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center w-7 h-7 rounded text-[13px] font-semibold transition-colors"
|
||||
className="flex items-center justify-center w-7 h-7 rounded text-title-4 font-semibold transition-colors"
|
||||
style={{ color: '#94a3b8', background: 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1a2540';
|
||||
@ -1194,7 +1194,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-[10px] font-bold font-mono"
|
||||
className="flex-shrink-0 w-7 h-7 rounded flex items-center justify-center text-caption font-bold font-mono"
|
||||
style={{
|
||||
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
|
||||
color: isActive ? '#06b6d4' : '#64748b',
|
||||
@ -1205,13 +1205,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-[12px] font-medium leading-tight truncate"
|
||||
className="text-label-1 font-medium leading-tight truncate"
|
||||
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
|
||||
>
|
||||
{chapter.title}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] leading-tight mt-0.5 truncate"
|
||||
className="text-caption leading-tight mt-0.5 truncate"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
{chapter.subtitle}
|
||||
@ -1230,7 +1230,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-[11px] font-mono px-2 py-0.5 rounded font-bold"
|
||||
className="text-label-2 font-mono px-2 py-0.5 rounded font-bold"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
color: '#06b6d4',
|
||||
@ -1239,20 +1239,20 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
>
|
||||
CH {selectedChapter.number}
|
||||
</span>
|
||||
<h2 className="text-[16px] font-semibold" style={{ color: '#e2e8f0' }}>
|
||||
<h2 className="text-title-2 font-semibold" style={{ color: '#e2e8f0' }}>
|
||||
{selectedChapter.title}
|
||||
</h2>
|
||||
<span className="text-[12px]" style={{ color: '#475569' }}>
|
||||
<span className="text-label-1" style={{ color: '#475569' }}>
|
||||
{selectedChapter.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] mr-1" style={{ color: '#64748b' }}>
|
||||
<span className="text-label-2 mr-1" style={{ color: '#64748b' }}>
|
||||
{selectedChapter.screens.length}개 화면
|
||||
</span>
|
||||
<button
|
||||
onClick={allExpanded ? collapseAll : expandAll}
|
||||
className="text-[11px] px-3 py-1 rounded transition-colors"
|
||||
className="text-label-2 px-3 py-1 rounded transition-colors"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.08)',
|
||||
color: '#06b6d4',
|
||||
@ -1298,7 +1298,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] font-mono font-bold px-1.5 py-0.5 rounded"
|
||||
className="flex-shrink-0 text-caption font-mono font-bold px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
color: '#06b6d4',
|
||||
@ -1310,13 +1310,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{screen.id}
|
||||
</span>
|
||||
<span
|
||||
className="flex-1 text-[13px] font-medium"
|
||||
className="flex-1 text-title-4 font-medium"
|
||||
style={{ color: '#cbd5e1' }}
|
||||
>
|
||||
{screen.name}
|
||||
</span>
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] font-mono"
|
||||
className="flex-shrink-0 text-caption font-mono"
|
||||
style={{
|
||||
color: '#475569',
|
||||
transition: 'transform 0.2s',
|
||||
@ -1346,14 +1346,17 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-right" style={{ color: '#475569' }}>
|
||||
<p
|
||||
className="mt-1 text-caption text-right"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
이미지를 클릭하면 크게 볼 수 있다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Menu path breadcrumb */}
|
||||
<div
|
||||
className="mb-3 text-[11px] font-mono px-2 py-1 rounded inline-block"
|
||||
className="mb-3 text-label-2 font-mono px-2 py-1 rounded inline-block"
|
||||
style={{
|
||||
background: 'rgba(71,85,105,0.15)',
|
||||
color: '#64748b',
|
||||
@ -1365,7 +1368,10 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
|
||||
{/* Overview */}
|
||||
<div className="mt-2">
|
||||
<p className="text-[12px] leading-relaxed" style={{ color: '#94a3b8' }}>
|
||||
<p
|
||||
className="text-label-1 leading-relaxed"
|
||||
style={{ color: '#94a3b8' }}
|
||||
>
|
||||
{screen.overview}
|
||||
</p>
|
||||
</div>
|
||||
@ -1380,13 +1386,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[11px] font-semibold mb-1.5 uppercase tracking-wide"
|
||||
className="text-label-2 font-semibold mb-1.5 uppercase tracking-wide"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
화면 설명
|
||||
</div>
|
||||
<p
|
||||
className="text-[12px] leading-relaxed"
|
||||
className="text-label-1 leading-relaxed"
|
||||
style={{ color: '#7f8ea3' }}
|
||||
>
|
||||
{screen.description}
|
||||
@ -1398,7 +1404,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{screen.procedure && screen.procedure.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
|
||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
사용 절차
|
||||
@ -1407,7 +1413,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{screen.procedure.map((step, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5">
|
||||
<span
|
||||
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold mt-0.5"
|
||||
className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-caption font-bold mt-0.5"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
color: '#06b6d4',
|
||||
@ -1417,7 +1423,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] leading-relaxed"
|
||||
className="text-label-1 leading-relaxed"
|
||||
style={{ color: '#94a3b8' }}
|
||||
>
|
||||
{step}
|
||||
@ -1432,7 +1438,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{screen.inputs && screen.inputs.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
|
||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
입력 항목
|
||||
@ -1441,7 +1447,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
className="rounded overflow-hidden"
|
||||
style={{ border: '1px solid #1e2a45' }}
|
||||
>
|
||||
<table className="w-full text-[12px]">
|
||||
<table className="w-full text-label-1">
|
||||
<thead>
|
||||
<tr style={{ background: '#0f1729' }}>
|
||||
<th
|
||||
@ -1494,7 +1500,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
<td className="px-3 py-2">
|
||||
{input.required ? (
|
||||
<span
|
||||
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
color: '#f87171',
|
||||
@ -1505,7 +1511,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
className="text-caption px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: 'rgba(100,116,139,0.1)',
|
||||
color: '#64748b',
|
||||
@ -1531,7 +1537,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
{screen.notes && screen.notes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className="text-[11px] font-semibold mb-2 uppercase tracking-wide"
|
||||
className="text-label-2 font-semibold mb-2 uppercase tracking-wide"
|
||||
style={{ color: '#475569' }}
|
||||
>
|
||||
유의사항
|
||||
@ -1544,7 +1550,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
style={{ background: '#f59e0b' }}
|
||||
/>
|
||||
<span
|
||||
className="text-[12px] leading-relaxed"
|
||||
className="text-label-1 leading-relaxed"
|
||||
style={{ color: '#94a3b8' }}
|
||||
>
|
||||
{note}
|
||||
@ -1590,7 +1596,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
||||
/>
|
||||
<button
|
||||
onClick={() => setLightboxSrc(null)}
|
||||
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-[13px] font-bold"
|
||||
className="absolute top-2 right-2 w-8 h-8 rounded flex items-center justify-center text-title-4 font-bold"
|
||||
style={{
|
||||
background: 'rgba(15,23,41,0.85)',
|
||||
color: '#94a3b8',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { api } from '../services/api'
|
||||
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../services/api';
|
||||
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
|
||||
|
||||
export interface MapTypeItem {
|
||||
mapKey: string;
|
||||
@ -46,11 +46,11 @@ interface MapState {
|
||||
}
|
||||
|
||||
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
||||
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
|
||||
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
||||
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
||||
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
||||
]
|
||||
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
|
||||
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
||||
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
||||
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
||||
];
|
||||
|
||||
let measureIdCounter = 0;
|
||||
|
||||
@ -67,17 +67,17 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
}),
|
||||
loadMapTypes: async () => {
|
||||
try {
|
||||
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
||||
const types = res.data
|
||||
const current = get().mapToggles
|
||||
const newToggles: Partial<MapToggles> = {}
|
||||
const res = await api.get<MapTypeItem[]>('/map-base/active');
|
||||
const types = res.data;
|
||||
const current = get().mapToggles;
|
||||
const newToggles: Partial<MapToggles> = {};
|
||||
for (const t of types) {
|
||||
if (t.mapKey in current) {
|
||||
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false
|
||||
newToggles[t.mapKey as keyof MapToggles] = current[t.mapKey as keyof MapToggles] ?? false;
|
||||
}
|
||||
}
|
||||
// 모든 토글 기본 off (기본지도 표시)
|
||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
|
||||
} catch {
|
||||
// API 실패 시 fallback 유지
|
||||
}
|
||||
@ -88,8 +88,7 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
measureInProgress: [],
|
||||
measurements: [],
|
||||
|
||||
setMeasureMode: (mode) =>
|
||||
set({ measureMode: mode, measureInProgress: [] }),
|
||||
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
|
||||
|
||||
addMeasurePoint: (pt) => {
|
||||
const { measureMode, measureInProgress } = get();
|
||||
@ -99,7 +98,10 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
const dist = haversineDistance(next[0], next[1]);
|
||||
const id = `measure-${++measureIdCounter}`;
|
||||
set((s) => ({
|
||||
measurements: [...s.measurements, { id, mode: 'distance', points: [next[0], next[1]], value: dist }],
|
||||
measurements: [
|
||||
...s.measurements,
|
||||
{ id, mode: 'distance', points: [next[0], next[1]], value: dist },
|
||||
],
|
||||
measureInProgress: [],
|
||||
}));
|
||||
} else {
|
||||
@ -116,7 +118,10 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
const area = polygonAreaKm2(measureInProgress);
|
||||
const id = `measure-${++measureIdCounter}`;
|
||||
set((s) => ({
|
||||
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
|
||||
measurements: [
|
||||
...s.measurements,
|
||||
{ id, mode: 'area', points: [...measureInProgress], value: area },
|
||||
],
|
||||
measureInProgress: [],
|
||||
}));
|
||||
},
|
||||
@ -124,6 +129,5 @@ export const useMapStore = create<MapState>((set, get) => ({
|
||||
removeMeasurement: (id) =>
|
||||
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
|
||||
|
||||
clearAllMeasurements: () =>
|
||||
set({ measurements: [], measureInProgress: [], measureMode: null }),
|
||||
}))
|
||||
clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }),
|
||||
}));
|
||||
|
||||
@ -77,6 +77,7 @@
|
||||
--font-size-heading-2: 1.5rem;
|
||||
--font-size-heading-3: 1.375rem;
|
||||
--font-size-title-1: 1.125rem;
|
||||
--font-size-subtitle: 0.9375rem;
|
||||
--font-size-title-2: 1rem;
|
||||
--font-size-title-3: 0.875rem;
|
||||
--font-size-title-4: 0.8125rem;
|
||||
|
||||
@ -41,34 +41,34 @@
|
||||
}
|
||||
|
||||
.wing-section-header {
|
||||
@apply text-[13px] font-bold font-korean mb-2;
|
||||
@apply text-title-4 font-bold font-korean mb-2;
|
||||
}
|
||||
|
||||
.wing-section-desc {
|
||||
@apply text-[10px] font-korean leading-relaxed;
|
||||
@apply text-caption font-korean leading-relaxed;
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
/* ── Typography ── */
|
||||
.wing-title {
|
||||
@apply text-[15px] font-bold font-korean;
|
||||
@apply text-subtitle font-bold font-korean;
|
||||
}
|
||||
|
||||
.wing-subtitle {
|
||||
@apply text-[10px] font-korean mt-0.5;
|
||||
@apply text-caption font-korean mt-0.5;
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
.wing-label {
|
||||
@apply text-[11px] font-semibold font-korean;
|
||||
@apply text-label-2 font-semibold font-korean;
|
||||
}
|
||||
|
||||
.wing-value {
|
||||
@apply text-[11px] font-mono font-semibold;
|
||||
@apply text-label-2 font-mono font-semibold;
|
||||
}
|
||||
|
||||
.wing-meta {
|
||||
@apply text-[9px] font-korean;
|
||||
@apply text-caption font-korean;
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
@ -83,12 +83,12 @@
|
||||
|
||||
/* ── Badge ── */
|
||||
.wing-badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean;
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded text-caption font-bold font-korean;
|
||||
}
|
||||
|
||||
/* ── Button ── */
|
||||
.wing-btn {
|
||||
@apply px-3 py-1.5 rounded-sm text-[11px] font-semibold cursor-pointer font-korean border-none;
|
||||
@apply px-3 py-1.5 rounded-sm text-label-2 font-semibold cursor-pointer font-korean border-none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
|
||||
/* ── Input ── */
|
||||
.wing-input {
|
||||
@apply w-full rounded-sm text-[11px] font-korean outline-none;
|
||||
@apply w-full rounded-sm text-label-2 font-korean outline-none;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--stroke-default);
|
||||
@ -151,7 +151,7 @@
|
||||
|
||||
/* ── Table ── */
|
||||
.wing-table {
|
||||
@apply w-full text-[10px] font-korean;
|
||||
@apply w-full text-caption font-korean;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@ -232,11 +232,11 @@
|
||||
}
|
||||
|
||||
.wing-kv-label {
|
||||
@apply text-[10px] font-korean;
|
||||
@apply text-caption font-korean;
|
||||
color: var(--fg-disabled);
|
||||
}
|
||||
|
||||
.wing-kv-value {
|
||||
@apply text-[11px] font-semibold font-mono;
|
||||
@apply text-label-2 font-semibold font-mono;
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,13 +30,13 @@ export const ComponentsContent = () => {
|
||||
style={{ opacity: 0.4 }}
|
||||
>
|
||||
<span
|
||||
className="text-[#64748b] font-sans text-[10px] leading-[15px] font-bold uppercase"
|
||||
className="text-[#64748b] font-sans text-caption leading-[15px] font-bold uppercase"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
© 2024 WING-OPS 해상 시스템
|
||||
</span>
|
||||
<span
|
||||
className="text-[#22d3ee] font-korean text-[10px] leading-[15px] font-medium uppercase"
|
||||
className="text-[#22d3ee] font-korean text-caption leading-[15px] font-medium uppercase"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
전술 네비게이터 쉘 v2.4
|
||||
|
||||
@ -29,13 +29,12 @@ const ButtonsThumbnail = ({ isDark }: { isDark: boolean }) => {
|
||||
{buttons.map(({ label, bg, border, color }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="w-full rounded flex items-center justify-center"
|
||||
className="w-full rounded flex items-center justify-center text-label-1"
|
||||
style={{
|
||||
height: '32px',
|
||||
backgroundColor: bg,
|
||||
border: `1.5px solid ${border}`,
|
||||
color,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
@ -112,6 +111,109 @@ const TextInputsThumbnail = ({ isDark }: { isDark: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FloatThumbnail = ({ isDark }: { isDark: boolean }) => {
|
||||
const backdropBg = isDark ? 'rgba(0,0,0,0.40)' : 'rgba(0,0,0,0.18)';
|
||||
const dialogBg = isDark ? '#1a2236' : '#ffffff';
|
||||
const dialogBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
|
||||
const accent = isDark ? '#4cd7f6' : '#06b6d4';
|
||||
const lineBg = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
|
||||
const successGreen = '#22c55e';
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-3 px-6">
|
||||
{/* 미니 모달 */}
|
||||
<div
|
||||
className="w-full rounded-md flex items-center justify-center"
|
||||
style={{ height: '60px', backgroundColor: backdropBg }}
|
||||
>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
width: '80%',
|
||||
height: '40px',
|
||||
backgroundColor: dialogBg,
|
||||
border: `1px solid ${dialogBorder}`,
|
||||
borderTop: `2px solid ${accent}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
padding: '6px 8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '5px', width: '50%', backgroundColor: lineBg }}
|
||||
/>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '5px', width: '70%', backgroundColor: lineBg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 미니 드롭다운 */}
|
||||
<div className="w-full flex flex-col items-start gap-0.5" style={{ paddingLeft: '8px' }}>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
width: '60%',
|
||||
height: '16px',
|
||||
backgroundColor: isDark ? 'rgba(255,255,255,0.07)' : '#e2e8f0',
|
||||
border: `1px solid ${dialogBorder}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
width: '60%',
|
||||
height: '28px',
|
||||
backgroundColor: dialogBg,
|
||||
border: `1px solid ${dialogBorder}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
padding: '3px 6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '4px', width: '80%', backgroundColor: accent, opacity: 0.5 }}
|
||||
/>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '4px', width: '60%', backgroundColor: lineBg }}
|
||||
/>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '4px', width: '70%', backgroundColor: lineBg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 미니 토스트 */}
|
||||
<div className="w-full flex justify-end" style={{ paddingRight: '8px' }}>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
width: '55%',
|
||||
height: '16px',
|
||||
backgroundColor: dialogBg,
|
||||
border: `1px solid ${dialogBorder}`,
|
||||
borderLeft: `3px solid ${successGreen}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{ height: '4px', width: '60%', backgroundColor: lineBg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- 카드 정의 ----------
|
||||
|
||||
const OVERVIEW_CARDS: OverviewCard[] = [
|
||||
@ -125,6 +227,11 @@ const OVERVIEW_CARDS: OverviewCard[] = [
|
||||
label: 'Text Field',
|
||||
thumbnail: (isDark) => <TextInputsThumbnail isDark={isDark} />,
|
||||
},
|
||||
{
|
||||
id: 'float',
|
||||
label: 'Float',
|
||||
thumbnail: (isDark) => <FloatThumbnail isDark={isDark} />,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------- Props ----------
|
||||
|
||||
@ -32,7 +32,7 @@ const SectionTitle = ({ num, title, sub, rightNode, theme }: SectionTitleProps)
|
||||
</div>
|
||||
{sub && (
|
||||
<p
|
||||
className="font-mono text-[10px] leading-[15px] uppercase"
|
||||
className="font-mono text-caption leading-[15px] uppercase"
|
||||
style={{ letterSpacing: theme.sectionSubSpacing, color: theme.sectionSub }}
|
||||
>
|
||||
{sub}
|
||||
@ -46,7 +46,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
{
|
||||
size: '9px / Meta',
|
||||
sampleNode: (t) => (
|
||||
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}>
|
||||
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
|
||||
메타정보 Meta info
|
||||
</span>
|
||||
),
|
||||
@ -55,7 +55,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
{
|
||||
size: '10px / Table',
|
||||
sampleNode: (t) => (
|
||||
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}>
|
||||
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
|
||||
테이블 데이터 Table data
|
||||
</span>
|
||||
),
|
||||
@ -68,7 +68,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
className="inline-flex items-center rounded-md border border-solid py-2 px-4"
|
||||
style={{ backgroundColor: t.typoActionBg, borderColor: t.typoActionBorder }}
|
||||
>
|
||||
<span className="font-korean text-[11px] font-medium" style={{ color: t.typoActionText }}>
|
||||
<span className="font-korean text-label-2 font-medium" style={{ color: t.typoActionText }}>
|
||||
입력/버튼 Input/Button text
|
||||
</span>
|
||||
</span>
|
||||
@ -78,7 +78,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
{
|
||||
size: '13px / Header',
|
||||
sampleNode: (t) => (
|
||||
<span className="font-korean text-[13px] font-bold" style={{ color: t.textPrimary }}>
|
||||
<span className="font-korean text-title-4 font-bold" style={{ color: t.textPrimary }}>
|
||||
섹션 헤더 Section Header
|
||||
</span>
|
||||
),
|
||||
@ -87,7 +87,7 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
{
|
||||
size: '15px / Title',
|
||||
sampleNode: (t) => (
|
||||
<span className="font-korean text-[15px] font-bold" style={{ color: t.textPrimary }}>
|
||||
<span className="font-korean text-subtitle font-bold" style={{ color: t.textPrimary }}>
|
||||
패널 타이틀 Panel Title
|
||||
</span>
|
||||
),
|
||||
@ -97,10 +97,10 @@ const TYPO_ROWS: TypoRow[] = [
|
||||
size: 'Data / Mono',
|
||||
sampleNode: (t) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[11px]" style={{ color: t.typoDataText }}>
|
||||
<span className="font-mono text-label-2" style={{ color: t.typoDataText }}>
|
||||
1,234.56 km²
|
||||
</span>
|
||||
<span className="font-mono text-[11px]" style={{ color: t.typoCoordText }}>
|
||||
<span className="font-mono text-label-2" style={{ color: t.typoCoordText }}>
|
||||
35° 06' 12" N
|
||||
</span>
|
||||
</div>
|
||||
@ -202,7 +202,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
{item.hex}
|
||||
</span>
|
||||
<span
|
||||
className="font-korean text-[11px] leading-[16.5px]"
|
||||
className="font-korean text-label-2 leading-[16.5px]"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{item.desc}
|
||||
@ -229,7 +229,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
boxShadow: t.borderCardShadow,
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
{item.token}
|
||||
</span>
|
||||
<span
|
||||
@ -259,7 +259,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
>
|
||||
{t.textTokens.map((item) => (
|
||||
<div key={item.token} className="flex flex-col gap-[3px]">
|
||||
<span className="font-mono text-[10px]" style={{ color: t.textAccent }}>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
{item.token}
|
||||
</span>
|
||||
<span className={item.sampleClass}>{item.sampleText}</span>
|
||||
@ -302,7 +302,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px]" style={{ color: t.textMuted }}>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
{item.token} / {item.color}
|
||||
</span>
|
||||
</div>
|
||||
@ -316,7 +316,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-korean text-[11px] font-medium"
|
||||
className="font-korean text-label-2 font-medium"
|
||||
style={{ color: item.badgeText }}
|
||||
>
|
||||
{item.badge}
|
||||
@ -348,12 +348,12 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="font-korean text-[13px] font-bold flex-1"
|
||||
className="font-korean text-title-4 font-bold flex-1"
|
||||
style={{ color: item.color }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] opacity-40" style={{ color: item.color }}>
|
||||
<span className="font-mono text-caption opacity-40" style={{ color: item.color }}>
|
||||
{item.hex}
|
||||
</span>
|
||||
</div>
|
||||
@ -370,7 +370,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
rightNode={
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span
|
||||
className="rounded-sm py-0.5 px-2 font-korean text-[10px] font-bold"
|
||||
className="rounded-sm py-0.5 px-2 font-korean text-caption font-bold"
|
||||
style={{ backgroundColor: t.fontBadgePrimaryBg, color: t.fontBadgePrimaryText }}
|
||||
>
|
||||
PretendardGOV
|
||||
@ -391,7 +391,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
style={{ textAlign: i === 2 ? 'right' : 'left', borderColor: t.tableRowBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-[10px] font-medium uppercase"
|
||||
className="font-mono text-caption font-medium uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
@ -412,7 +412,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
>
|
||||
{/* Size */}
|
||||
<div className="flex-1 py-4 px-8">
|
||||
<span className="font-mono text-[10px]" style={{ color: t.typoSizeText }}>
|
||||
<span className="font-mono text-caption" style={{ color: t.typoSizeText }}>
|
||||
{row.size}
|
||||
</span>
|
||||
</div>
|
||||
@ -420,7 +420,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
|
||||
{/* Properties */}
|
||||
<div className="flex-1 py-4 px-8 text-right" style={{ opacity: 0.5 }}>
|
||||
<span className="font-mono text-[10px]" style={{ color: t.typoPropertiesText }}>
|
||||
<span className="font-mono text-caption" style={{ color: t.typoPropertiesText }}>
|
||||
{row.properties}
|
||||
</span>
|
||||
</div>
|
||||
@ -447,7 +447,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-korean text-[10px] font-bold uppercase"
|
||||
className="font-korean text-caption font-bold uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||
>
|
||||
Small Elements
|
||||
@ -475,7 +475,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-korean text-[10px] font-bold uppercase"
|
||||
className="font-korean text-caption font-bold uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||
>
|
||||
Structural Panels
|
||||
@ -503,7 +503,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
{['Precision Engineering', 'Safety Compliant', 'Optimized v8.42'].map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="font-mono text-[10px] uppercase"
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||
>
|
||||
{label}
|
||||
@ -513,13 +513,13 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
||||
{/* 우측 */}
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span
|
||||
className="font-mono text-[10px] uppercase"
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||
>
|
||||
Generated for Terminal:
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] font-medium uppercase"
|
||||
className="font-mono text-caption font-medium uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.footerAccent }}
|
||||
>
|
||||
1440x900_PR_MKT
|
||||
|
||||
@ -50,10 +50,10 @@ export const DesignHeader = ({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-[10px] leading-[15px] uppercase"
|
||||
className="font-sans text-caption leading-[15px] uppercase"
|
||||
style={{ letterSpacing: '2px', color: theme.textMuted }}
|
||||
>
|
||||
Design System v1.0
|
||||
Design System v1.1
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,6 +13,7 @@ import FoundationsOverview from './FoundationsOverview';
|
||||
import ComponentsOverview from './ComponentsOverview';
|
||||
import { ButtonContent } from './ButtonContent';
|
||||
import { TextFieldContent } from './TextFieldContent';
|
||||
import { FloatContent } from './FloatContent';
|
||||
import { getTheme } from './designTheme';
|
||||
import type { ThemeMode } from './designTheme';
|
||||
|
||||
@ -69,6 +70,8 @@ export const DesignPage = () => {
|
||||
return <ButtonContent theme={theme} />;
|
||||
case 'text-field':
|
||||
return <TextFieldContent theme={theme} />;
|
||||
case 'float':
|
||||
return <FloatContent theme={theme} />;
|
||||
default:
|
||||
return <ComponentsContent />;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import type { DesignTheme } from './designTheme';
|
||||
import type { DesignTab } from './DesignHeader';
|
||||
|
||||
export type FoundationsMenuItemId = 'overview' | 'color' | 'typography' | 'radius' | 'layout';
|
||||
export type ComponentsMenuItemId = 'overview' | 'buttons' | 'text-field';
|
||||
export type ComponentsMenuItemId = 'overview' | 'buttons' | 'text-field' | 'float';
|
||||
export type MenuItemId = FoundationsMenuItemId | ComponentsMenuItemId;
|
||||
|
||||
interface MenuItem {
|
||||
@ -22,6 +22,7 @@ const COMPONENTS_MENU: MenuItem[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'buttons', label: 'Buttons' },
|
||||
{ id: 'text-field', label: 'Text Field' },
|
||||
{ id: 'float', label: 'Float' },
|
||||
];
|
||||
|
||||
const SIDEBAR_CONFIG: Record<DesignTab, { title: string; subtitle: string; menu: MenuItem[] }> = {
|
||||
|
||||
100
frontend/src/pages/design/FloatContent.tsx
Normal file
100
frontend/src/pages/design/FloatContent.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
// FloatContent.tsx — Float 서브탭 래퍼
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DesignTheme } from './designTheme';
|
||||
import { FloatModalContent } from './float/FloatModalContent';
|
||||
import { FloatDropdownContent } from './float/FloatDropdownContent';
|
||||
import { FloatOverlayContent } from './float/FloatOverlayContent';
|
||||
import { FloatToastContent } from './float/FloatToastContent';
|
||||
|
||||
type FloatSubTab = 'modal' | 'dropdown' | 'overlay' | 'toast';
|
||||
|
||||
const SUB_TABS: { id: FloatSubTab; label: string; desc: string }[] = [
|
||||
{ id: 'modal', label: 'Modal', desc: 'Dialog · Confirm' },
|
||||
{ id: 'dropdown', label: 'Dropdown', desc: 'ComboBox · Select' },
|
||||
{ id: 'overlay', label: 'Overlay', desc: 'Map Layer · Popup' },
|
||||
{ id: 'toast', label: 'Toast', desc: 'Notification · Alert' },
|
||||
];
|
||||
|
||||
interface FloatContentProps {
|
||||
theme: DesignTheme;
|
||||
}
|
||||
|
||||
export const FloatContent = ({ theme }: FloatContentProps) => {
|
||||
const [activeSubTab, setActiveSubTab] = useState<FloatSubTab>('modal');
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
|
||||
const renderSubContent = () => {
|
||||
switch (activeSubTab) {
|
||||
case 'modal':
|
||||
return <FloatModalContent theme={t} />;
|
||||
case 'dropdown':
|
||||
return <FloatDropdownContent theme={t} />;
|
||||
case 'overlay':
|
||||
return <FloatOverlayContent theme={t} />;
|
||||
case 'toast':
|
||||
return <FloatToastContent theme={t} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 서브탭 헤더 */}
|
||||
<div
|
||||
className="px-8 pt-6 pb-0 border-b border-solid shrink-0"
|
||||
style={{ borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#e2e8f0' }}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="font-sans text-2xl leading-8 font-bold" style={{ color: t.textPrimary }}>
|
||||
Float
|
||||
</h1>
|
||||
<p className="font-korean text-sm leading-5 mt-1" style={{ color: t.textSecondary }}>
|
||||
화면 위에 떠서 표시되는 UI 패턴 카탈로그 — Modal, Dropdown, Overlay, Toast
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 서브탭 바 */}
|
||||
<nav className="flex flex-row gap-1">
|
||||
{SUB_TABS.map(({ id, label, desc }) => {
|
||||
const isActive = activeSubTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActiveSubTab(id)}
|
||||
className="flex flex-col items-start px-4 pb-3 pt-1 cursor-pointer bg-transparent relative"
|
||||
style={{
|
||||
borderBottom: isActive ? `2px solid ${t.textAccent}` : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-sans text-sm font-bold leading-5"
|
||||
style={{ color: isActive ? t.textAccent : t.textMuted }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-caption leading-4"
|
||||
style={{
|
||||
color: isActive ? t.textAccent : t.textMuted,
|
||||
opacity: isActive ? 0.7 : 0.5,
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 서브탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto">{renderSubContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatContent;
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -118,7 +118,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
||||
{(['이름', '값', 'Preview'] as const).map((col) => (
|
||||
<div key={col} className="py-3 px-4">
|
||||
<span
|
||||
className="font-mono text-[10px] font-medium uppercase"
|
||||
className="font-mono text-caption font-medium uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
@ -153,7 +153,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
||||
</span>
|
||||
{token.isCustom && (
|
||||
<span
|
||||
className="font-mono text-[9px] rounded px-1.5 py-0.5"
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5"
|
||||
style={{
|
||||
color: isDark ? '#f97316' : '#c2410c',
|
||||
backgroundColor: isDark ? 'rgba(249,115,22,0.10)' : 'rgba(249,115,22,0.08)',
|
||||
@ -166,7 +166,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
||||
|
||||
{/* 값 */}
|
||||
<div className="py-4 px-4">
|
||||
<span className="font-mono text-[11px]" style={{ color: t.textPrimary }}>
|
||||
<span className="font-mono text-label-2" style={{ color: t.textPrimary }}>
|
||||
{token.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -378,7 +378,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
>
|
||||
<span className="font-sans text-lg font-bold" style={{ color: t.textPrimary }}>{font.name}</span>
|
||||
<span
|
||||
className="font-mono text-[11px] rounded border border-solid px-2 py-0.5"
|
||||
className="font-mono text-label-2 rounded border border-solid px-2 py-0.5"
|
||||
style={{
|
||||
color: t.textAccent,
|
||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||
@ -401,11 +401,11 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
<p className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>{font.usage}</p>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[9px] uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
|
||||
<span className="font-mono text-caption uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Regular</span>
|
||||
<span className="text-xl leading-7" style={{ color: t.textPrimary, fontWeight: 400 }}>{font.sampleText}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[9px] uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Bold</span>
|
||||
<span className="font-mono text-caption uppercase" style={{ letterSpacing: '1px', color: t.textMuted }}>Bold</span>
|
||||
<span className="text-xl leading-7 font-bold" style={{ color: t.textPrimary }}>{font.sampleText}</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -484,7 +484,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
{row.letterSpacing}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
|
||||
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5 w-fit"
|
||||
style={{
|
||||
color: t.textAccent,
|
||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||
@ -515,7 +515,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
>
|
||||
{row.token}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] mt-0.5" style={{ color: t.textMuted }}>
|
||||
<div className="font-mono text-caption mt-0.5" style={{ color: t.textMuted }}>
|
||||
{row.size} · {row.weight} · {row.lineHeight}
|
||||
</div>
|
||||
</div>
|
||||
@ -767,7 +767,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
||||
{row.value}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] rounded border border-solid px-1.5 py-0.5 w-fit"
|
||||
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5 w-fit"
|
||||
style={{
|
||||
color: t.textAccent,
|
||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
||||
|
||||
@ -19,7 +19,7 @@ const buttonRows: ButtonRow[] = [
|
||||
'linear-gradient(120.41deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-white text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
실행
|
||||
</div>
|
||||
</div>
|
||||
@ -33,7 +33,7 @@ const buttonRows: ButtonRow[] = [
|
||||
boxShadow: '0px 0px 12px 0px rgba(6, 182, 212, 0.4)',
|
||||
}}
|
||||
>
|
||||
<div className="text-white text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-white text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
확인
|
||||
</div>
|
||||
</div>
|
||||
@ -43,7 +43,7 @@ const buttonRows: ButtonRow[] = [
|
||||
className="bg-[#334155] rounded-md pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
|
||||
style={{ opacity: 0.5 }}
|
||||
>
|
||||
<div className="text-[#64748b] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#64748b] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
저장
|
||||
</div>
|
||||
</div>
|
||||
@ -53,21 +53,21 @@ const buttonRows: ButtonRow[] = [
|
||||
label: '세컨더리 (솔리드)',
|
||||
defaultBtn: (
|
||||
<div className="bg-[#1a2236] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
hoverBtn: (
|
||||
<div className="bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
닫기
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
disabledBtn: (
|
||||
<div className="bg-[rgba(26,34,54,0.50)] rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
취소
|
||||
</div>
|
||||
</div>
|
||||
@ -77,21 +77,21 @@ const buttonRows: ButtonRow[] = [
|
||||
label: '아웃라인 (고스트)',
|
||||
defaultBtn: (
|
||||
<div className="rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
더보기
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
hoverBtn: (
|
||||
<div className="bg-[#1e2844] rounded-md border border-solid border-[#1e2a42] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
필터
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
disabledBtn: (
|
||||
<div className="rounded-md border border-solid border-[rgba(30,42,66,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[rgba(192,200,220,0.30)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
더보기
|
||||
</div>
|
||||
</div>
|
||||
@ -102,7 +102,7 @@ const buttonRows: ButtonRow[] = [
|
||||
defaultBtn: (
|
||||
<div className="bg-[rgba(59,130,246,0.08)] rounded-md border border-solid border-[rgba(59,130,246,0.30)] pt-1.5 pr-3 pb-1.5 pl-3 flex flex-row gap-2 items-center justify-start shrink-0 relative">
|
||||
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
|
||||
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#3b82f6] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
PDF 다운로드
|
||||
</div>
|
||||
</div>
|
||||
@ -113,7 +113,7 @@ const buttonRows: ButtonRow[] = [
|
||||
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
|
||||
>
|
||||
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
|
||||
<div className="text-[#3b82f6] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#3b82f6] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
PDF 다운로드
|
||||
</div>
|
||||
</div>
|
||||
@ -125,7 +125,7 @@ const buttonRows: ButtonRow[] = [
|
||||
src={pdfFileDisabledIcon}
|
||||
alt="PDF 아이콘 (비활성)"
|
||||
/>
|
||||
<div className="text-[rgba(59,130,246,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[rgba(59,130,246,0.40)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
PDF 다운로드
|
||||
</div>
|
||||
</div>
|
||||
@ -135,7 +135,7 @@ const buttonRows: ButtonRow[] = [
|
||||
label: '경고 (삭제)',
|
||||
defaultBtn: (
|
||||
<div className="bg-[rgba(239,68,68,0.10)] rounded-md border border-solid border-[rgba(239,68,68,0.30)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#ef4444] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
삭제
|
||||
</div>
|
||||
</div>
|
||||
@ -145,14 +145,14 @@ const buttonRows: ButtonRow[] = [
|
||||
className="bg-[rgba(239,68,68,0.20)] rounded-md border border-solid border-[rgba(239,68,68,0.50)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative"
|
||||
style={{ boxShadow: '0px 0px 8px 0px rgba(239, 68, 68, 0.15)' }}
|
||||
>
|
||||
<div className="text-[#ef4444] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#ef4444] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
삭제
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
disabledBtn: (
|
||||
<div className="bg-[rgba(239,68,68,0.05)] rounded-md border border-solid border-[rgba(239,68,68,0.15)] pt-1.5 pr-3 pb-[7px] pl-3 flex flex-row gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[rgba(239,68,68,0.40)] text-center font-korean text-[11px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[rgba(239,68,68,0.40)] text-center font-korean text-label-2 font-medium relative flex items-center justify-center">
|
||||
초기화
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -32,7 +32,7 @@ export const CardSection = () => {
|
||||
{/* 카드 헤더 */}
|
||||
<div className="border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div
|
||||
className="text-[#64748b] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
|
||||
className="text-[#64748b] text-left font-korean text-caption leading-[15px] font-medium uppercase relative flex items-center justify-start"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
활성 물류 현황
|
||||
@ -55,12 +55,12 @@ export const CardSection = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 items-start justify-start flex-1 min-w-0 relative">
|
||||
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div className="text-[#dfe2f3] text-left font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-start">
|
||||
<div className="text-[#dfe2f3] text-left font-korean text-label-2 leading-[16.5px] font-medium relative flex items-center justify-start">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
|
||||
<div className="text-[#64748b] text-left font-sans text-caption leading-[15px] font-normal relative flex items-center justify-start">
|
||||
{item.progress}
|
||||
</div>
|
||||
</div>
|
||||
@ -78,7 +78,7 @@ export const CardSection = () => {
|
||||
'linear-gradient(97.29deg, rgba(6, 182, 212, 1) 0%, rgba(59, 130, 246, 1) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="text-white text-center font-korean text-[11px] leading-[16.5px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-white text-center font-korean text-label-2 leading-[16.5px] font-medium relative flex items-center justify-center">
|
||||
대응팀 배치
|
||||
</div>
|
||||
</div>
|
||||
@ -108,7 +108,7 @@ export const CardSection = () => {
|
||||
<div className="flex flex-row items-start justify-between self-stretch shrink-0 relative">
|
||||
<div className="flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative">
|
||||
<div
|
||||
className="text-[#22d3ee] text-left font-korean text-[10px] leading-[15px] font-medium uppercase relative flex items-center justify-start"
|
||||
className="text-[#22d3ee] text-left font-korean text-caption leading-[15px] font-medium uppercase relative flex items-center justify-start"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
실시간 텔레메트리
|
||||
@ -139,14 +139,14 @@ export const CardSection = () => {
|
||||
<div className="flex flex-row items-center justify-between self-stretch shrink-0 relative">
|
||||
{/* 정상 가동중 뱃지 */}
|
||||
<div className="bg-[rgba(34,197,94,0.10)] rounded-xl pt-0.5 pr-2 pb-0.5 pl-2 flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||
<div className="text-[#22c55e] text-left font-korean text-[9px] leading-[13.5px] font-medium relative flex items-center justify-start">
|
||||
<div className="text-[#22c55e] text-left font-korean text-caption leading-[13.5px] font-medium relative flex items-center justify-start">
|
||||
정상 가동중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 대응팀 배치 아웃라인 버튼 */}
|
||||
<div className="rounded-md border border-[#1e2a42] pt-1 pr-3 pb-1 pl-3 flex flex-col gap-0 items-center justify-center shrink-0 relative">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-center">
|
||||
<div className="text-[#c0c8dc] text-center font-korean text-caption leading-[15px] font-medium relative flex items-center justify-center">
|
||||
대응팀 배치
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -85,7 +85,7 @@ export const IconBadgeSection = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||
<div
|
||||
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
|
||||
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
|
||||
style={{ letterSpacing: '0.9px' }}
|
||||
>
|
||||
{btn.label}
|
||||
@ -97,7 +97,7 @@ export const IconBadgeSection = () => {
|
||||
|
||||
{/* 카드 푸터 */}
|
||||
<div className="bg-[rgba(15,23,42,0.30)] pt-4 pr-6 pb-4 pl-6 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div className="text-[#64748b] text-left font-sans text-[10px] leading-[15px] font-normal relative flex items-center justify-start">
|
||||
<div className="text-[#64748b] text-left font-sans text-caption leading-[15px] font-normal relative flex items-center justify-start">
|
||||
Standard dimensions: 36x36px with radius-md (6px)
|
||||
</div>
|
||||
</div>
|
||||
@ -127,7 +127,7 @@ export const IconBadgeSection = () => {
|
||||
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div
|
||||
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
|
||||
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
Operational Status
|
||||
@ -141,7 +141,7 @@ export const IconBadgeSection = () => {
|
||||
style={{ backgroundColor: badge.bg }}
|
||||
>
|
||||
<div
|
||||
className="text-left font-korean text-[10px] leading-[15px] font-medium relative flex items-center justify-start"
|
||||
className="text-left font-korean text-caption leading-[15px] font-medium relative flex items-center justify-start"
|
||||
style={{ color: badge.color }}
|
||||
>
|
||||
{badge.label}
|
||||
@ -155,7 +155,7 @@ export const IconBadgeSection = () => {
|
||||
<div className="flex flex-col gap-4 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div className="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||
<div
|
||||
className="text-[#64748b] text-left font-sans font-bold text-[10px] leading-[15px] uppercase relative flex items-center justify-start"
|
||||
className="text-[#64748b] text-left font-sans font-bold text-caption leading-[15px] uppercase relative flex items-center justify-start"
|
||||
style={{ letterSpacing: '1px' }}
|
||||
>
|
||||
Data Classification
|
||||
@ -174,7 +174,7 @@ export const IconBadgeSection = () => {
|
||||
></div>
|
||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||
<div
|
||||
className="text-left font-sans font-bold text-[10px] leading-[15px] relative flex items-center justify-start"
|
||||
className="text-left font-sans font-bold text-caption leading-[15px] relative flex items-center justify-start"
|
||||
style={{ color: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
|
||||
@ -321,21 +321,21 @@ export const DARK_THEME: DesignTheme = {
|
||||
{
|
||||
token: 'text-1',
|
||||
sampleText: '주요 텍스트 Primary Text',
|
||||
sampleClass: 'text-[#edf0f7] font-korean text-[15px] font-bold',
|
||||
sampleClass: 'text-[#edf0f7] font-korean text-subtitle font-bold',
|
||||
desc: 'Headings, active values, and primary labels.',
|
||||
descColor: 'rgba(237,240,247,0.60)',
|
||||
},
|
||||
{
|
||||
token: 'text-2',
|
||||
sampleText: '보조 텍스트 Secondary Text',
|
||||
sampleClass: 'text-[#c0c8dc] font-korean text-[15px] font-medium',
|
||||
sampleClass: 'text-[#c0c8dc] font-korean text-subtitle font-medium',
|
||||
desc: 'Supporting labels and secondary information.',
|
||||
descColor: 'rgba(192,200,220,0.60)',
|
||||
},
|
||||
{
|
||||
token: 'text-3',
|
||||
sampleText: '비활성 텍스트 Muted Text',
|
||||
sampleClass: 'text-[#9ba3b8] font-korean text-[15px]',
|
||||
sampleClass: 'text-[#9ba3b8] font-korean text-subtitle',
|
||||
desc: 'Disabled states, placeholders, and captions.',
|
||||
descColor: 'rgba(155,163,184,0.60)',
|
||||
},
|
||||
@ -353,7 +353,7 @@ export const LIGHT_THEME: DesignTheme = {
|
||||
headerBg: '#ffffff',
|
||||
headerBorder: '#e2e8f0',
|
||||
|
||||
textPrimary: '#0f172a',
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
textAccent: '#06b6d4',
|
||||
@ -498,21 +498,21 @@ export const LIGHT_THEME: DesignTheme = {
|
||||
{
|
||||
token: 'text-1',
|
||||
sampleText: '주요 텍스트 Primary Text',
|
||||
sampleClass: 'text-[#0f172a] font-korean text-[15px] font-bold',
|
||||
sampleClass: 'text-[#0f172a] font-korean text-subtitle font-bold',
|
||||
desc: 'Headings, active values, and primary labels.',
|
||||
descColor: '#64748b',
|
||||
},
|
||||
{
|
||||
token: 'text-2',
|
||||
sampleText: '보조 텍스트 Secondary Text',
|
||||
sampleClass: 'text-[#475569] font-korean text-[15px] font-medium',
|
||||
sampleClass: 'text-[#475569] font-korean text-subtitle font-medium',
|
||||
desc: 'Supporting labels and secondary information.',
|
||||
descColor: '#64748b',
|
||||
},
|
||||
{
|
||||
token: 'text-3',
|
||||
sampleText: '비활성 텍스트 Muted Text',
|
||||
sampleClass: 'text-[#94a3b8] font-korean text-[15px]',
|
||||
sampleClass: 'text-[#94a3b8] font-korean text-subtitle',
|
||||
desc: 'Disabled states, placeholders, and captions.',
|
||||
descColor: '#94a3b8',
|
||||
},
|
||||
|
||||
440
frontend/src/pages/design/float/FloatDropdownContent.tsx
Normal file
440
frontend/src/pages/design/float/FloatDropdownContent.tsx
Normal file
@ -0,0 +1,440 @@
|
||||
// FloatDropdownContent.tsx — Dropdown/ComboBox 카탈로그
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DesignTheme } from '../designTheme';
|
||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||
|
||||
interface FloatDropdownContentProps {
|
||||
theme: DesignTheme;
|
||||
}
|
||||
|
||||
const DEMO_OPTIONS = [
|
||||
{ value: 'option1', label: '연속 유출 (Continuous)' },
|
||||
{ value: 'option2', label: '순간 유출 (Instantaneous)' },
|
||||
{ value: 'option3', label: '밀도가스 유출 (Dense Gas)' },
|
||||
{ value: 'option4', label: '수중 유출 (Subsurface)' },
|
||||
{ value: 'option5', label: '증발 유출 (Evaporative)' },
|
||||
];
|
||||
|
||||
const ALGORITHM_OPTIONS = [
|
||||
{ value: 'slick', label: 'Slick Formation Model' },
|
||||
{ value: 'gnome', label: 'GNOME (NOAA)' },
|
||||
{ value: 'medslik', label: 'MEDSLIK-II' },
|
||||
];
|
||||
|
||||
export const FloatDropdownContent = ({ theme }: FloatDropdownContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
|
||||
const [demoValue, setDemoValue] = useState('option1');
|
||||
const [algoValue, setAlgoValue] = useState('slick');
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
|
||||
{/* ── 개요 ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||
Dropdown
|
||||
</h2>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
트리거 요소에{' '}
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
position: absolute
|
||||
</code>
|
||||
로 부착되는 선택 목록. 5개 이상의 선택지가 있는 단일 선택에 사용한다. 프로젝트 공통
|
||||
컴포넌트는{' '}
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
ComboBox
|
||||
</code>
|
||||
다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Live Preview ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Live Preview
|
||||
</h3>
|
||||
<span
|
||||
className="font-mono text-caption px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
|
||||
color: '#22c55e',
|
||||
}}
|
||||
>
|
||||
interactive
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-6 flex flex-col gap-6"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
유출 유형
|
||||
</span>
|
||||
<ComboBox
|
||||
value={demoValue}
|
||||
onChange={setDemoValue}
|
||||
options={DEMO_OPTIONS}
|
||||
placeholder="유형 선택"
|
||||
/>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
선택값: {DEMO_OPTIONS.find((o) => o.value === demoValue)?.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
예측 알고리즘
|
||||
</span>
|
||||
<ComboBox
|
||||
value={algoValue}
|
||||
onChange={setAlgoValue}
|
||||
options={ALGORITHM_OPTIONS}
|
||||
placeholder="알고리즘 선택"
|
||||
/>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
선택값: {ALGORITHM_OPTIONS.find((o) => o.value === algoValue)?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
위 컴포넌트는{' '}
|
||||
<code className="font-mono" style={{ color: t.textAccent }}>
|
||||
@common/components/ui/ComboBox
|
||||
</code>
|
||||
를 직접 렌더링합니다. 외부 클릭 시 자동으로 닫힙니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Anatomy ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Anatomy
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 구조 다이어그램 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Structure
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 p-4">
|
||||
{/* 트리거 */}
|
||||
<div
|
||||
className="rounded border border-solid flex items-center justify-between px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1b1f2c' : '#ffffff',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||
선택된 옵션
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
▼
|
||||
</span>
|
||||
</div>
|
||||
{/* 리스트 */}
|
||||
<div
|
||||
className="rounded border border-solid flex flex-col overflow-hidden mt-0.5"
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1b1f2c' : '#ffffff',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
>
|
||||
{['옵션 A (선택됨)', '옵션 B', '옵션 C', '옵션 D'].map((opt, i) => (
|
||||
<div
|
||||
key={opt}
|
||||
className="px-3 py-1.5"
|
||||
style={{
|
||||
backgroundColor:
|
||||
i === 0
|
||||
? isDark
|
||||
? 'rgba(76,215,246,0.10)'
|
||||
: 'rgba(6,182,212,0.07)'
|
||||
: 'transparent',
|
||||
borderTop: i === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
borderLeft: i === 0 ? `2px solid ${t.textAccent}` : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-korean text-caption"
|
||||
style={{ color: i === 0 ? t.textAccent : t.textSecondary }}
|
||||
>
|
||||
{opt}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치 지정 규칙 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Position Rules
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ label: 'trigger', rule: 'position: relative', desc: '드롭다운 기준점' },
|
||||
{
|
||||
label: 'list',
|
||||
rule: 'position: absolute, top: calc(100% + 2px)',
|
||||
desc: '트리거 바로 아래',
|
||||
},
|
||||
{ label: 'z-index', rule: 'z-[1000]', desc: '모달(9999) 아래, 일반 UI 위' },
|
||||
{ label: 'max-height', rule: '200px + overflow-y: auto', desc: '스크롤 한계' },
|
||||
{ label: 'animation', rule: 'fadeSlideDown 0.15s ease-out', desc: '부드러운 등장' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-start gap-2">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||
{item.rule}
|
||||
</span>
|
||||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 상태 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
상태
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{
|
||||
label: 'Default',
|
||||
desc: '닫힘, 값 미선택',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
|
||||
textColor: t.textMuted,
|
||||
},
|
||||
{
|
||||
label: 'Open',
|
||||
desc: '리스트 표시 중',
|
||||
borderColor: t.textAccent,
|
||||
textColor: t.textAccent,
|
||||
},
|
||||
{
|
||||
label: 'Selected',
|
||||
desc: '값 선택됨',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
|
||||
textColor: t.textPrimary,
|
||||
},
|
||||
{
|
||||
label: 'Disabled',
|
||||
desc: '비활성',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.20)' : '#f1f5f9',
|
||||
textColor: isDark ? 'rgba(140,144,159,0.40)' : '#cbd5e1',
|
||||
},
|
||||
].map((state) => (
|
||||
<div
|
||||
key={state.label}
|
||||
className="rounded-lg border border-solid p-3 flex flex-col gap-2"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span className="font-mono text-caption font-bold" style={{ color: t.textPrimary }}>
|
||||
{state.label}
|
||||
</span>
|
||||
<div
|
||||
className="rounded border border-solid flex items-center justify-between px-2 py-1.5"
|
||||
style={{
|
||||
borderColor: state.borderColor,
|
||||
opacity: state.label === 'Disabled' ? 0.45 : 1,
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: state.textColor }}>
|
||||
{state.label === 'Selected' ? '연속 유출' : '선택하세요'}
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: state.textColor }}>
|
||||
{state.label === 'Open' ? '▲' : '▼'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||
{state.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Props 테이블 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Props (ComboBox)
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '140px 160px 80px 1fr',
|
||||
backgroundColor: t.tableHeaderBg,
|
||||
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
{['Prop', 'Type', 'Required', 'Description'].map((col) => (
|
||||
<div key={col} className="py-2.5 px-4">
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{[
|
||||
{ prop: 'value', type: 'string | number', required: 'Y', desc: '현재 선택값' },
|
||||
{
|
||||
prop: 'onChange',
|
||||
type: '(value: string) => void',
|
||||
required: 'Y',
|
||||
desc: '선택 변경 콜백',
|
||||
},
|
||||
{
|
||||
prop: 'options',
|
||||
type: 'ComboBoxOption[]',
|
||||
required: 'Y',
|
||||
desc: '{ value, label } 배열',
|
||||
},
|
||||
{ prop: 'placeholder', type: 'string', required: 'N', desc: '미선택 상태 표시 텍스트' },
|
||||
{ prop: 'className', type: 'string', required: 'N', desc: '트리거 추가 스타일' },
|
||||
].map((row, idx) => (
|
||||
<div
|
||||
key={row.prop}
|
||||
className="grid items-center"
|
||||
style={{
|
||||
gridTemplateColumns: '140px 160px 80px 1fr',
|
||||
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
{row.prop}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||
{row.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span
|
||||
className="font-mono text-caption"
|
||||
style={{ color: row.required === 'Y' ? '#22c55e' : t.textMuted }}
|
||||
>
|
||||
{row.required}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||
{row.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 사용 가이드라인 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
사용 가이드라인
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{
|
||||
title: 'clickOutside 처리 필수',
|
||||
desc: 'useEffect + mousedown 이벤트로 외부 클릭 감지. ComboBox 내부에 구현됨.',
|
||||
type: 'rule',
|
||||
},
|
||||
{
|
||||
title: '5개 이상 선택지',
|
||||
desc: '4개 이하는 Radio 버튼 또는 버튼 그룹으로 대체. 너무 많은 옵션은 검색 필터 추가 고려.',
|
||||
type: 'rule',
|
||||
},
|
||||
{
|
||||
title: '모달 내부 사용 시',
|
||||
desc: '모달 z-index(9999) 내부에 있으면 드롭다운 z-[1000]이 자연스럽게 모달 위에 렌더링됨.',
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
title: '너비 상속',
|
||||
desc: '드롭다운 리스트는 트리거와 동일한 너비. left: 0, right: 0으로 너비 상속.',
|
||||
type: 'info',
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded border border-solid px-4 py-3 flex flex-col gap-1"
|
||||
style={{
|
||||
backgroundColor: t.cardBg,
|
||||
borderColor:
|
||||
item.type === 'rule'
|
||||
? isDark
|
||||
? 'rgba(76,215,246,0.20)'
|
||||
: 'rgba(6,182,212,0.20)'
|
||||
: t.cardBorder,
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatDropdownContent;
|
||||
668
frontend/src/pages/design/float/FloatModalContent.tsx
Normal file
668
frontend/src/pages/design/float/FloatModalContent.tsx
Normal file
@ -0,0 +1,668 @@
|
||||
// FloatModalContent.tsx — Modal + Confirm 카탈로그
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { DesignTheme } from '../designTheme';
|
||||
|
||||
interface FloatModalContentProps {
|
||||
theme: DesignTheme;
|
||||
}
|
||||
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'full';
|
||||
|
||||
const SIZE_CONFIG: Record<ModalSize, { label: string; width: string; desc: string }> = {
|
||||
sm: { label: 'Small', width: '380px', desc: '입력 폼, 간단한 확인' },
|
||||
md: { label: 'Medium', width: '520px', desc: '상세 파라미터, 재계산' },
|
||||
lg: { label: 'Large', width: '720px', desc: '복잡한 폼, 미디어 뷰어' },
|
||||
full: { label: 'Full', width: '95vw', desc: '매뉴얼, 전체 화면 콘텐츠' },
|
||||
};
|
||||
|
||||
const MODAL_INVENTORY = [
|
||||
{
|
||||
component: 'HNSRecalcModal',
|
||||
zIndex: 'z-[9999]',
|
||||
trigger: '버튼 클릭',
|
||||
source: 'tabs/hns/components/',
|
||||
},
|
||||
{
|
||||
component: 'RecalcModal',
|
||||
zIndex: 'z-[9999]',
|
||||
trigger: '재계산 버튼',
|
||||
source: 'tabs/prediction/components/',
|
||||
},
|
||||
{
|
||||
component: 'BacktrackModal',
|
||||
zIndex: 'z-[9999]',
|
||||
trigger: '역추적 분석',
|
||||
source: 'tabs/prediction/components/',
|
||||
},
|
||||
{
|
||||
component: 'MediaModal',
|
||||
zIndex: 'z-[10000]',
|
||||
trigger: '미디어 클릭',
|
||||
source: 'tabs/incidents/components/',
|
||||
},
|
||||
{
|
||||
component: 'SimulationErrorModal',
|
||||
zIndex: 'z-50 ⚠️',
|
||||
trigger: '오류 발생',
|
||||
source: 'tabs/prediction/components/',
|
||||
},
|
||||
{
|
||||
component: 'TemplateFormEditor',
|
||||
zIndex: 'z-50 ⚠️',
|
||||
trigger: '템플릿 편집',
|
||||
source: 'tabs/reports/components/',
|
||||
},
|
||||
{
|
||||
component: 'Admin 모달 (Layer/Map/Perm)',
|
||||
zIndex: 'z-50 ⚠️',
|
||||
trigger: '관리 작업',
|
||||
source: 'tabs/admin/components/',
|
||||
},
|
||||
{
|
||||
component: 'UserManualPopup',
|
||||
zIndex: 'z-[9999]',
|
||||
trigger: '도움말 버튼',
|
||||
source: 'common/components/ui/',
|
||||
},
|
||||
];
|
||||
|
||||
export const FloatModalContent = ({ theme }: FloatModalContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [activeSize, setActiveSize] = useState<ModalSize>('md');
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
|
||||
const overlayBg = isDark ? 'rgba(0,0,0,0.65)' : 'rgba(0,0,0,0.45)';
|
||||
const modalBg = isDark ? '#1b1f2c' : '#ffffff';
|
||||
const modalBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
|
||||
const modalWidth = activeSize === 'full' ? '95vw' : SIZE_CONFIG[activeSize].width;
|
||||
const modalHeight = activeSize === 'full' ? '92vh' : 'auto';
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
|
||||
{/* ── 개요 ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||
Modal
|
||||
</h2>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
fixed inset-0
|
||||
</code>{' '}
|
||||
백드롭 위에 중앙 정렬된 다이얼로그. 사용자 확인·입력이 필요한 중요 작업에 사용한다.
|
||||
크기(size)와 용도(variant)로 변형하며, <strong>Confirm</strong>은 Modal의 서브컴포넌트다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Live Preview ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Live Preview
|
||||
</h3>
|
||||
<span
|
||||
className="font-mono text-caption px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
|
||||
color: '#22c55e',
|
||||
}}
|
||||
>
|
||||
interactive
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
{/* 사이즈 선택 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Size Variant
|
||||
</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.keys(SIZE_CONFIG) as ModalSize[]).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setActiveSize(size)}
|
||||
className="px-3 py-1.5 rounded border border-solid font-mono text-caption transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
activeSize === size
|
||||
? isDark
|
||||
? 'rgba(76,215,246,0.15)'
|
||||
: 'rgba(6,182,212,0.10)'
|
||||
: 'transparent',
|
||||
borderColor: activeSize === size ? t.textAccent : t.cardBorder,
|
||||
color: activeSize === size ? t.textAccent : t.textMuted,
|
||||
}}
|
||||
>
|
||||
{SIZE_CONFIG[size].label}
|
||||
<span className="ml-1.5 opacity-60">{SIZE_CONFIG[size].width}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
{SIZE_CONFIG[activeSize].desc}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 버튼 그룹 */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.15)' : 'rgba(6,182,212,0.12)',
|
||||
borderColor: t.textAccent,
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
모달 열기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsConfirmOpen(true)}
|
||||
className="px-4 py-2 rounded border border-solid font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(239,68,68,0.10)' : 'rgba(239,68,68,0.06)',
|
||||
borderColor: 'rgba(239,68,68,0.40)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
Confirm (삭제 확인)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Confirm 서브컴포넌트 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Confirm — Modal 서브컴포넌트
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-5 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
Confirm은 독립 컴포넌트가 아닌 <strong>Modal의 variant</strong>다. 타이틀 + 단문 메시지
|
||||
+ 취소/확인 2버튼 구성. 파괴적 작업(삭제, 초기화) 전 사용자 의도를 확인한다.
|
||||
</p>
|
||||
<div
|
||||
className="rounded border border-solid px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(239,68,68,0.06)' : 'rgba(239,68,68,0.04)',
|
||||
borderColor: 'rgba(239,68,68,0.20)',
|
||||
}}
|
||||
>
|
||||
<p className="font-mono text-caption" style={{ color: '#ef4444' }}>
|
||||
⚠️ window.confirm 대체 — admin, board 4건에서 OS 레벨 confirm 사용 중 → 커스텀
|
||||
ConfirmDialog로 전환 필요
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
{ label: 'variant', value: '"confirm"', desc: 'Modal 컴포넌트의 prop으로 전달' },
|
||||
{ label: 'title', value: '"항목을 삭제하시겠습니까?"', desc: '액션을 명확히 서술' },
|
||||
{
|
||||
label: 'message',
|
||||
value: '"삭제된 데이터는 복구할 수 없습니다."',
|
||||
desc: '부가 설명 (선택)',
|
||||
},
|
||||
{ label: 'onConfirm', value: '() => handleDelete()', desc: '확인 버튼 콜백' },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-start gap-3">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||
{row.value}
|
||||
</span>
|
||||
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
|
||||
{row.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Anatomy ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Anatomy
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 구조 다이어그램 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Structure
|
||||
</span>
|
||||
<div
|
||||
className="rounded flex items-center justify-center p-4"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(0,0,0,0.50)' : 'rgba(0,0,0,0.06)',
|
||||
minHeight: '200px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border border-solid flex flex-col overflow-hidden w-full"
|
||||
style={{ backgroundColor: modalBg, borderColor: modalBorder }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b border-solid"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: t.textPrimary }}>
|
||||
다이얼로그 타이틀
|
||||
</span>
|
||||
<div
|
||||
className="w-4 h-4 rounded flex items-center justify-center"
|
||||
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
|
||||
>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
{[75, 100, 60].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded"
|
||||
style={{
|
||||
height: '7px',
|
||||
width: `${w}%`,
|
||||
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-3 py-2 border-t border-solid"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<div
|
||||
className="rounded px-2 py-1"
|
||||
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.20)' : '#f1f5f9' }}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||
취소
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="rounded px-2 py-1"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.12)',
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: t.textAccent }}>
|
||||
확인
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS 클래스 레퍼런스 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
CSS Classes
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{
|
||||
cls: '.wing-overlay',
|
||||
styles: 'fixed inset-0, z-index: 10000',
|
||||
desc: '백드롭 오버레이',
|
||||
},
|
||||
{
|
||||
cls: '.wing-modal',
|
||||
styles: 'rounded-xl, bg-surface, border + shadow',
|
||||
desc: '다이얼로그 컨테이너',
|
||||
},
|
||||
{
|
||||
cls: '.wing-modal-header',
|
||||
styles: 'flex justify-between, px-5, py-[14px], border-b',
|
||||
desc: '헤더 (타이틀 + 닫기)',
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.cls}
|
||||
className="rounded border border-solid px-3 py-2"
|
||||
style={{ borderColor: t.cardBorder }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
{item.cls}
|
||||
</span>
|
||||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-caption" style={{ color: t.textSecondary }}>
|
||||
{item.styles}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Z-Index 규칙 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Z-Index 규칙
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{
|
||||
range: 'z-[9999]',
|
||||
status: '표준',
|
||||
desc: '일반 Modal — 표준값, 신규 Modal은 이 값 사용',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
range: 'z-[10000]',
|
||||
status: '허용',
|
||||
desc: '모달 위 모달 (MediaModal, IncidentsView) — 필요 시만',
|
||||
color: '#eab308',
|
||||
},
|
||||
{
|
||||
range: 'z-50',
|
||||
status: '비표준 ⚠️',
|
||||
desc: 'SimulationErrorModal, Admin 모달, TemplateFormEditor — z-[9999]로 통일 필요',
|
||||
color: '#ef4444',
|
||||
},
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.range}
|
||||
className="flex items-start gap-4 rounded border border-solid px-4 py-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-sm rounded border border-solid px-2 py-0.5 shrink-0"
|
||||
style={{ color: t.textAccent, borderColor: t.cardBorder }}
|
||||
>
|
||||
{row.range}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0"
|
||||
style={{ color: row.color, backgroundColor: `${row.color}15` }}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
||||
{row.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 인벤토리 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
현재 사용 사례
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 120px 130px 1fr',
|
||||
backgroundColor: t.tableHeaderBg,
|
||||
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
{['Component', 'Z-Index', 'Trigger', 'Source'].map((col) => (
|
||||
<div key={col} className="py-2.5 px-4">
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{MODAL_INVENTORY.map((item, idx) => (
|
||||
<div
|
||||
key={item.component}
|
||||
className="grid items-center"
|
||||
style={{
|
||||
gridTemplateColumns: '1fr 120px 130px 1fr',
|
||||
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
||||
{item.component}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span
|
||||
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5"
|
||||
style={{
|
||||
color: item.zIndex.includes('⚠️') ? '#ef4444' : t.textAccent,
|
||||
borderColor: item.zIndex.includes('⚠️') ? 'rgba(239,68,68,0.30)' : t.cardBorder,
|
||||
}}
|
||||
>
|
||||
{item.zIndex}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||
{item.trigger}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
{item.source}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 실제 Modal 렌더링 ── */}
|
||||
{isModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-[9999]"
|
||||
style={{ backgroundColor: overlayBg, backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setIsModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col rounded-xl border border-solid overflow-hidden"
|
||||
style={{
|
||||
width: modalWidth,
|
||||
maxHeight: modalHeight,
|
||||
backgroundColor: modalBg,
|
||||
borderColor: modalBorder,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4 border-b border-solid shrink-0"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
||||
Modal Preview — {SIZE_CONFIG[activeSize].label} ({SIZE_CONFIG[activeSize].width})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="w-7 h-7 rounded flex items-center justify-center hover:opacity-70 transition-opacity"
|
||||
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.25)' : '#f1f5f9' }}
|
||||
>
|
||||
<span className="font-mono text-sm" style={{ color: t.textMuted }}>
|
||||
✕
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-5 flex flex-col gap-3 overflow-y-auto">
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
이 모달은{' '}
|
||||
<code
|
||||
className="font-mono text-xs px-1 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
fixed inset-0, z-[9999]
|
||||
</code>{' '}
|
||||
백드롭 위에 렌더링됩니다. 백드롭 클릭 또는 닫기 버튼으로 닫을 수 있습니다.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{['파라미터 입력 필드', '선택 항목', '추가 설정값'].map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded border border-solid px-3 py-2.5"
|
||||
style={{ borderColor: t.cardBorder }}
|
||||
>
|
||||
<span className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-5 py-4 border-t border-solid shrink-0"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
|
||||
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 실제 Confirm 렌더링 ── */}
|
||||
{isConfirmOpen && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-[9999]"
|
||||
style={{ backgroundColor: overlayBg, backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col rounded-xl border border-solid overflow-hidden"
|
||||
style={{
|
||||
width: '360px',
|
||||
backgroundColor: modalBg,
|
||||
borderColor: modalBorder,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4 border-b border-solid"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: '#ef4444', fontSize: '16px' }}>⚠</span>
|
||||
<span className="font-korean text-sm font-medium" style={{ color: t.textPrimary }}>
|
||||
항목을 삭제하시겠습니까?
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
삭제된 데이터는 복구할 수 없습니다. 계속 진행하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 px-5 py-4 border-t border-solid"
|
||||
style={{ borderColor: modalBorder }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsConfirmOpen(false)}
|
||||
className="px-4 py-2 rounded border border-solid font-korean text-sm transition-opacity hover:opacity-70"
|
||||
style={{ borderColor: t.cardBorder, color: t.textMuted }}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsConfirmOpen(false)}
|
||||
className="px-4 py-2 rounded font-korean text-sm font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.12)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatModalContent;
|
||||
428
frontend/src/pages/design/float/FloatOverlayContent.tsx
Normal file
428
frontend/src/pages/design/float/FloatOverlayContent.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
// FloatOverlayContent.tsx — Map Overlay + Map Popup 카탈로그
|
||||
|
||||
import type { DesignTheme } from '../designTheme';
|
||||
|
||||
interface FloatOverlayContentProps {
|
||||
theme: DesignTheme;
|
||||
}
|
||||
|
||||
const OVERLAY_CASES = [
|
||||
{
|
||||
component: 'BacktrackReplayBar',
|
||||
position: '하단 중앙',
|
||||
zIndex: 'z-40',
|
||||
pointerEvents: 'auto',
|
||||
desc: '역추적 재생 컨트롤 바. 재생/일시정지/슬라이더.',
|
||||
source: 'common/components/map/BacktrackReplayBar.tsx',
|
||||
},
|
||||
{
|
||||
component: 'MeasureOverlay',
|
||||
position: '마커 위치',
|
||||
zIndex: 'z-40',
|
||||
pointerEvents: 'auto',
|
||||
desc: '거리 측정 마커 "지우기" 버튼. MapLibre Marker 컴포넌트 활용.',
|
||||
source: 'common/components/map/MeasureOverlay.tsx',
|
||||
},
|
||||
{
|
||||
component: 'OilDetectionOverlay',
|
||||
position: 'inset-0 + 우하단 정보',
|
||||
zIndex: 'z-[15]',
|
||||
pointerEvents: 'none',
|
||||
desc: '유류 탐지 결과 마스크 렌더링. OffscreenCanvas 기반. 정보 패널만 클릭 가능.',
|
||||
source: 'tabs/aerial/components/OilDetectionOverlay.tsx',
|
||||
},
|
||||
{
|
||||
component: 'WeatherMapOverlay',
|
||||
position: 'absolute inset-0',
|
||||
zIndex: 'map layer',
|
||||
pointerEvents: 'none',
|
||||
desc: '기상 데이터 레이어 오버레이.',
|
||||
source: 'tabs/weather/components/WeatherMapOverlay.tsx',
|
||||
},
|
||||
{
|
||||
component: 'OceanForecastOverlay',
|
||||
position: 'absolute inset-0',
|
||||
zIndex: 'map layer',
|
||||
pointerEvents: 'none',
|
||||
desc: '해양 예측 레이어 오버레이.',
|
||||
source: 'tabs/weather/components/OceanForecastOverlay.tsx',
|
||||
},
|
||||
];
|
||||
|
||||
export const FloatOverlayContent = ({ theme }: FloatOverlayContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
|
||||
const mapMockBg = isDark ? '#0f1a2e' : '#c8d8e8';
|
||||
const mapGridColor = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)';
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
|
||||
{/* ── 개요 ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||
Overlay
|
||||
</h2>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
지도 컨테이너 위에
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
position: absolute
|
||||
</code>
|
||||
로 레이어되는 UI. 백드롭 없이 지도 위에 기능 UI를 표시한다. Modal과 달리 화면 상호작용을
|
||||
차단하지 않으며, 지도 컨테이너의 크기 변화에 반응한다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Overlay vs Modal 비교 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Overlay vs Modal
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '160px 1fr 1fr',
|
||||
backgroundColor: t.tableHeaderBg,
|
||||
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
{['속성', 'Overlay', 'Modal'].map((col) => (
|
||||
<div key={col} className="py-2.5 px-4">
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{[
|
||||
{ attr: 'position', overlay: 'absolute (지도 기준)', modal: 'fixed (뷰포트 기준)' },
|
||||
{ attr: '백드롭', overlay: '없음', modal: 'rgba(0,0,0,0.65) + blur' },
|
||||
{ attr: '클릭 차단', overlay: 'pointer-events: none (일반)', modal: '전체 화면 차단' },
|
||||
{ attr: 'z-index', overlay: 'z-40 (지도 UI 위)', modal: 'z-[9999] (최상위)' },
|
||||
{ attr: '크기 기준', overlay: '지도 컨테이너 = 100%', modal: '고정 너비 (380~720px)' },
|
||||
{
|
||||
attr: '닫기 방식',
|
||||
overlay: '기능 비활성화 시 사라짐',
|
||||
modal: '닫기 버튼 / 백드롭 클릭',
|
||||
},
|
||||
].map((row, idx) => (
|
||||
<div
|
||||
key={row.attr}
|
||||
className="grid items-center"
|
||||
style={{
|
||||
gridTemplateColumns: '160px 1fr 1fr',
|
||||
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
{row.attr}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||
{row.overlay}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-4">
|
||||
<span className="font-korean text-xs" style={{ color: t.textSecondary }}>
|
||||
{row.modal}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 지도 목업 다이어그램 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Overlay 배치 다이어그램
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
지도 컨테이너 기준 절대 위치
|
||||
</span>
|
||||
|
||||
{/* 지도 목업 */}
|
||||
<div
|
||||
className="relative rounded overflow-hidden"
|
||||
style={{ backgroundColor: mapMockBg, minHeight: '280px' }}
|
||||
>
|
||||
{/* 격자 배경 (지도 모사) */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${mapGridColor} 1px, transparent 1px), linear-gradient(90deg, ${mapGridColor} 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 지도 레이블 */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span
|
||||
className="font-mono text-caption"
|
||||
style={{ color: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)' }}
|
||||
>
|
||||
MapView (position: relative)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* OilDetectionOverlay — 전체 영역 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ border: `1.5px dashed rgba(6,182,212,0.35)`, borderRadius: '4px' }}
|
||||
>
|
||||
<div className="absolute top-10 left-3">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5"
|
||||
style={{ backgroundColor: 'rgba(6,182,212,0.15)', color: t.textAccent }}
|
||||
>
|
||||
OilDetectionOverlay — inset-0, z-[15], pointer-events:none
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MeasureOverlay 마커 */}
|
||||
<div className="absolute" style={{ top: '80px', left: '120px' }}>
|
||||
<div
|
||||
className="rounded-full w-3 h-3 border-2 border-solid"
|
||||
style={{ backgroundColor: '#ef4444', borderColor: '#ffffff' }}
|
||||
/>
|
||||
<div
|
||||
className="rounded border border-solid px-2 py-0.5 mt-1"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(239,68,68,0.85)' : 'rgba(239,68,68,0.90)',
|
||||
borderColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-caption" style={{ color: '#ffffff' }}>
|
||||
지우기
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1 py-0.5"
|
||||
style={{
|
||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||
color: '#ef4444',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
MeasureOverlay — Marker 위치
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BacktrackReplayBar — 하단 중앙 */}
|
||||
<div
|
||||
className="absolute bottom-3 left-1/2 rounded border border-solid px-4 py-2 flex items-center gap-3"
|
||||
style={{
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: isDark ? 'rgba(23,27,40,0.92)' : 'rgba(255,255,255,0.92)',
|
||||
borderColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0',
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
◀◀
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
▶
|
||||
</span>
|
||||
<div
|
||||
className="w-24 h-1 rounded"
|
||||
style={{ backgroundColor: isDark ? 'rgba(66,71,84,0.40)' : '#e2e8f0' }}
|
||||
>
|
||||
<div className="h-1 w-10 rounded" style={{ backgroundColor: t.textAccent }} />
|
||||
</div>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
▶▶
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-14 left-1/2" style={{ transform: 'translateX(-50%)' }}>
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.12)' : 'rgba(6,182,212,0.10)',
|
||||
color: t.textAccent,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
BacktrackReplayBar — bottom-3 center, z-40
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map Popup 서브패턴 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Map Popup — 위치 앵커드 패턴
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
<strong>ScatPopup</strong>은 지도 마커에 앵커된 컨텍스트 팝업이다. Modal(fixed 뷰포트
|
||||
중앙)과 달리 마커 위치에서 동적으로 좌표를 계산하며, 지도 패닝·줌 시 위치가 함께
|
||||
업데이트된다.
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{
|
||||
label: '위치 계산',
|
||||
value: 'map.project(lngLat)',
|
||||
desc: '지도 좌표 → 픽셀 좌표 변환',
|
||||
},
|
||||
{ label: '위치 업데이트', value: 'map.on("move")', desc: '패닝/줌 시 재계산' },
|
||||
{ label: 'z-index', value: 'z-[9999]', desc: '다른 오버레이 위' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded border border-solid px-3 py-2.5 flex flex-col gap-1"
|
||||
style={{ borderColor: t.cardBorder }}
|
||||
>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs" style={{ color: t.textAccent }}>
|
||||
{item.value}
|
||||
</span>
|
||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="rounded border border-solid px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(234,179,8,0.06)' : 'rgba(234,179,8,0.04)',
|
||||
borderColor: 'rgba(234,179,8,0.25)',
|
||||
}}
|
||||
>
|
||||
<span className="font-korean text-xs" style={{ color: '#eab308' }}>
|
||||
주의: ScatPopup은 MapLibre GL JS의 Popup/Marker 컴포넌트가 아닌 React DOM으로 구현됨.
|
||||
지도 컨테이너 내부에 position: absolute로 렌더링된다.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
Source:
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: t.textAccent }}>
|
||||
tabs/scat/components/ScatPopup.tsx
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 사용 사례 목록 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
현재 사용 사례
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid overflow-hidden"
|
||||
style={{ backgroundColor: t.tableContainerBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: '200px 120px 80px 100px 1fr',
|
||||
backgroundColor: t.tableHeaderBg,
|
||||
borderBottom: `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
{['Component', 'Position', 'Z-Index', 'Events', 'Description'].map((col) => (
|
||||
<div key={col} className="py-2.5 px-3">
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{OVERLAY_CASES.map((item, idx) => (
|
||||
<div
|
||||
key={item.component}
|
||||
className="grid items-start"
|
||||
style={{
|
||||
gridTemplateColumns: '200px 120px 80px 100px 1fr',
|
||||
borderTop: idx === 0 ? 'none' : `1px solid ${t.tableRowBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="py-2.5 px-3">
|
||||
<span className="font-mono text-xs" style={{ color: t.textPrimary }}>
|
||||
{item.component}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-3">
|
||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||
{item.position}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-3">
|
||||
<span
|
||||
className="font-mono text-caption rounded border border-solid px-1.5 py-0.5"
|
||||
style={{ color: t.textAccent, borderColor: t.cardBorder }}
|
||||
>
|
||||
{item.zIndex}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-3">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5"
|
||||
style={{
|
||||
color: item.pointerEvents === 'none' ? t.textMuted : '#22c55e',
|
||||
backgroundColor:
|
||||
item.pointerEvents === 'none'
|
||||
? isDark
|
||||
? 'rgba(140,144,159,0.10)'
|
||||
: 'rgba(148,163,184,0.10)'
|
||||
: isDark
|
||||
? 'rgba(34,197,94,0.10)'
|
||||
: 'rgba(34,197,94,0.08)',
|
||||
}}
|
||||
>
|
||||
{item.pointerEvents}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-2.5 px-3">
|
||||
<span className="font-korean text-xs leading-5" style={{ color: t.textSecondary }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatOverlayContent;
|
||||
427
frontend/src/pages/design/float/FloatToastContent.tsx
Normal file
427
frontend/src/pages/design/float/FloatToastContent.tsx
Normal file
@ -0,0 +1,427 @@
|
||||
// FloatToastContent.tsx — Toast 컴포넌트 카탈로그
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { DesignTheme } from '../designTheme';
|
||||
|
||||
interface FloatToastContentProps {
|
||||
theme: DesignTheme;
|
||||
}
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const TOAST_CONFIG: Record<ToastType, { color: string; bg: string; icon: string; label: string }> =
|
||||
{
|
||||
success: { color: '#22c55e', bg: 'rgba(34,197,94,0.12)', icon: '✓', label: 'Success' },
|
||||
error: { color: '#ef4444', bg: 'rgba(239,68,68,0.12)', icon: '✕', label: 'Error' },
|
||||
info: { color: '#06b6d4', bg: 'rgba(6,182,212,0.12)', icon: 'ℹ', label: 'Info' },
|
||||
warning: { color: '#eab308', bg: 'rgba(234,179,8,0.12)', icon: '⚠', label: 'Warning' },
|
||||
};
|
||||
|
||||
const DEMO_MESSAGES: Record<ToastType, string> = {
|
||||
success: '저장이 완료되었습니다.',
|
||||
error: '요청 처리 중 오류가 발생했습니다.',
|
||||
info: '시뮬레이션이 시작되었습니다.',
|
||||
warning: '미저장 변경사항이 있습니다.',
|
||||
};
|
||||
|
||||
const TOAST_DURATION = 3000;
|
||||
|
||||
let toastIdCounter = 0;
|
||||
|
||||
export const FloatToastContent = ({ theme }: FloatToastContentProps) => {
|
||||
const t = theme;
|
||||
const isDark = t.mode === 'dark';
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const addToast = (type: ToastType) => {
|
||||
const id = ++toastIdCounter;
|
||||
setToasts((prev) => [...prev, { id, type, message: DEMO_MESSAGES[type], progress: 100 }]);
|
||||
};
|
||||
|
||||
const removeToast = (id: number) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setToasts((prev) =>
|
||||
prev
|
||||
.map((toast) => ({ ...toast, progress: toast.progress - 100 / (TOAST_DURATION / 100) }))
|
||||
.filter((toast) => toast.progress > 0),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [toasts.length]);
|
||||
|
||||
const toastBg = isDark ? '#1b1f2c' : '#ffffff';
|
||||
const toastBorder = isDark ? 'rgba(66,71,84,0.30)' : '#e2e8f0';
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 flex flex-col gap-12 max-w-[1200px]">
|
||||
{/* ── 개요 ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="font-sans text-xl font-bold" style={{ color: t.textPrimary }}>
|
||||
Toast
|
||||
</h2>
|
||||
<span
|
||||
className="font-mono text-caption rounded px-2 py-0.5"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(234,179,8,0.10)' : 'rgba(234,179,8,0.08)',
|
||||
color: '#eab308',
|
||||
}}
|
||||
>
|
||||
미구현 — 설계 사양
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
화면을 차단하지 않는 비파괴적 알림.
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
fixed bottom-right
|
||||
</code>
|
||||
에 위치하며 일정 시간 후 자동으로 사라진다. 현재 프로젝트에서는{' '}
|
||||
<code
|
||||
className="font-mono text-xs px-1.5 py-0.5 mx-1 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(239,68,68,0.08)' : 'rgba(239,68,68,0.05)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
window.alert
|
||||
</code>
|
||||
또는 console.log로 대체하고 있으며 커스텀 구현이 필요하다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Live Preview ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Live Preview
|
||||
</h3>
|
||||
<span
|
||||
className="font-mono text-caption px-2 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(34,197,94,0.10)' : 'rgba(34,197,94,0.08)',
|
||||
color: '#22c55e',
|
||||
}}
|
||||
>
|
||||
interactive
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<p className="font-korean text-xs" style={{ color: t.textMuted }}>
|
||||
버튼 클릭 시 화면 우하단에 Toast가 표시됩니다. 3초 후 자동으로 사라집니다.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.keys(TOAST_CONFIG) as ToastType[]).map((type) => {
|
||||
const cfg = TOAST_CONFIG[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => addToast(type)}
|
||||
className="px-4 py-2 rounded border border-solid font-mono text-caption font-medium transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
backgroundColor: cfg.bg,
|
||||
borderColor: `${cfg.color}40`,
|
||||
color: cfg.color,
|
||||
}}
|
||||
>
|
||||
{cfg.icon} {cfg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{toasts.length > 0 && (
|
||||
<p className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
활성 Toast: {toasts.length}개 — 우측 하단을 확인하세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Anatomy ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
Anatomy
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 구조 목업 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Structure
|
||||
</span>
|
||||
<div
|
||||
className="rounded relative flex items-end justify-end"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.04)',
|
||||
padding: '16px',
|
||||
minHeight: '160px',
|
||||
}}
|
||||
>
|
||||
{/* 성공 Toast 목업 */}
|
||||
<div className="flex flex-col gap-1.5 w-full max-w-[220px]">
|
||||
{(['success', 'info', 'error'] as ToastType[]).map((type, i) => {
|
||||
const cfg = TOAST_CONFIG[type];
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="rounded border border-solid flex items-center gap-2 px-3 py-2"
|
||||
style={{
|
||||
backgroundColor: toastBg,
|
||||
borderColor: toastBorder,
|
||||
borderLeft: `3px solid ${cfg.color}`,
|
||||
opacity: 1 - i * 0.2,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption shrink-0"
|
||||
style={{ color: cfg.color }}
|
||||
>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<span className="font-korean text-caption" style={{ color: t.textSecondary }}>
|
||||
{DEMO_MESSAGES[type].slice(0, 14)}…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치 규칙 */}
|
||||
<div
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-caption uppercase"
|
||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||
>
|
||||
Position Rules
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ label: 'position', value: 'fixed', desc: '뷰포트 기준 고정' },
|
||||
{ label: 'bottom', value: '24px', desc: '화면 하단에서 24px' },
|
||||
{ label: 'right', value: '24px', desc: '화면 우측에서 24px' },
|
||||
{ label: 'z-index', value: 'z-60', desc: '콘텐츠 위, Modal(9999) 아래' },
|
||||
{ label: 'width', value: '320px (고정)', desc: '일정한 너비 유지' },
|
||||
{ label: 'gap', value: '8px (스택)', desc: '복수 Toast 간격' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-mono text-caption rounded px-1.5 py-0.5 shrink-0 w-24 text-right"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.06)',
|
||||
color: t.textAccent,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-mono text-caption" style={{ color: t.textPrimary }}>
|
||||
{item.value}
|
||||
</span>
|
||||
<span className="font-korean text-caption ml-auto" style={{ color: t.textMuted }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 타입별 색상 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
타입별 색상
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{(Object.entries(TOAST_CONFIG) as [ToastType, (typeof TOAST_CONFIG)[ToastType]][]).map(
|
||||
([type, cfg]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="rounded-lg border border-solid p-4 flex flex-col gap-3"
|
||||
style={{
|
||||
backgroundColor: t.cardBg,
|
||||
borderColor: t.cardBorder,
|
||||
borderLeft: `3px solid ${cfg.color}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-lg" style={{ color: cfg.color }}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-bold" style={{ color: cfg.color }}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-caption" style={{ color: t.textMuted }}>
|
||||
{cfg.color}
|
||||
</span>
|
||||
<span
|
||||
className="font-korean text-caption leading-5"
|
||||
style={{ color: t.textSecondary }}
|
||||
>
|
||||
{type === 'success' && '저장 완료, 복사 완료, 전송 성공'}
|
||||
{type === 'error' && 'API 오류, 저장 실패, 권한 없음'}
|
||||
{type === 'info' && '작업 시작, 업데이트 알림'}
|
||||
{type === 'warning' && '미저장 변경, 만료 임박'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 구현 패턴 제안 ── */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="font-sans text-base font-bold" style={{ color: t.textPrimary }}>
|
||||
구현 패턴 제안 — useToast Hook
|
||||
</h3>
|
||||
<div
|
||||
className="rounded-lg border border-solid p-5 flex flex-col gap-4"
|
||||
style={{ backgroundColor: t.cardBg, borderColor: t.cardBorder }}
|
||||
>
|
||||
<p className="font-korean text-sm leading-6" style={{ color: t.textSecondary }}>
|
||||
Toast는 앱 어디서든 호출해야 하므로 <strong>Zustand store + useToast hook</strong>{' '}
|
||||
패턴을 권장한다. ToastContainer는 App.tsx 최상위에 한 번만 렌더링한다.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{[
|
||||
{
|
||||
title: 'toastStore.ts',
|
||||
code: 'const useToastStore = create<ToastStore>()\naddToast(type, message, duration?)\nremoveToast(id)',
|
||||
desc: 'Zustand store — Toast 큐 관리',
|
||||
},
|
||||
{
|
||||
title: 'useToast.ts',
|
||||
code: 'const { success, error, info, warning } = useToast()\nsuccess("저장 완료") // duration 기본값 3000ms',
|
||||
desc: '컴포넌트에서 호출하는 hook',
|
||||
},
|
||||
{
|
||||
title: 'ToastContainer.tsx',
|
||||
code: '<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2">\n {toasts.map(t => <ToastItem key={t.id} {...t} />)}\n</div>',
|
||||
desc: 'App.tsx 최상위에 배치',
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded border border-solid p-3 flex flex-col gap-1.5"
|
||||
style={{ borderColor: t.cardBorder }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="font-mono text-caption font-bold"
|
||||
style={{ color: t.textPrimary }}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="font-korean text-caption" style={{ color: t.textMuted }}>
|
||||
{item.desc}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
className="font-mono text-caption leading-5 rounded p-2 overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: isDark ? 'rgba(0,0,0,0.30)' : 'rgba(0,0,0,0.04)',
|
||||
color: t.textSecondary,
|
||||
}}
|
||||
>
|
||||
{item.code}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 실제 Toast 렌더링 (fixed 위치) ── */}
|
||||
{toasts.length > 0 && (
|
||||
<div
|
||||
className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2"
|
||||
style={{ width: '300px' }}
|
||||
>
|
||||
{toasts.map((toast) => {
|
||||
const cfg = TOAST_CONFIG[toast.type];
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="rounded border border-solid flex flex-col overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: toastBg,
|
||||
borderColor: toastBorder,
|
||||
borderLeft: `3px solid ${cfg.color}`,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.30)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-3 py-2.5">
|
||||
<span className="font-mono text-base shrink-0" style={{ color: cfg.color }}>
|
||||
{cfg.icon}
|
||||
</span>
|
||||
<span className="font-korean text-sm flex-1" style={{ color: t.textPrimary }}>
|
||||
{toast.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="w-5 h-5 rounded flex items-center justify-center shrink-0 hover:opacity-70 transition-opacity"
|
||||
style={{ color: t.textMuted }}
|
||||
>
|
||||
<span className="font-mono text-caption">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
style={{
|
||||
height: '2px',
|
||||
backgroundColor: isDark ? 'rgba(66,71,84,0.30)' : '#f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '2px',
|
||||
width: `${toast.progress}%`,
|
||||
backgroundColor: cfg.color,
|
||||
transition: 'width 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatToastContent;
|
||||
@ -7,7 +7,7 @@ const AdminPlaceholder = ({ label }: AdminPlaceholderProps) => (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="text-4xl opacity-20">🚧</div>
|
||||
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</div>
|
||||
<div className="text-[11px] font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
||||
<div className="text-label-2 font-korean text-fg-disabled">해당 기능은 준비 중입니다.</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="w-full text-left px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
className="w-full text-left px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||
@ -65,7 +65,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
if (firstLeaf) onSelect(firstLeaf.id);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
|
||||
style={{
|
||||
paddingLeft: `${12 + depth * 14}px`,
|
||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||
@ -74,7 +74,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span
|
||||
className="text-[9px] text-fg-disabled transition-transform"
|
||||
className="text-caption text-fg-disabled transition-transform"
|
||||
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||
>
|
||||
▶
|
||||
@ -123,7 +123,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
{/* 섹션 헤더 */}
|
||||
<button
|
||||
onClick={() => toggle(section.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-[11px] font-bold font-korean transition-colors cursor-pointer"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-label-2 font-bold font-korean transition-colors cursor-pointer"
|
||||
style={{
|
||||
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
||||
@ -132,7 +132,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||||
<span className="text-sm">{section.icon}</span>
|
||||
<span className="flex-1 text-left">{section.label}</span>
|
||||
<span
|
||||
className="text-[9px] text-fg-disabled transition-transform"
|
||||
className="text-caption text-fg-disabled transition-transform"
|
||||
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||
>
|
||||
▶
|
||||
|
||||
@ -130,7 +130,7 @@ function AssetUploadPanel() {
|
||||
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
||||
dragging
|
||||
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
|
||||
: 'border-stroke hover:border-color-accent/50 bg-bg-elevated'
|
||||
: 'border-stroke hover:border-[rgba(6,182,212,0.5)] bg-bg-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl mb-2 opacity-40">📁</div>
|
||||
@ -143,7 +143,7 @@ function AssetUploadPanel() {
|
||||
<div className="text-xs font-semibold text-fg-sub font-korean mb-1">
|
||||
파일을 드래그하거나 클릭하여 업로드
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean mb-3">
|
||||
<div className="text-caption text-fg-disabled font-korean mb-3">
|
||||
엑셀(.xlsx), CSV 파일 지원 · 최대 10MB
|
||||
</div>
|
||||
<button
|
||||
@ -170,7 +170,7 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 자산 분류 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
자산 분류
|
||||
</label>
|
||||
<select
|
||||
@ -189,7 +189,7 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 대상 관할 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
대상 관할
|
||||
</label>
|
||||
<select
|
||||
@ -208,7 +208,7 @@ function AssetUploadPanel() {
|
||||
|
||||
{/* 업로드 방식 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
업로드 방식
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
@ -271,7 +271,7 @@ function AssetUploadPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean mt-0.5">
|
||||
<div className="text-caption text-fg-disabled font-korean mt-0.5">
|
||||
{p.desc}
|
||||
</div>
|
||||
</div>
|
||||
@ -287,7 +287,7 @@ function AssetUploadPanel() {
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{uploadHistory.length === 0 ? (
|
||||
<div className="text-[11px] text-fg-disabled font-korean text-center py-4">
|
||||
<div className="text-label-2 text-fg-disabled font-korean text-center py-4">
|
||||
이력이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
@ -298,12 +298,12 @@ function AssetUploadPanel() {
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">
|
||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-semibold
|
||||
className="px-2 py-0.5 rounded-full text-caption font-semibold
|
||||
bg-[rgba(34,197,94,0.15)] text-color-success flex-shrink-0"
|
||||
>
|
||||
완료
|
||||
|
||||
@ -273,7 +273,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
<td className="py-2 text-center text-fg-disabled">{post.sn}</td>
|
||||
<td className="py-2 text-center">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-caption font-medium ${
|
||||
post.categoryCd === 'NOTICE'
|
||||
? 'bg-red-500/15 text-red-400'
|
||||
: post.categoryCd === 'QNA'
|
||||
@ -285,7 +285,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
|
||||
{post.pinnedYn === 'Y' && <span className="text-[10px] text-orange-400 mr-1">[고정]</span>}
|
||||
{post.pinnedYn === 'Y' && <span className="text-caption text-orange-400 mr-1">[고정]</span>}
|
||||
{post.title}
|
||||
</td>
|
||||
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
||||
|
||||
@ -167,47 +167,47 @@ function CleanupEquipPanel() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
유형
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
관할청
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
기관명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
주소
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
|
||||
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '방제선' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
|
||||
>
|
||||
방제선
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
|
||||
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
|
||||
>
|
||||
유회수기
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
|
||||
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
|
||||
>
|
||||
이송펌프
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
|
||||
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
|
||||
>
|
||||
방제차량
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-color-accent/5' : 'text-fg-disabled'}`}
|
||||
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
|
||||
>
|
||||
살포장치
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
총자산
|
||||
</th>
|
||||
</tr>
|
||||
@ -228,51 +228,51 @@ function CleanupEquipPanel() {
|
||||
key={org.id}
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
|
||||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
||||
className={`text-caption px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
||||
>
|
||||
{org.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
|
||||
<td className="px-4 py-3 text-label-2 text-fg font-korean font-semibold">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-4 py-3 text-label-2 font-mono text-center ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center font-bold text-color-accent">
|
||||
{org.totalAssets.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@ -286,7 +286,7 @@ function CleanupEquipPanel() {
|
||||
{/* 합계 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -301,15 +301,15 @@ function CleanupEquipPanel() {
|
||||
return (
|
||||
<div
|
||||
key={t.label}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-color-accent/10' : ''}`}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
className={`text-caption font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
|
||||
className={`text-caption font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
|
||||
>
|
||||
{t.value.toLocaleString()}
|
||||
{t.unit}
|
||||
@ -323,7 +323,7 @@ function CleanupEquipPanel() {
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} /
|
||||
전체 {filtered.length}개
|
||||
</span>
|
||||
@ -331,7 +331,7 @@ function CleanupEquipPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -339,7 +339,7 @@ function CleanupEquipPanel() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
|
||||
style={
|
||||
p === safePage
|
||||
? {
|
||||
@ -356,7 +356,7 @@ function CleanupEquipPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
@ -256,7 +256,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
||||
<td className="px-3 py-2 text-t2 font-mono">{row.jobName}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-label-2 font-medium ${
|
||||
row.activeYn === 'Y'
|
||||
? 'text-emerald-400 bg-emerald-500/10'
|
||||
: 'text-t3 bg-bg-elevated'
|
||||
@ -269,7 +269,7 @@ function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean })
|
||||
<td className="px-3 py-2 text-t2 whitespace-nowrap">{row.clctDate ?? '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-[11px] font-medium ${status.color}`}
|
||||
className={`inline-block px-2 py-0.5 rounded text-label-2 font-medium ${status.color}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
@ -149,7 +149,7 @@ const DispersingZonePanel = () => {
|
||||
/>
|
||||
</button>
|
||||
{/* 펼침 화살표 */}
|
||||
<span className="text-fg-disabled text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
<span className="text-fg-disabled text-caption shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{/* 펼침 영역 */}
|
||||
@ -159,10 +159,10 @@ const DispersingZonePanel = () => {
|
||||
<tbody>
|
||||
{info.rows.map((row) => (
|
||||
<tr key={row.key} className="border-b border-stroke last:border-0">
|
||||
<td className="py-2 pr-2 text-[11px] text-fg-disabled font-korean whitespace-nowrap align-top w-24">
|
||||
<td className="py-2 pr-2 text-label-2 text-fg-disabled font-korean whitespace-nowrap align-top w-24">
|
||||
{row.key}
|
||||
</td>
|
||||
<td className="py-2 text-[11px] text-fg-sub font-korean leading-relaxed">
|
||||
<td className="py-2 text-label-2 text-fg-sub font-korean leading-relaxed">
|
||||
{row.value}
|
||||
</td>
|
||||
</tr>
|
||||
@ -196,11 +196,11 @@ const DispersingZonePanel = () => {
|
||||
<div className="absolute bottom-4 left-4 bg-bg-surface border border-stroke rounded-lg px-3 py-2 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
||||
<span className="text-[11px] text-fg-sub font-korean">사용고려해역</span>
|
||||
<span className="text-label-2 text-fg-sub font-korean">사용고려해역</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
||||
<span className="text-[11px] text-fg-sub font-korean">사용제한해역</span>
|
||||
<span className="text-label-2 text-fg-sub font-korean">사용제한해역</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -210,7 +210,7 @@ const DispersingZonePanel = () => {
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
||||
<h1 className="text-sm font-bold text-fg font-korean">유처리제 제한구역</h1>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">해양환경관리법 기준</p>
|
||||
</div>
|
||||
|
||||
{/* 구역 카드 목록 */}
|
||||
|
||||
@ -187,7 +187,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none';
|
||||
const labelCls = 'block text-[11px] font-semibold text-fg-sub font-korean mb-1.5';
|
||||
const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
@ -321,7 +321,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
||||
{/* 에러 */}
|
||||
{formError && (
|
||||
<div className="px-6 pb-2">
|
||||
<p className="text-[11px] text-red-400 font-korean">{formError}</p>
|
||||
<p className="text-label-2 text-red-400 font-korean">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 버튼 */}
|
||||
@ -502,34 +502,34 @@ const LayerPanel = () => {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
|
||||
레이어코드
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
레이어명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
레이어전체명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
|
||||
레벨
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
|
||||
WMS레이어명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-16">
|
||||
정렬
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-28">
|
||||
등록일시
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-20">
|
||||
사용여부
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">
|
||||
액션
|
||||
</th>
|
||||
</tr>
|
||||
@ -555,7 +555,7 @@ const LayerPanel = () => {
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
{/* 레이어코드 */}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">{item.layerCd}</td>
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||
{/* 레이어명 */}
|
||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
||||
{/* 레이어전체명 */}
|
||||
@ -566,12 +566,12 @@ const LayerPanel = () => {
|
||||
</td>
|
||||
{/* 레벨 */}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-caption font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
{/* WMS레이어명 */}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||
</td>
|
||||
{/* 정렬순서 */}
|
||||
@ -579,7 +579,7 @@ const LayerPanel = () => {
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
{/* 등록일시 */}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
{/* 사용여부 토글 */}
|
||||
@ -598,7 +598,7 @@ const LayerPanel = () => {
|
||||
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||
? 'bg-color-accent'
|
||||
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
||||
? 'bg-color-accent/40'
|
||||
? 'bg-[rgba(6,182,212,0.4)]'
|
||||
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||
}`}
|
||||
>
|
||||
@ -637,27 +637,27 @@ const LayerPanel = () => {
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">
|
||||
<span key={`e${i}`} className="px-1.5 text-label-2 text-fg-disabled">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
className={`px-2.5 py-1 text-label-2 rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||
@ -670,7 +670,7 @@ const LayerPanel = () => {
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -100,7 +100,7 @@ function MapBaseModal({
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 지도 이름 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 이름 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -114,7 +114,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 지도 키 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 키 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -129,7 +129,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 지도 레벨 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
지도 레벨
|
||||
</label>
|
||||
<select
|
||||
@ -148,7 +148,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 파일 소스 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
파일 소스
|
||||
</label>
|
||||
<input
|
||||
@ -162,7 +162,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 상세 설명 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
상세 설명
|
||||
</label>
|
||||
<textarea
|
||||
@ -176,7 +176,7 @@ function MapBaseModal({
|
||||
|
||||
{/* 사용여부 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
사용여부
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -200,7 +200,7 @@ function MapBaseModal({
|
||||
</div>
|
||||
|
||||
{/* 에러 */}
|
||||
{modalError && <p className="text-[11px] text-red-400 font-korean">{modalError}</p>}
|
||||
{modalError && <p className="text-label-2 text-red-400 font-korean">{modalError}</p>}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
@ -363,7 +363,7 @@ function MapBasePanel() {
|
||||
{/* 메시지 */}
|
||||
{message && (
|
||||
<div
|
||||
className={`mx-6 mt-2 px-3 py-2 text-[11px] rounded-md font-korean ${
|
||||
className={`mx-6 mt-2 px-3 py-2 text-label-2 rounded-md font-korean ${
|
||||
message.type === 'success'
|
||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
||||
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
||||
@ -404,7 +404,7 @@ function MapBasePanel() {
|
||||
<td className="py-3 text-center text-fg-disabled">{(page - 1) * 10 + idx + 1}</td>
|
||||
<td className="py-3 pl-4">
|
||||
<span className="text-fg font-korean">{item.mapNm}</span>
|
||||
<span className="ml-2 text-[10px] text-fg-disabled font-mono">
|
||||
<span className="ml-2 text-caption text-fg-disabled font-mono">
|
||||
{item.mapKey}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@ -189,8 +189,8 @@ function MenusPanel() {
|
||||
{activeMenu ? (
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-color-accent bg-bg-surface shadow-lg opacity-90 max-w-[700px]">
|
||||
<span className="text-fg-disabled text-xs">⠿</span>
|
||||
<span className="text-[16px]">{activeMenu.icon}</span>
|
||||
<span className="text-[13px] font-semibold text-fg font-korean">
|
||||
<span className="text-title-2">{activeMenu.icon}</span>
|
||||
<span className="text-title-4 font-semibold text-fg font-korean">
|
||||
{activeMenu.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -342,19 +342,19 @@ function ConnectionBadge({
|
||||
if (isNormal) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-blue-600 text-white">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-blue-600 text-white">
|
||||
ON
|
||||
</span>
|
||||
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
|
||||
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold bg-orange-500 text-white">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-label-2 font-semibold bg-orange-500 text-white">
|
||||
OFF
|
||||
</span>
|
||||
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
|
||||
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -133,7 +133,7 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
|
||||
const isDisabled = state === 'forced-denied' || readOnly;
|
||||
|
||||
const baseClasses =
|
||||
'w-5 h-5 rounded border text-[10px] font-bold transition-all flex items-center justify-center';
|
||||
'w-5 h-5 rounded border text-caption font-bold transition-all flex items-center justify-center';
|
||||
|
||||
let classes: string;
|
||||
let icon: string;
|
||||
@ -240,14 +240,14 @@ function TreeRow({
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-[9px]">
|
||||
<span className="w-4 mr-1 flex-shrink-0 text-center text-fg-disabled text-caption">
|
||||
{node.level > 0 ? '├' : ''}
|
||||
</span>
|
||||
)}
|
||||
{node.icon && <span className="mr-1 flex-shrink-0 text-[11px]">{node.icon}</span>}
|
||||
{node.icon && <span className="mr-1 flex-shrink-0 text-label-2">{node.icon}</span>}
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className={`text-[11px] font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}
|
||||
className={`text-label-2 font-korean truncate ${node.level === 0 ? 'font-bold text-fg' : 'font-medium text-fg-sub'}`}
|
||||
>
|
||||
{node.name}
|
||||
</div>
|
||||
@ -295,29 +295,29 @@ function TreeRow({
|
||||
function PermLegend() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-1.5 border-b border-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean"
|
||||
className="flex items-center gap-3 px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption text-fg-disabled font-korean"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent text-center text-[8px] leading-3">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.2)] border-color-accent text-color-accent text-center text-caption leading-3">
|
||||
✓
|
||||
</span>
|
||||
허용
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-[8px] leading-3">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.3)] text-[rgba(6,182,212,0.5)] text-center text-caption leading-3">
|
||||
✓
|
||||
</span>
|
||||
상속
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-[8px] leading-3">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-[rgba(239,68,68,0.08)] border-[rgba(239,68,68,0.3)] text-red-400 text-center text-caption leading-3">
|
||||
—
|
||||
</span>
|
||||
거부
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-bg-elevated border-stroke text-fg-disabled opacity-40 text-center text-[8px] leading-3">
|
||||
<span className="inline-block w-3 h-3 rounded border bg-bg-elevated border-stroke text-fg-disabled opacity-40 text-center text-caption leading-3">
|
||||
—
|
||||
</span>
|
||||
비활성
|
||||
@ -418,14 +418,14 @@ function RolePermTab({
|
||||
setShowCreateForm(true);
|
||||
setCreateError('');
|
||||
}}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||||
>
|
||||
+ 역할 추가
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
|
||||
dirty
|
||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
@ -434,7 +434,7 @@ function RolePermTab({
|
||||
{saving ? '저장 중...' : '변경사항 저장'}
|
||||
</button>
|
||||
{saveError && (
|
||||
<span className="text-[11px] text-color-danger font-korean">{saveError}</span>
|
||||
<span className="text-label-2 text-color-danger font-korean">{saveError}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -450,7 +450,7 @@ function RolePermTab({
|
||||
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedRoleSn(role.sn)}
|
||||
className={`px-2.5 py-1 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
className={`px-2.5 py-1 text-label-2 font-semibold rounded-md transition-all font-korean ${
|
||||
isSelected
|
||||
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||
: 'border border-stroke text-fg-disabled hover:border-stroke'
|
||||
@ -469,19 +469,21 @@ function RolePermTab({
|
||||
onBlur={() => handleSaveRoleName(role.sn)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
autoFocus
|
||||
className="w-20 px-1 py-0 text-[11px] font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
|
||||
className="w-20 px-1 py-0 text-label-2 font-semibold bg-bg-elevated border border-color-accent rounded text-center text-fg focus:outline-none font-korean"
|
||||
/>
|
||||
) : (
|
||||
<span onDoubleClick={() => handleStartEditName(role)}>{role.name}</span>
|
||||
)}
|
||||
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
||||
{role.isDefault && <span className="ml-1 text-[9px] text-color-accent">기본</span>}
|
||||
<span className="ml-1 text-caption font-mono opacity-50">{role.code}</span>
|
||||
{role.isDefault && (
|
||||
<span className="ml-1 text-caption text-color-accent">기본</span>
|
||||
)}
|
||||
</button>
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => toggleDefault(role.sn)}
|
||||
className={`px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||||
className={`px-1.5 py-0.5 text-caption rounded transition-all font-korean ${
|
||||
role.isDefault
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
@ -525,13 +527,15 @@ function RolePermTab({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-3 py-1.5 text-left text-[10px] font-semibold text-fg-disabled font-korean min-w-[200px]">
|
||||
<th className="px-3 py-1.5 text-left text-caption font-semibold text-fg-disabled font-korean min-w-[200px]">
|
||||
기능
|
||||
</th>
|
||||
{OPER_CODES.map((oper) => (
|
||||
<th key={oper} className="px-1 py-1.5 text-center w-12">
|
||||
<div className="text-[10px] font-semibold text-fg-sub">{OPER_LABELS[oper]}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-korean">
|
||||
<div className="text-caption font-semibold text-fg-sub">
|
||||
{OPER_LABELS[oper]}
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">
|
||||
{OPER_FULL_LABELS[oper]}
|
||||
</div>
|
||||
</th>
|
||||
@ -567,7 +571,7 @@ function RolePermTab({
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
|
||||
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
||||
역할 코드
|
||||
</label>
|
||||
<input
|
||||
@ -579,12 +583,12 @@ function RolePermTab({
|
||||
placeholder="CUSTOM_ROLE"
|
||||
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-fg-disabled mt-1 font-korean">
|
||||
<p className="text-caption text-fg-disabled mt-1 font-korean">
|
||||
영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
|
||||
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
||||
역할 이름
|
||||
</label>
|
||||
<input
|
||||
@ -596,7 +600,7 @@ function RolePermTab({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-fg-disabled font-korean block mb-1">
|
||||
<label className="text-label-2 text-fg-disabled font-korean block mb-1">
|
||||
설명 (선택)
|
||||
</label>
|
||||
<input
|
||||
@ -608,7 +612,7 @@ function RolePermTab({
|
||||
/>
|
||||
</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">
|
||||
<div className="px-3 py-2 text-label-2 text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
@ -792,7 +796,9 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* 사용자 검색/선택 */}
|
||||
<div className="px-4 py-2.5 border-b border-stroke" style={{ flexShrink: 0 }}>
|
||||
<label className="text-[10px] text-fg-disabled font-korean block mb-1.5">사용자 선택</label>
|
||||
<label className="text-caption text-fg-disabled font-korean block mb-1.5">
|
||||
사용자 선택
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
@ -823,17 +829,17 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
<div className="text-xs font-semibold text-fg font-korean truncate">
|
||||
{user.name}
|
||||
{user.rank && (
|
||||
<span className="ml-1 text-[10px] text-fg-disabled font-korean">
|
||||
<span className="ml-1 text-caption text-fg-disabled font-korean">
|
||||
{user.rank}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono truncate">
|
||||
<div className="text-caption text-fg-disabled font-mono truncate">
|
||||
{user.account}
|
||||
</div>
|
||||
</div>
|
||||
{user.orgName && (
|
||||
<span className="text-[10px] text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">
|
||||
<span className="text-caption text-fg-disabled font-korean flex-shrink-0 truncate max-w-[100px]">
|
||||
{user.orgName}
|
||||
</span>
|
||||
)}
|
||||
@ -857,11 +863,11 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold text-fg-sub font-korean">역할 할당</span>
|
||||
<span className="text-caption font-semibold text-fg-sub font-korean">역할 할당</span>
|
||||
<button
|
||||
onClick={handleSaveRoles}
|
||||
disabled={!rolesDirty || savingRoles}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all font-korean ${
|
||||
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all font-korean ${
|
||||
rolesDirty
|
||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||||
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||
@ -878,7 +884,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
<label
|
||||
key={role.sn}
|
||||
className={[
|
||||
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-[11px] select-none',
|
||||
'flex items-center gap-1 px-2 py-1 rounded-md border cursor-pointer transition-all font-korean text-label-2 select-none',
|
||||
isChecked ? '' : 'border-stroke text-fg-disabled hover:border-text-2',
|
||||
].join(' ')}
|
||||
style={
|
||||
@ -894,7 +900,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
className="w-3 h-3 accent-primary-cyan"
|
||||
/>
|
||||
<span>{role.name}</span>
|
||||
<span className="text-[9px] font-mono opacity-60">{role.code}</span>
|
||||
<span className="text-caption font-mono opacity-60">{role.code}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@ -903,7 +909,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
|
||||
{/* 유효 권한 매트릭스 (읽기 전용) */}
|
||||
<div
|
||||
className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-[9px] text-fg-disabled font-korean"
|
||||
className="px-4 py-1.5 border-b border-stroke bg-bg-surface text-caption text-fg-disabled font-korean"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<span className="font-semibold text-fg-sub">유효 권한 (읽기 전용)</span>
|
||||
@ -917,15 +923,15 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean min-w-[240px]">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean min-w-[240px]">
|
||||
기능
|
||||
</th>
|
||||
{OPER_CODES.map((oper) => (
|
||||
<th key={oper} className="px-2 py-3 text-center w-16">
|
||||
<div className="text-[11px] font-semibold text-fg-sub">
|
||||
<div className="text-label-2 font-semibold text-fg-sub">
|
||||
{OPER_LABELS[oper]}
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled font-korean">
|
||||
<div className="text-caption text-fg-disabled font-korean">
|
||||
{OPER_FULL_LABELS[oper]}
|
||||
</div>
|
||||
</th>
|
||||
@ -1189,7 +1195,7 @@ function PermissionsPanel() {
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-fg font-korean">권한 관리</h1>
|
||||
<p className="text-[10px] text-fg-disabled mt-0.5 font-korean">
|
||||
<p className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||
역할별 리소스 × CRUD 권한 설정
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -183,31 +183,31 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
|
||||
레이어코드
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
레이어명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
레이어전체명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
|
||||
레벨
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
|
||||
WMS레이어명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-16">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-16">
|
||||
정렬
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-28">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-28">
|
||||
등록일시
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean w-20">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-20">
|
||||
사용여부
|
||||
</th>
|
||||
</tr>
|
||||
@ -231,7 +231,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">{item.layerCd}</td>
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
|
||||
<td className="px-4 py-3 text-xs text-fg font-korean">{item.layerNm}</td>
|
||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
||||
<span className="block truncate" title={item.layerFullNm}>
|
||||
@ -239,17 +239,17 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-caption font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
|
||||
{item.layerLevel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-mono">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
|
||||
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
||||
{item.sortOrd}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
||||
{item.regDtm ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
@ -285,27 +285,27 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
{buildPageButtons().map((btn, i) =>
|
||||
btn === 'ellipsis' ? (
|
||||
<span key={`e${i}`} className="px-1.5 text-[11px] text-fg-disabled">
|
||||
<span key={`e${i}`} className="px-1.5 text-label-2 text-fg-disabled">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={btn}
|
||||
onClick={() => setPage(btn)}
|
||||
className={`px-2.5 py-1 text-[11px] rounded transition-all ${
|
||||
className={`px-2.5 py-1 text-label-2 rounded transition-all ${
|
||||
page === btn
|
||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
||||
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||
@ -318,7 +318,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -75,7 +75,7 @@ function SettingsPanel() {
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록 설정</h2>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">
|
||||
<p className="text-label-2 text-fg-disabled mt-0.5 font-korean">
|
||||
신규 사용자 등록 시 적용되는 정책을 설정합니다
|
||||
</p>
|
||||
</div>
|
||||
@ -84,8 +84,8 @@ function SettingsPanel() {
|
||||
{/* 자동 승인 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-fg font-korean">자동 승인</div>
|
||||
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
<div className="text-title-4 font-semibold text-fg font-korean">자동 승인</div>
|
||||
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자가 등록 즉시{' '}
|
||||
<span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||||
비활성화하면 관리자 승인 전까지{' '}
|
||||
@ -111,10 +111,10 @@ function SettingsPanel() {
|
||||
{/* 기본 역할 자동 할당 */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="text-[13px] font-semibold text-fg font-korean">
|
||||
<div className="text-title-4 font-semibold text-fg font-korean">
|
||||
기본 역할 자동 할당
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
<p className="text-label-2 text-fg-disabled mt-1 font-korean leading-relaxed">
|
||||
활성화하면 신규 사용자에게{' '}
|
||||
<span className="text-color-accent font-semibold">기본 역할</span>이 자동으로
|
||||
할당됩니다. 기본 역할은 권한 관리 탭에서 설정할 수 있습니다.
|
||||
@ -141,16 +141,16 @@ function SettingsPanel() {
|
||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth 설정</h2>
|
||||
<p className="text-[11px] text-fg-disabled mt-0.5 font-korean">
|
||||
<p className="text-label-2 text-fg-disabled 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-fg font-korean mb-1">
|
||||
<div className="text-title-4 font-semibold text-fg font-korean mb-1">
|
||||
자동 승인 도메인
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-disabled font-korean leading-relaxed mb-3">
|
||||
<p className="text-label-2 text-fg-disabled 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> 상태로
|
||||
@ -202,7 +202,7 @@ function SettingsPanel() {
|
||||
.map((domain) => (
|
||||
<span
|
||||
key={domain}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-caption font-mono rounded-md"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
color: 'var(--color-accent)',
|
||||
@ -223,7 +223,7 @@ function SettingsPanel() {
|
||||
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex flex-col gap-3 text-[12px] font-korean">
|
||||
<div className="flex flex-col gap-3 text-label-1 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'}`}
|
||||
|
||||
@ -103,31 +103,31 @@ function SortableMenuItem({
|
||||
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-elevated border border-stroke rounded px-2 text-fg focus:border-color-accent focus:outline-none"
|
||||
className="w-full h-8 text-title-4 font-semibold font-korean bg-bg-elevated border border-stroke rounded px-2 text-fg focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
<div className="text-[10px] text-fg-disabled font-mono mt-0.5">{menu.id}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono mt-0.5">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onEditEnd}
|
||||
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-color-accent border border-color-accent rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
className="shrink-0 px-2 py-1 text-caption font-semibold text-color-accent border border-color-accent rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
>
|
||||
완료
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[16px] shrink-0">{menu.icon}</span>
|
||||
<span className="text-title-2 shrink-0">{menu.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}
|
||||
className={`text-title-4 font-semibold font-korean ${menu.enabled ? 'text-fg' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{menu.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">{menu.id}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">{menu.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onEditStart(menu.id)}
|
||||
className="shrink-0 w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-[11px] flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all"
|
||||
className="shrink-0 w-7 h-7 rounded border border-stroke bg-bg-elevated text-fg-disabled text-label-2 flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all"
|
||||
title="라벨/아이콘 편집"
|
||||
>
|
||||
✏️
|
||||
|
||||
@ -107,7 +107,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* 계정 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
계정 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -121,7 +121,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 비밀번호 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
비밀번호 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -135,7 +135,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 사용자명 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
사용자명 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
@ -149,7 +149,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 직급 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
직급
|
||||
</label>
|
||||
<input
|
||||
@ -163,7 +163,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 소속 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
소속
|
||||
</label>
|
||||
<select
|
||||
@ -183,7 +183,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 이메일 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
@ -197,12 +197,12 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
|
||||
{/* 역할 */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub font-korean mb-1.5">
|
||||
역할
|
||||
</label>
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
|
||||
{allRoles.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-disabled font-korean px-1 py-1">역할 없음</p>
|
||||
<p className="text-caption text-fg-disabled font-korean px-1 py-1">역할 없음</p>
|
||||
) : (
|
||||
allRoles.map((role, idx) => {
|
||||
const color = getRoleColor(role.code, idx);
|
||||
@ -220,7 +220,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
<span className="text-xs font-korean" style={{ color }}>
|
||||
{role.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">{role.code}</span>
|
||||
<span className="text-caption text-fg-disabled font-mono">{role.code}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
@ -229,7 +229,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && <p className="text-[11px] text-red-400 font-korean">{error}</p>}
|
||||
{error && <p className="text-label-2 text-red-400 font-korean">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
@ -333,7 +333,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 정보</h2>
|
||||
<p className="text-[10px] text-fg-disabled font-mono mt-0.5">{user.account}</p>
|
||||
<p className="text-caption text-fg-disabled font-mono mt-0.5">{user.account}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||
<svg
|
||||
@ -352,12 +352,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{/* 기본 정보 수정 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">
|
||||
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-3">
|
||||
기본 정보 수정
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<label className="block text-caption text-fg-disabled font-korean mb-1">
|
||||
사용자명
|
||||
</label>
|
||||
<input
|
||||
@ -369,7 +369,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<label className="block text-caption text-fg-disabled font-korean mb-1">
|
||||
직급
|
||||
</label>
|
||||
<input
|
||||
@ -381,7 +381,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<label className="block text-caption text-fg-disabled font-korean mb-1">
|
||||
소속
|
||||
</label>
|
||||
<select
|
||||
@ -402,7 +402,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<button
|
||||
onClick={handleSaveInfo}
|
||||
disabled={saving || !name.trim()}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
className="px-4 py-1.5 text-label-2 font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
|
||||
>
|
||||
{saving ? '저장 중...' : '정보 저장'}
|
||||
</button>
|
||||
@ -414,12 +414,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
|
||||
{/* 비밀번호 초기화 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">
|
||||
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-3">
|
||||
비밀번호 초기화
|
||||
</h3>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<label className="block text-caption text-fg-disabled font-korean mb-1">
|
||||
새 비밀번호
|
||||
</label>
|
||||
<input
|
||||
@ -433,20 +433,20 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
disabled={resetPwLoading || !newPassword.trim()}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
>
|
||||
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnlock}
|
||||
disabled={unlockLoading || user.status !== 'LOCKED'}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
|
||||
>
|
||||
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1.5">
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1.5">
|
||||
초기화 후 사용자에게 새 비밀번호를 전달하세요.
|
||||
</p>
|
||||
</div>
|
||||
@ -456,12 +456,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
|
||||
{/* 계정 잠금 해제 */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2">계정 상태</h3>
|
||||
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-2">계정 상태</h3>
|
||||
<div className="flex items-center justify-between bg-bg-elevated border border-stroke rounded-md px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-[11px] font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}
|
||||
className={`inline-flex items-center gap-1.5 text-label-2 font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`}
|
||||
@ -469,13 +469,13 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
|
||||
</span>
|
||||
{user.failCount > 0 && (
|
||||
<span className="text-[10px] text-red-400 font-korean">
|
||||
<span className="text-caption text-red-400 font-korean">
|
||||
(로그인 실패 {user.failCount}회)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user.status === 'LOCKED' && (
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1">
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
비밀번호 5회 이상 오류로 잠금 처리됨
|
||||
</p>
|
||||
)}
|
||||
@ -484,7 +484,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
<button
|
||||
onClick={handleUnlock}
|
||||
disabled={unlockLoading}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
className="px-4 py-1.5 text-label-2 font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
|
||||
>
|
||||
{unlockLoading ? '해제 중...' : '잠금 해제'}
|
||||
</button>
|
||||
@ -494,8 +494,8 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
|
||||
{/* 기타 정보 (읽기 전용) */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2">기타 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] font-korean">
|
||||
<h3 className="text-label-2 font-semibold text-fg-sub font-korean mb-2">기타 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-caption font-korean">
|
||||
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||
<span className="text-fg-disabled">이메일: </span>
|
||||
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
|
||||
@ -520,7 +520,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
||||
{/* 메시지 */}
|
||||
{message && (
|
||||
<div
|
||||
className={`px-3 py-2 text-[11px] rounded-md font-korean ${
|
||||
className={`px-3 py-2 text-label-2 rounded-md font-korean ${
|
||||
message.type === 'success'
|
||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
||||
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
|
||||
@ -685,7 +685,7 @@ function UsersPanel() {
|
||||
</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">
|
||||
<span className="px-2.5 py-1 text-caption 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>
|
||||
)}
|
||||
@ -747,31 +747,31 @@ function UsersPanel() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
사용자명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
직급
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
소속
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
이메일
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
역할
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
승인상태
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-right text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
@ -796,12 +796,12 @@ function UsersPanel() {
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
{/* 번호 */}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
|
||||
{rowNum}
|
||||
</td>
|
||||
|
||||
{/* ID(account) */}
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-mono">
|
||||
<td className="px-4 py-3 text-label-1 text-fg-sub font-mono">
|
||||
{user.account}
|
||||
</td>
|
||||
|
||||
@ -809,24 +809,24 @@ function UsersPanel() {
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setDetailUser(user)}
|
||||
className="text-[12px] text-color-accent font-semibold font-korean hover:underline"
|
||||
className="text-label-1 text-color-accent font-semibold font-korean hover:underline"
|
||||
>
|
||||
{user.name}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* 직급 */}
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
|
||||
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
|
||||
{user.rank || '-'}
|
||||
</td>
|
||||
|
||||
{/* 소속 */}
|
||||
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
|
||||
<td className="px-4 py-3 text-label-1 text-fg-sub font-korean">
|
||||
{user.orgAbbr || user.orgName || '-'}
|
||||
</td>
|
||||
|
||||
{/* 이메일 */}
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
|
||||
{user.email || '-'}
|
||||
</td>
|
||||
|
||||
@ -846,7 +846,7 @@ function UsersPanel() {
|
||||
return (
|
||||
<span
|
||||
key={roleCode}
|
||||
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
|
||||
className="px-2 py-0.5 text-caption font-semibold rounded-md font-korean"
|
||||
style={{
|
||||
background: `${color}20`,
|
||||
color: color,
|
||||
@ -858,11 +858,11 @@ function UsersPanel() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-[10px] text-fg-disabled font-korean">
|
||||
<span className="text-caption text-fg-disabled font-korean">
|
||||
역할 없음
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-fg-disabled ml-0.5">
|
||||
<span className="text-caption text-fg-disabled ml-0.5">
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
@ -881,7 +881,7 @@ function UsersPanel() {
|
||||
ref={roleDropdownRef}
|
||||
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-surface border border-stroke rounded-lg shadow-lg min-w-[200px]"
|
||||
>
|
||||
<div className="text-[10px] text-fg-disabled font-korean font-semibold mb-1.5 px-1">
|
||||
<div className="text-caption text-fg-disabled font-korean font-semibold mb-1.5 px-1">
|
||||
역할 선택
|
||||
</div>
|
||||
{allRoles.map((role, roleIdx) => {
|
||||
@ -900,7 +900,7 @@ function UsersPanel() {
|
||||
<span className="text-xs font-korean" style={{ color }}>
|
||||
{role.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">
|
||||
<span className="text-caption text-fg-disabled font-mono">
|
||||
{role.code}
|
||||
</span>
|
||||
</label>
|
||||
@ -909,14 +909,14 @@ function UsersPanel() {
|
||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
|
||||
<button
|
||||
onClick={() => setRoleEditUserId(null)}
|
||||
className="px-3 py-1 text-[10px] text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
|
||||
className="px-3 py-1 text-caption text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveRoles(user.id)}
|
||||
disabled={selectedRoleSns.length === 0}
|
||||
className="px-3 py-1 text-[10px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||
className="px-3 py-1 text-caption font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
@ -929,7 +929,7 @@ function UsersPanel() {
|
||||
{/* 승인상태 */}
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}
|
||||
className={`inline-flex items-center gap-1.5 text-caption font-semibold font-korean ${statusInfo.color}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
|
||||
{statusInfo.label}
|
||||
@ -943,13 +943,13 @@ function UsersPanel() {
|
||||
<>
|
||||
<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"
|
||||
className="px-2 py-1 text-caption 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"
|
||||
className="px-2 py-1 text-caption font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
|
||||
>
|
||||
거절
|
||||
</button>
|
||||
@ -958,7 +958,7 @@ function UsersPanel() {
|
||||
{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"
|
||||
className="px-2 py-1 text-caption font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
|
||||
>
|
||||
잠금해제
|
||||
</button>
|
||||
@ -966,7 +966,7 @@ function UsersPanel() {
|
||||
{user.status === 'ACTIVE' && (
|
||||
<button
|
||||
onClick={() => handleDeactivate(user.id)}
|
||||
className="px-2 py-1 text-[10px] font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
className="px-2 py-1 text-caption font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
@ -974,7 +974,7 @@ function UsersPanel() {
|
||||
{(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"
|
||||
className="px-2 py-1 text-caption font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
|
||||
>
|
||||
활성화
|
||||
</button>
|
||||
@ -993,7 +993,7 @@ function UsersPanel() {
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
|
||||
{totalCount}명
|
||||
</span>
|
||||
@ -1001,7 +1001,7 @@ function UsersPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -1020,14 +1020,14 @@ function UsersPanel() {
|
||||
}, [])
|
||||
.map((item, i) =>
|
||||
item === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-fg-disabled">
|
||||
<span key={`ellipsis-${i}`} className="px-2 text-label-2 text-fg-disabled">
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
onClick={() => setCurrentPage(item as number)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-all font-mono"
|
||||
className="px-2.5 py-1 text-label-2 border rounded transition-all font-mono"
|
||||
style={
|
||||
currentPage === item
|
||||
? {
|
||||
@ -1045,7 +1045,7 @@ function UsersPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -148,37 +148,37 @@ function VesselMaterialsPanel() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-surface">
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
유형
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
관할청
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
기관명
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
주소
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-color-accent bg-color-accent/5">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-color-accent bg-[rgba(6,182,212,0.05)]">
|
||||
방제선
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||||
유회수기
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||||
이송펌프
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||||
방제차량
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-fg-disabled">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||||
살포장치
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean">
|
||||
총자산
|
||||
</th>
|
||||
</tr>
|
||||
@ -199,41 +199,41 @@ function VesselMaterialsPanel() {
|
||||
key={org.id}
|
||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
|
||||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
||||
className={`text-caption px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
||||
>
|
||||
{org.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-sub font-korean">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-sub font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg font-korean font-semibold">
|
||||
<td className="px-4 py-3 text-label-2 text-fg font-korean font-semibold">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean max-w-[200px] truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-color-accent font-semibold bg-color-accent/5">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]">
|
||||
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||||
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||||
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||||
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-fg-sub">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||||
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-color-accent">
|
||||
<td className="px-4 py-3 text-label-2 font-mono text-center font-bold text-color-accent">
|
||||
{org.totalAssets.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
@ -247,7 +247,7 @@ function VesselMaterialsPanel() {
|
||||
{/* 합계 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -290,15 +290,15 @@ function VesselMaterialsPanel() {
|
||||
].map((t) => (
|
||||
<div
|
||||
key={t.label}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-color-accent/10' : ''}`}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[9px] font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
className={`text-caption font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}
|
||||
className={`text-caption font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}
|
||||
>
|
||||
{t.value.toLocaleString()}
|
||||
{t.unit}
|
||||
@ -311,7 +311,7 @@ function VesselMaterialsPanel() {
|
||||
{/* 페이지네이션 */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} /
|
||||
전체 {filtered.length}개
|
||||
</span>
|
||||
@ -319,7 +319,7 @@ function VesselMaterialsPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
@ -327,7 +327,7 @@ function VesselMaterialsPanel() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
|
||||
style={
|
||||
p === safePage
|
||||
? {
|
||||
@ -344,7 +344,7 @@ function VesselMaterialsPanel() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
className="px-2.5 py-1 text-[11px] border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
|
||||
@ -180,10 +180,10 @@ export default function VesselSignalPanel() {
|
||||
className="flex flex-col justify-center mb-4"
|
||||
style={{ height: 20 }}
|
||||
>
|
||||
<span className="text-[12px] font-semibold leading-tight" style={{ color: c }}>
|
||||
<span className="text-label-1 font-semibold leading-tight" style={{ color: c }}>
|
||||
{src}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
||||
<span className="text-caption font-mono text-text-4 mt-0.5">{st.rate}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -196,14 +196,14 @@ export default function VesselSignalPanel() {
|
||||
{HOURS.map((h) => (
|
||||
<span
|
||||
key={h}
|
||||
className="absolute text-[10px] text-fg-disabled font-mono"
|
||||
className="absolute text-caption text-fg-disabled font-mono"
|
||||
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{String(h).padStart(2, '0')}시
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className="absolute text-[10px] text-fg-disabled font-mono"
|
||||
className="absolute text-caption text-fg-disabled font-mono"
|
||||
style={{ right: 0 }}
|
||||
>
|
||||
24시
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -19,6 +19,7 @@ interface CCTVPlayerProps {
|
||||
coordDc?: string | null;
|
||||
sourceNm?: string | null;
|
||||
cellIndex?: number;
|
||||
onClose?: () => void;
|
||||
oilDetectionEnabled?: boolean;
|
||||
vesselDetectionEnabled?: boolean;
|
||||
intrusionDetectionEnabled?: boolean;
|
||||
@ -44,9 +45,8 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
cameraNm,
|
||||
streamUrl,
|
||||
sttsCd,
|
||||
coordDc,
|
||||
sourceNm,
|
||||
cellIndex = 0,
|
||||
onClose,
|
||||
oilDetectionEnabled = false,
|
||||
vesselDetectionEnabled = false,
|
||||
intrusionDetectionEnabled = false,
|
||||
@ -251,10 +251,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-30 mb-2">📹</div>
|
||||
<div className="text-[11px] font-korean text-fg-disabled opacity-70">
|
||||
<div className="text-label-2 font-korean text-fg-disabled opacity-70">
|
||||
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
|
||||
</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
|
||||
<div className="text-caption font-korean text-fg-disabled opacity-40 mt-1">
|
||||
{cameraNm}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -264,10 +266,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-20 mb-2">📹</div>
|
||||
<div className="text-[10px] font-korean text-fg-disabled opacity-50">
|
||||
<div className="text-caption font-korean text-fg-disabled opacity-50">
|
||||
스트림 URL 미설정
|
||||
</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-30 mt-1">{cameraNm}</div>
|
||||
<div className="text-caption font-korean text-fg-disabled opacity-30 mt-1">
|
||||
{cameraNm}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -277,11 +281,13 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
||||
<div className="text-2xl opacity-30 mb-2">⚠️</div>
|
||||
<div className="text-[10px] font-korean text-color-danger opacity-70">연결 실패</div>
|
||||
<div className="text-[9px] font-korean text-fg-disabled opacity-40 mt-1">{cameraNm}</div>
|
||||
<div className="text-caption font-korean text-color-danger opacity-70">연결 실패</div>
|
||||
<div className="text-caption font-korean text-fg-disabled opacity-40 mt-1">
|
||||
{cameraNm}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
className="mt-2 px-2.5 py-1 rounded text-caption font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors"
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
@ -295,7 +301,7 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
{playerState === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base z-10">
|
||||
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
|
||||
<div className="text-[10px] font-korean text-fg-disabled opacity-50">연결 중...</div>
|
||||
<div className="text-caption font-korean text-fg-disabled opacity-50">연결 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -343,16 +349,22 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
|
||||
{vesselDetectionEnabled && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
||||
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-info) 30%, transparent)',
|
||||
color: 'var(--color-info)',
|
||||
}}
|
||||
>
|
||||
🚢 선박 출입 감지 중
|
||||
</div>
|
||||
)}
|
||||
{intrusionDetectionEnabled && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
||||
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
|
||||
color: 'var(--color-warning)',
|
||||
}}
|
||||
>
|
||||
🚨 침입 감지 중
|
||||
</div>
|
||||
@ -362,22 +374,45 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
||||
|
||||
{/* OSD 오버레이 */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
|
||||
<span className="text-caption font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
|
||||
{cameraNm}
|
||||
</span>
|
||||
{sttsCd === 'LIVE' && (
|
||||
<span
|
||||
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
|
||||
style={{ background: 'rgba(239,68,68,.3)' }}
|
||||
className="text-caption font-bold px-1 py-0.5 rounded text-color-danger"
|
||||
style={{ background: 'color-mix(in srgb, var(--color-danger) 30%, transparent)' }}
|
||||
>
|
||||
● REC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
|
||||
|
||||
{/* 닫기 (지도로 돌아가기) */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded bg-black/60 hover:bg-black/80 text-white/70 hover:text-white cursor-pointer transition-colors z-20"
|
||||
title="지도로 돌아가기"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M19 12H5" />
|
||||
<path d="M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* <div className="absolute bottom-2 left-2 text-caption font-mono px-1.5 py-0.5 rounded text-fg-disabled bg-black/70 z-20">
|
||||
{coordDc ?? ''}
|
||||
{sourceNm ? ` · ${sourceNm}` : ''}
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -13,17 +13,9 @@ function formatDtm(dtm: string | null): string {
|
||||
|
||||
const equipIcon = (t: string) => (t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰');
|
||||
|
||||
const equipTagCls = (t: string) =>
|
||||
t === 'drone'
|
||||
? 'bg-[rgba(59,130,246,0.12)] text-color-info'
|
||||
: t === 'plane'
|
||||
? 'bg-[rgba(34,197,94,0.12)] text-color-success'
|
||||
: 'bg-[rgba(168,85,247,0.12)] text-color-tertiary';
|
||||
const equipTagCls = () => 'text-fg';
|
||||
|
||||
const mediaTagCls = (t: string) =>
|
||||
t === '영상'
|
||||
? 'bg-[rgba(239,68,68,0.12)] text-color-danger'
|
||||
: 'bg-[rgba(234,179,8,0.12)] text-color-caution';
|
||||
const mediaTagCls = () => 'text-fg';
|
||||
|
||||
const FilterBtn = ({
|
||||
label,
|
||||
@ -36,10 +28,10 @@ const FilterBtn = ({
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
|
||||
className={`px-2.5 py-1 text-caption font-semibold rounded font-korean transition-colors ${
|
||||
active
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)]'
|
||||
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@ -181,7 +173,7 @@ export function MediaManagement() {
|
||||
{/* Filters */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">촬영 장비:</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">촬영 장비:</span>
|
||||
<FilterBtn
|
||||
label="전체"
|
||||
active={equipFilter === 'all'}
|
||||
@ -203,7 +195,7 @@ export function MediaManagement() {
|
||||
onClick={() => setEquipFilter('satellite')}
|
||||
/>
|
||||
<span className="w-px h-4 bg-border mx-1" />
|
||||
<span className="text-[11px] text-fg-disabled font-korean">유형:</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">유형:</span>
|
||||
<FilterBtn
|
||||
label="📷 사진"
|
||||
active={typeFilter.has('photo')}
|
||||
@ -221,7 +213,7 @@ export function MediaManagement() {
|
||||
placeholder="파일명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-sm text-fg font-korean text-[11px] outline-none w-40 focus:border-color-accent"
|
||||
className="px-3 py-1.5 bg-bg-base border border-stroke rounded-sm text-fg font-korean text-label-2 outline-none w-40 focus:border-color-accent"
|
||||
/>
|
||||
<select
|
||||
value={sortBy}
|
||||
@ -242,7 +234,7 @@ export function MediaManagement() {
|
||||
icon: '📸',
|
||||
value: loading ? '…' : String(mediaItems.length),
|
||||
label: '총 파일',
|
||||
color: 'text-color-accent',
|
||||
color: 'text-fg',
|
||||
},
|
||||
{
|
||||
icon: '🛸',
|
||||
@ -261,19 +253,19 @@ export function MediaManagement() {
|
||||
].map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-stroke rounded-sm"
|
||||
className="flex-1 flex items-center gap-2.5 px-4 py-3 border border-stroke 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-fg-disabled font-korean">{s.label}</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File Table */}
|
||||
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@ -287,7 +279,7 @@ export function MediaManagement() {
|
||||
<col style={{ width: 145 }} />
|
||||
<col style={{ width: 85 }} />
|
||||
<col style={{ width: 95 }} />
|
||||
<col style={{ width: 50 }} />
|
||||
<col style={{ width: 60 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-elevated">
|
||||
@ -300,32 +292,32 @@ export function MediaManagement() {
|
||||
/>
|
||||
</th>
|
||||
<th className="px-1 py-2.5" />
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
사고명
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
위치
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
||||
파일명
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
||||
장비
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean">
|
||||
유형
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
촬영일시
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
용량
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled font-korean whitespace-nowrap">
|
||||
해상도
|
||||
</th>
|
||||
<th className="px-2 py-2.5 text-[10px] font-semibold text-fg-disabled text-center">
|
||||
📥
|
||||
<th className="px-2 py-2.5 text-caption font-semibold text-fg-disabled text-center">
|
||||
다운로드
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -334,7 +326,7 @@ export function MediaManagement() {
|
||||
<tr>
|
||||
<td
|
||||
colSpan={11}
|
||||
className="px-4 py-8 text-center text-[11px] text-fg-disabled font-korean"
|
||||
className="px-4 py-8 text-center text-label-2 text-fg-disabled font-korean"
|
||||
>
|
||||
불러오는 중...
|
||||
</td>
|
||||
@ -344,7 +336,7 @@ export function MediaManagement() {
|
||||
<tr
|
||||
key={f.aerialMediaSn}
|
||||
onClick={() => toggleId(f.aerialMediaSn)}
|
||||
className={`border-b border-stroke/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
className={`border-b border-stroke cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
@ -357,37 +349,33 @@ export function MediaManagement() {
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-fg font-korean truncate">
|
||||
<td className="px-2 py-2 text-caption font-semibold text-fg font-korean truncate">
|
||||
{f.acdntSn != null ? String(f.acdntSn) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[10px] text-color-accent font-mono truncate">
|
||||
<td className="px-2 py-2 text-caption text-color-accent font-mono truncate">
|
||||
{f.locDc ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-fg font-korean truncate">
|
||||
<td className="px-2 py-2 text-label-2 font-semibold text-fg font-korean truncate">
|
||||
{f.fileNm}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}
|
||||
>
|
||||
<span className={`text-caption font-semibold font-korean ${equipTagCls()}`}>
|
||||
{f.equipNm}
|
||||
</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.mediaTpCd)}`}
|
||||
>
|
||||
<span className={`text-caption font-semibold font-korean ${mediaTagCls()}`}>
|
||||
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{formatDtm(f.takngDtm)}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-label-2 font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => handleDownload(e, f)}
|
||||
disabled={downloadingId === f.aerialMediaSn}
|
||||
className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors disabled:opacity-50"
|
||||
className="px-2 py-1 text-caption rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||
</button>
|
||||
@ -402,26 +390,26 @@ export function MediaManagement() {
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-stroke">
|
||||
<div className="text-[11px] text-fg-disabled font-korean">
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
선택된 파일: <span className="text-color-accent 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-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover transition-colors font-korean"
|
||||
>
|
||||
☑ 전체선택
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkDownload}
|
||||
disabled={bulkDownloading || selectedIds.size === 0}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(6,182,212,0.1)] text-color-accent border border-color-accent/30 hover:bg-[rgba(6,182,212,0.2)] transition-colors font-korean disabled:opacity-50"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean disabled:opacity-50"
|
||||
>
|
||||
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-[rgba(168,85,247,0.1)] text-color-tertiary border border-color-tertiary/30 hover:bg-[rgba(168,85,247,0.2)] transition-colors font-korean"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded bg-[rgba(6,182,212,0.08)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.15)] transition-colors font-korean"
|
||||
>
|
||||
🔬 유출유확산예측으로 →
|
||||
</button>
|
||||
@ -434,10 +422,10 @@ export function MediaManagement() {
|
||||
<div className="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
||||
<div className="text-2xl mb-3">📥</div>
|
||||
<div className="text-sm font-bold font-korean mb-3">다운로드 완료</div>
|
||||
<div className="text-[13px] font-korean text-fg-sub mb-1">
|
||||
<div className="text-title-4 font-korean text-fg-sub mb-1">
|
||||
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
||||
</div>
|
||||
<div className="text-[13px] font-korean text-fg-sub mb-4">
|
||||
<div className="text-title-4 font-korean text-fg-sub mb-4">
|
||||
<span className="text-color-success font-bold">{downloadResult.success}</span>건
|
||||
다운로드 성공
|
||||
{downloadResult.total - downloadResult.success > 0 && (
|
||||
@ -453,7 +441,7 @@ export function MediaManagement() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDownloadResult(null)}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30 hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)] hover:bg-[rgba(6,182,212,0.25)] transition-colors font-korean"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
@ -477,12 +465,12 @@ export function MediaManagement() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-color-accent/40 transition-colors">
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-8 px-4 text-center mb-4 cursor-pointer hover:border-[rgba(6,182,212,0.4)] transition-colors">
|
||||
<div className="text-3xl mb-2 opacity-50">📁</div>
|
||||
<div className="text-[13px] font-semibold mb-1 font-korean">
|
||||
<div className="text-title-4 font-semibold mb-1 font-korean">
|
||||
파일을 드래그하거나 클릭하여 업로드
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
||||
</div>
|
||||
</div>
|
||||
@ -520,9 +508,11 @@ export function MediaManagement() {
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="w-full py-3 rounded-sm text-sm font-bold font-korean text-white border-none cursor-pointer"
|
||||
className="w-full py-3 rounded-sm text-sm font-bold font-korean cursor-pointer hover:brightness-125 transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
|
||||
background: 'rgba(6,182,212,0.15)',
|
||||
border: '1px solid rgba(6,182,212,0.3)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
📤 업로드 실행
|
||||
|
||||
@ -240,7 +240,7 @@ export function OilAreaAnalysis() {
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="text-sm font-bold mb-1 font-korean">🧩 영상사진합성</div>
|
||||
<div className="text-[11px] text-fg-disabled mb-4 font-korean">
|
||||
<div className="text-label-2 text-fg-disabled mb-4 font-korean">
|
||||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||||
</div>
|
||||
|
||||
@ -266,12 +266,12 @@ export function OilAreaAnalysis() {
|
||||
{/* 선택된 이미지 목록 */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<>
|
||||
<div className="text-[11px] font-bold mb-1.5 font-korean">선택된 이미지</div>
|
||||
<div className="text-label-2 font-bold mb-1.5 font-korean">선택된 이미지</div>
|
||||
<div className="flex flex-col gap-1 mb-3">
|
||||
{selectedFiles.map((file, i) => (
|
||||
<div key={`${file.name}-${i}`}>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-card border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
|
||||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-card border rounded-sm text-label-2 font-korean cursor-pointer transition-colors
|
||||
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||||
onClick={() => setSelectedImageIndex(i)}
|
||||
>
|
||||
@ -291,7 +291,7 @@ export function OilAreaAnalysis() {
|
||||
</button>
|
||||
</div>
|
||||
{selectedImageIndex === i && imageExifs[i] !== undefined && (
|
||||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-base border border-stroke/60 rounded-sm text-[11px] font-korean">
|
||||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-base border border-stroke/60 rounded-sm text-label-2 font-korean">
|
||||
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
|
||||
<MetaRow
|
||||
label="해상도"
|
||||
@ -368,7 +368,7 @@ export function OilAreaAnalysis() {
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-color-danger font-korean">
|
||||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-label-2 text-color-danger font-korean">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@ -377,7 +377,7 @@ export function OilAreaAnalysis() {
|
||||
<button
|
||||
onClick={handleStitch}
|
||||
disabled={!canStitch}
|
||||
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
|
||||
className="w-full py-2.5 mb-2 rounded-sm text-label-1 font-bold font-korean cursor-pointer transition-colors
|
||||
border border-color-accent text-color-accent bg-[rgba(6,182,212,0.06)]
|
||||
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
@ -388,7 +388,7 @@ export function OilAreaAnalysis() {
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!canAnalyze}
|
||||
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
|
||||
className="w-full py-3 rounded-sm text-title-4 font-bold font-korean cursor-pointer border-none transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||
style={
|
||||
canAnalyze
|
||||
@ -403,7 +403,7 @@ export function OilAreaAnalysis() {
|
||||
{/* ── Right Panel ── */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 3×2 이미지 그리드 */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||||
<div className="text-label-2 font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||
<div
|
||||
@ -426,21 +426,21 @@ export function OilAreaAnalysis() {
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1 bg-bg-base border-t border-stroke shrink-0 flex items-start justify-between gap-1">
|
||||
<div className="text-[10px] text-fg-sub truncate font-korean flex-1 min-w-0">
|
||||
<div className="text-caption text-fg-sub truncate font-korean flex-1 min-w-0">
|
||||
{selectedFiles[i]?.name}
|
||||
</div>
|
||||
{imageExifs[i] === undefined ? (
|
||||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
|
||||
<div className="text-caption text-fg-disabled font-korean shrink-0">
|
||||
GPS 읽는 중...
|
||||
</div>
|
||||
) : imageExifs[i]?.lat !== null ? (
|
||||
<div className="text-[10px] text-color-accent font-mono leading-tight text-right shrink-0">
|
||||
<div className="text-caption text-color-accent font-mono leading-tight text-right shrink-0">
|
||||
{decimalToDMS(imageExifs[i]!.lat!, true)}
|
||||
<br />
|
||||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
|
||||
<div className="text-caption text-fg-disabled font-korean shrink-0">
|
||||
GPS 정보 없음
|
||||
</div>
|
||||
)}
|
||||
@ -456,7 +456,7 @@ export function OilAreaAnalysis() {
|
||||
</div>
|
||||
|
||||
{/* 합성 결과 */}
|
||||
<div className="text-[11px] font-bold mb-2 font-korean">합성 결과</div>
|
||||
<div className="text-label-2 font-bold mb-2 font-korean">합성 결과</div>
|
||||
<div
|
||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||||
@ -468,7 +468,7 @@ export function OilAreaAnalysis() {
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-[12px] text-fg-disabled font-korean text-center px-4">
|
||||
<div className="text-label-1 text-fg-disabled font-korean text-center px-4">
|
||||
{isStitching
|
||||
? '⏳ 이미지를 합성하고 있습니다...'
|
||||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||||
|
||||
@ -85,11 +85,11 @@ const OilDetectionOverlay = memo(
|
||||
{/* 에러 표시 */}
|
||||
{error && (
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.5)',
|
||||
color: '#f87171',
|
||||
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
추론 서버 연결 불가
|
||||
@ -101,13 +101,13 @@ const OilDetectionOverlay = memo(
|
||||
<>
|
||||
{result.regions.map((region) => {
|
||||
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
|
||||
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
|
||||
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : 'var(--color-danger)';
|
||||
const label = OIL_CLASS_NAMES[region.classId] || region.className;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={region.classId}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
|
||||
style={{
|
||||
background: `${color}33`,
|
||||
border: `1px solid ${color}80`,
|
||||
@ -120,11 +120,11 @@ const OilDetectionOverlay = memo(
|
||||
})}
|
||||
{/* 합계 */}
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.5)',
|
||||
color: '#f87171',
|
||||
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
||||
@ -135,11 +135,11 @@ const OilDetectionOverlay = memo(
|
||||
{/* 감지 없음 */}
|
||||
{!hasRegions && !isAnalyzing && !error && (
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold font-korean"
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold font-korean"
|
||||
style={{
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
border: '1px solid rgba(34,197,94,0.35)',
|
||||
color: '#4ade80',
|
||||
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
|
||||
color: 'var(--color-success)',
|
||||
}}
|
||||
>
|
||||
감지 없음
|
||||
@ -148,7 +148,7 @@ const OilDetectionOverlay = memo(
|
||||
|
||||
{/* 분석 중 */}
|
||||
{isAnalyzing && (
|
||||
<span className="text-[9px] font-korean text-fg-disabled animate-pulse px-1">
|
||||
<span className="text-caption font-korean text-fg-disabled animate-pulse px-1">
|
||||
분석중...
|
||||
</span>
|
||||
)}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -396,7 +396,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
||||
</div>
|
||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-color-accent/40 rounded-full"
|
||||
className="h-full bg-[rgba(6,182,212,0.4)] rounded-full"
|
||||
style={{ width: '64%', animation: 'pulse 2s infinite' }}
|
||||
/>
|
||||
</div>
|
||||
@ -717,7 +717,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
||||
</div>
|
||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-color-danger/40 rounded-full"
|
||||
className="h-full bg-[rgba(239,68,68,0.4)] rounded-full"
|
||||
style={{ width: '52%', animation: 'pulse 2s infinite' }}
|
||||
/>
|
||||
</div>
|
||||
@ -746,15 +746,15 @@ export function SensorAnalysis() {
|
||||
<div className="w-[280px] bg-bg-surface border-r border-stroke flex flex-col overflow-auto">
|
||||
{/* 3D Reconstruction List */}
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled 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 ${
|
||||
className={`flex-1 py-1.5 text-center text-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'vessel'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
|
||||
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||
}`}
|
||||
>
|
||||
@ -762,9 +762,9 @@ export function SensorAnalysis() {
|
||||
</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 ${
|
||||
className={`flex-1 py-1.5 text-center text-caption font-semibold rounded cursor-pointer border transition-colors font-korean ${
|
||||
subTab === 'pollution'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-color-accent/20'
|
||||
? 'text-color-accent bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
|
||||
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||
}`}
|
||||
>
|
||||
@ -778,20 +778,20 @@ export function SensorAnalysis() {
|
||||
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-color-accent/20'
|
||||
? 'bg-[rgba(6,182,212,0.08)] border-[rgba(6,182,212,0.2)]'
|
||||
: 'border-transparent hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-bold text-fg font-korean">{item.name}</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">
|
||||
<div className="text-caption font-bold text-fg font-korean">{item.name}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{item.id} · {item.points} pts
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[8px] font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
|
||||
className={`text-caption font-semibold ${item.status === 'complete' ? 'text-color-success' : 'text-color-warning'}`}
|
||||
>
|
||||
{item.status === 'complete' ? '✅ 완료' : '⏳ 처리중'}
|
||||
{item.status === 'complete' ? '완료' : '처리중'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -800,15 +800,15 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Source Images */}
|
||||
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-1.5 uppercase tracking-wider">
|
||||
📹 촬영 원본
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{[
|
||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-color-info' },
|
||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-color-danger' },
|
||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-color-tertiary' },
|
||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-color-danger' },
|
||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-fg-sub' },
|
||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-fg-sub' },
|
||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-fg-sub' },
|
||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-fg-sub' },
|
||||
].map((src, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@ -816,13 +816,13 @@ export function SensorAnalysis() {
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #0c1624, #1a1a2e)' }}
|
||||
style={{ background: 'var(--bg-base)' }}
|
||||
>
|
||||
<div className="text-fg-disabled/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-fg-disabled font-korean">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 flex justify-between text-caption text-fg-disabled font-korean">
|
||||
<span>{src.label}</span>
|
||||
<span className={src.color}>{src.sensor}</span>
|
||||
</div>
|
||||
@ -863,7 +863,7 @@ export function SensorAnalysis() {
|
||||
className="absolute bottom-16 left-4"
|
||||
style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
<div style={{ color: '#ef4444' }}>X →</div>
|
||||
<div style={{ color: 'var(--color-danger)' }}>X →</div>
|
||||
<div className="text-green-500">Y ↑</div>
|
||||
<div className="text-blue-500">Z ⊙</div>
|
||||
</div>
|
||||
@ -871,13 +871,13 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Title */}
|
||||
<div className="absolute top-3 left-3 z-[2]">
|
||||
<div className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled uppercase tracking-wider">
|
||||
3D Vessel Analysis
|
||||
</div>
|
||||
<div className="text-[13px] font-bold text-color-accent my-1 font-korean">
|
||||
<div className="text-title-4 font-bold text-color-accent my-1 font-korean">
|
||||
{selectedItem.name} 정밀분석
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}
|
||||
</div>
|
||||
</div>
|
||||
@ -892,10 +892,10 @@ export function SensorAnalysis() {
|
||||
<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 ${
|
||||
className={`px-2.5 py-1.5 text-caption font-semibold rounded-sm cursor-pointer border font-korean transition-colors ${
|
||||
viewMode === m.id
|
||||
? 'bg-[rgba(6,182,212,0.2)] border-color-accent/50 text-color-accent'
|
||||
: 'bg-black/40 border-color-accent/20 text-fg-disabled hover:bg-black/60 hover:border-color-accent/40'
|
||||
? 'bg-[rgba(6,182,212,0.2)] border-[rgba(6,182,212,0.5)] text-color-accent'
|
||||
: 'bg-black/40 border-[rgba(6,182,212,0.2)] text-fg-disabled hover:bg-black/60 hover:border-[rgba(6,182,212,0.4)]'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
@ -906,7 +906,7 @@ export function SensorAnalysis() {
|
||||
{/* 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)' }}
|
||||
style={{ borderColor: 'var(--stroke-default)' }}
|
||||
>
|
||||
{[
|
||||
{ value: selectedItem.points, label: '포인트' },
|
||||
@ -917,7 +917,7 @@ export function SensorAnalysis() {
|
||||
].map((s, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="font-mono font-bold text-sm text-color-accent">{s.value}</div>
|
||||
<div className="text-[8px] text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -927,10 +927,10 @@ export function SensorAnalysis() {
|
||||
<div className="w-[270px] bg-bg-surface border-l border-stroke flex flex-col overflow-auto">
|
||||
{/* Ship/Pollution Info */}
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
📊 분석 정보
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-[10px]">
|
||||
<div className="flex flex-col gap-1.5 text-caption">
|
||||
{(selectedItem.type === 'vessel'
|
||||
? [
|
||||
['대상', selectedItem.name],
|
||||
@ -962,26 +962,26 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* AI Detection Results */}
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
🤖 AI 탐지 결과
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(selectedItem.type === 'vessel'
|
||||
? [
|
||||
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
|
||||
{ label: '선종 분류', confidence: 78, color: 'bg-color-caution' },
|
||||
{ label: '손상 감지', confidence: 45, color: 'bg-color-warning' },
|
||||
{ label: '화물 분석', confidence: 62, color: 'bg-color-caution' },
|
||||
{ label: '선종 분류', confidence: 78, color: 'bg-color-success' },
|
||||
{ label: '손상 감지', confidence: 45, color: 'bg-color-success' },
|
||||
{ label: '화물 분석', confidence: 62, color: 'bg-color-success' },
|
||||
]
|
||||
: [
|
||||
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
|
||||
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
|
||||
{ label: '두께 추정', confidence: 72, color: 'bg-color-caution' },
|
||||
{ label: '확산 예측', confidence: 68, color: 'bg-color-warning' },
|
||||
{ label: '두께 추정', confidence: 72, color: 'bg-color-success' },
|
||||
{ label: '확산 예측', confidence: 68, color: 'bg-color-success' },
|
||||
]
|
||||
).map((r, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-[9px] mb-0.5">
|
||||
<div className="flex justify-between text-caption mb-0.5">
|
||||
<span className="text-fg-disabled font-korean">{r.label}</span>
|
||||
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
|
||||
</div>
|
||||
@ -998,10 +998,10 @@ export function SensorAnalysis() {
|
||||
|
||||
{/* Comparison / Measurements */}
|
||||
<div className="p-2.5 px-3 border-b border-stroke">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-2 uppercase tracking-wider">
|
||||
📐 3D 측정값
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[10px]">
|
||||
<div className="flex flex-col gap-1 text-caption">
|
||||
{(selectedItem.type === 'vessel'
|
||||
? [
|
||||
['전장 (LOA)', '84.7 m'],
|
||||
@ -1020,7 +1020,7 @@ export function SensorAnalysis() {
|
||||
).map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded">
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-color-accent">{v}</span>
|
||||
<span className="font-mono font-semibold text-fg">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -1029,14 +1029,15 @@ export function SensorAnalysis() {
|
||||
{/* 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"
|
||||
className="w-full py-2.5 rounded-sm text-label-2 font-semibold font-korean text-color-accent border cursor-pointer mb-2 transition-colors"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
|
||||
border: '1px solid rgba(6,182,212,.3)',
|
||||
background: 'rgba(6,182,212,.08)',
|
||||
}}
|
||||
>
|
||||
📊 상세 보고서 생성
|
||||
</button>
|
||||
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-[11px] font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||
<button className="w-full py-2 border border-stroke bg-bg-card text-fg-sub rounded-sm text-label-2 font-semibold font-korean cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||
📥 3D 모델 다운로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { typeTagCls } from './assetTypes';
|
||||
import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi';
|
||||
import type { AssetOrgCompat } from '../services/assetsApi';
|
||||
import AssetMap from './AssetMap';
|
||||
@ -96,20 +95,20 @@ function AssetManagement() {
|
||||
<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 ${
|
||||
className={`px-3 py-1.5 text-label-2 font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'list'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
|
||||
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
📋 방제자산리스트
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||||
className={`px-3 py-1.5 text-label-2 font-semibold rounded-sm font-korean transition-colors ${
|
||||
viewMode === 'map'
|
||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
|
||||
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
🗺 지도 보기
|
||||
@ -170,7 +169,7 @@ function AssetManagement() {
|
||||
|
||||
{viewMode === 'list' ? (
|
||||
/* ── LIST VIEW ── */
|
||||
<div className="flex-1 bg-bg-card border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1 border border-stroke rounded-md overflow-hidden flex flex-col">
|
||||
<div className="flex-1">
|
||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
@ -212,7 +211,7 @@ function AssetManagement() {
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
className={`px-2.5 py-2.5 text-[10px] font-bold font-korean border-b border-stroke ${[0, 5, 6, 7, 8, 9, 10].includes(i) ? 'text-center' : ''} ${isHighlight ? 'text-color-accent bg-color-accent/5' : 'text-fg-sub'}`}
|
||||
className={`px-2.5 py-2.5 text-caption font-bold font-korean border-b border-stroke ${[0, 5, 6, 7, 8, 9, 10].includes(i) ? 'text-center' : ''} ${isHighlight ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : 'text-fg-sub'}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
@ -224,59 +223,53 @@ function AssetManagement() {
|
||||
{paged.map((org, idx) => (
|
||||
<tr
|
||||
key={org.id}
|
||||
className={`border-b border-stroke/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||||
selectedOrg?.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
||||
}`}
|
||||
className={`border-b border-stroke hover:bg-white/[0.02] cursor-pointer transition-colors`}
|
||||
onClick={() => {
|
||||
handleSelectOrg(org);
|
||||
setViewMode('map');
|
||||
}}
|
||||
>
|
||||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">
|
||||
<td className="px-2.5 py-2 text-center font-mono text-caption">
|
||||
{(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>
|
||||
<span className="text-caption text-color-accent font-korean">{org.type}</span>
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">
|
||||
<td className="px-2.5 py-2 text-caption font-korean">
|
||||
{regionShort(org.jurisdiction)}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-[10px] font-semibold text-color-accent font-korean cursor-pointer truncate">
|
||||
<td className="px-2.5 py-2 text-caption text-fg-sub font-korean cursor-pointer truncate">
|
||||
{org.name}
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-[10px] text-fg-disabled font-korean truncate">
|
||||
<td className="px-2.5 py-2 text-caption text-fg-disabled font-korean truncate">
|
||||
{org.address}
|
||||
</td>
|
||||
<td
|
||||
className={`px-2.5 py-2 text-center font-mono text-[10px] font-semibold ${equipFilter === 'vessel' ? 'text-color-accent bg-color-accent/5' : ''}`}
|
||||
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'vessel' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
|
||||
>
|
||||
{org.vessel}척
|
||||
</td>
|
||||
<td
|
||||
className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'skimmer' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}
|
||||
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'skimmer' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
|
||||
>
|
||||
{org.skimmer}대
|
||||
</td>
|
||||
<td
|
||||
className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'pump' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}
|
||||
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'pump' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
|
||||
>
|
||||
{org.pump}대
|
||||
</td>
|
||||
<td
|
||||
className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'vehicle' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}
|
||||
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'vehicle' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
|
||||
>
|
||||
{org.vehicle}대
|
||||
</td>
|
||||
<td
|
||||
className={`px-2.5 py-2 text-center font-mono text-[10px] ${equipFilter === 'sprayer' ? 'text-color-accent font-semibold bg-color-accent/5' : ''}`}
|
||||
className={`px-2.5 py-2 text-center font-mono text-caption ${equipFilter === 'sprayer' ? 'text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_5%,transparent)]' : ''}`}
|
||||
>
|
||||
{org.sprayer}대
|
||||
</td>
|
||||
<td className="px-2.5 py-2 text-center font-bold text-color-accent font-mono text-[10px]">
|
||||
<td className="px-2.5 py-2 text-center text-fg-sub font-mono text-caption">
|
||||
{org.totalAssets}
|
||||
</td>
|
||||
</tr>
|
||||
@ -287,7 +280,7 @@ function AssetManagement() {
|
||||
|
||||
{/* Totals Summary */}
|
||||
<div className="flex items-center justify-end gap-4 px-4 py-2 border-t border-stroke bg-bg-base/80">
|
||||
<span className="text-[10px] text-fg-disabled font-korean font-semibold mr-auto">
|
||||
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
|
||||
합계 ({filtered.length}개 기관)
|
||||
</span>
|
||||
{[
|
||||
@ -332,15 +325,15 @@ function AssetManagement() {
|
||||
return (
|
||||
<div
|
||||
key={t.key}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-color-accent/10' : ''}`}
|
||||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${equipFilter === t.key ? 'bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)]' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[9px] font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
className={`text-caption font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
|
||||
className={`text-caption font-mono font-bold ${isActive ? 'text-color-accent' : 'text-fg'}`}
|
||||
>
|
||||
{t.value.toLocaleString()}
|
||||
{t.unit}
|
||||
@ -352,7 +345,7 @@ function AssetManagement() {
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-stroke bg-bg-base">
|
||||
<span className="text-[10px] text-fg-disabled font-korean">
|
||||
<span className="text-caption text-fg-disabled font-korean">
|
||||
전체 <span className="font-semibold text-fg-sub">{filtered.length}</span>건 중{' '}
|
||||
<span className="font-semibold text-fg-sub">
|
||||
{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}
|
||||
@ -362,14 +355,14 @@ function AssetManagement() {
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={safePage <= 1}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-caption rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-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-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-caption rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
@ -377,9 +370,9 @@ function AssetManagement() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setCurrentPage(p)}
|
||||
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
|
||||
className={`w-6 h-6 text-caption font-bold rounded transition-colors cursor-pointer ${
|
||||
p === safePage
|
||||
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]'
|
||||
: 'border border-stroke bg-bg-card text-fg-disabled hover:bg-bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
@ -389,14 +382,14 @@ function AssetManagement() {
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
className="px-1.5 py-1 text-[10px] rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-caption rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-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-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
className="px-1.5 py-1 text-caption rounded border border-stroke bg-bg-card text-fg-sub disabled:opacity-30 hover:bg-bg-surface-hover transition-colors cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
@ -423,10 +416,10 @@ function AssetManagement() {
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-stroke">
|
||||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||||
<div className="text-[11px] text-fg-sub font-semibold font-korean mb-1">
|
||||
<div className="text-label-2 text-fg-sub font-semibold font-korean mb-1">
|
||||
{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">
|
||||
<div className="text-label-2 text-fg-disabled font-korean">
|
||||
{selectedOrg.address}
|
||||
</div>
|
||||
</div>
|
||||
@ -437,7 +430,7 @@ function AssetManagement() {
|
||||
<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 ${
|
||||
className={`flex-1 py-2.5 text-center text-label-2 font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||||
detailTab === t
|
||||
? 'text-color-accent border-color-accent'
|
||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||
@ -449,7 +442,10 @@ function AssetManagement() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-3.5 scrollbar-thin"
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{[
|
||||
@ -462,7 +458,7 @@ function AssetManagement() {
|
||||
className="bg-bg-card border border-stroke rounded-sm p-2.5 text-center"
|
||||
>
|
||||
<div className="text-lg font-bold text-color-accent font-mono">{s.value}</div>
|
||||
<div className="text-[9px] text-fg-disabled mt-0.5 font-korean">
|
||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
@ -498,10 +494,10 @@ function AssetManagement() {
|
||||
key={ci}
|
||||
className="flex items-center justify-between px-2.5 py-2 bg-bg-card border border-stroke rounded-sm hover:bg-bg-surface-hover transition-colors"
|
||||
>
|
||||
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
|
||||
<span className="text-label-2 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-label-2 font-bold font-mono">
|
||||
<span className="text-color-accent">{cat.count}</span>
|
||||
<span className="text-fg-disabled font-normal ml-0.5">{unit}</span>
|
||||
</span>
|
||||
@ -528,7 +524,7 @@ function AssetManagement() {
|
||||
].map(([k, v], i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between px-2.5 py-2 bg-bg-base rounded text-[11px]"
|
||||
className="flex justify-between px-2.5 py-2 bg-bg-base rounded text-label-2"
|
||||
>
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="font-mono font-semibold text-fg">{v}</span>
|
||||
@ -541,7 +537,7 @@ function AssetManagement() {
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 기관 기본 정보 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 font-korean">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-2 font-korean">
|
||||
기관 정보
|
||||
</div>
|
||||
{[
|
||||
@ -553,7 +549,7 @@ function AssetManagement() {
|
||||
].map(([k, v], j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0"
|
||||
className="flex justify-between py-1.5 text-label-2 border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-disabled font-korean shrink-0 mr-2">{k}</span>
|
||||
<span
|
||||
@ -568,7 +564,7 @@ function AssetManagement() {
|
||||
{/* 담당자 목록 */}
|
||||
{selectedOrg.contacts.length > 0 && (
|
||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
||||
<div className="text-[10px] font-bold text-fg-disabled mb-2 font-korean">
|
||||
<div className="text-caption font-bold text-fg-disabled mb-2 font-korean">
|
||||
담당자
|
||||
</div>
|
||||
{selectedOrg.contacts.map((c, i) => (
|
||||
@ -582,7 +578,7 @@ function AssetManagement() {
|
||||
.map(([k, v], j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="flex justify-between py-1.5 text-[11px] border-b border-stroke/30 last:border-b-0"
|
||||
className="flex justify-between py-1.5 text-label-2 border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span
|
||||
@ -604,19 +600,14 @@ function AssetManagement() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="p-3.5 border-t border-stroke 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(--color-accent), var(--color-info))',
|
||||
}}
|
||||
>
|
||||
{/* <div className="p-3.5 border-t border-stroke flex gap-2">
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white bg-color-accent border-none cursor-pointer hover:opacity-90 transition-opacity">
|
||||
📥 다운로드
|
||||
</button>
|
||||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-card border border-stroke text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">
|
||||
✏ 수정
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,45 +1,45 @@
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { ScatterplotLayer } from '@deck.gl/layers'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
import type { AssetOrgCompat } from '../services/assetsApi'
|
||||
import { typeColor } from './assetTypes'
|
||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import type { AssetOrgCompat } from '../services/assetsApi';
|
||||
import { typeColor } from './assetTypes';
|
||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||
overlay.setProps({ layers })
|
||||
return null
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FlyTo Controller ────────────────────────────────────
|
||||
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
||||
const { current: map } = useMap()
|
||||
const prevIdRef = useRef<number | undefined>(undefined)
|
||||
const { current: map } = useMap();
|
||||
const prevIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
if (!map) return;
|
||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
|
||||
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 })
|
||||
map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 });
|
||||
}
|
||||
prevIdRef.current = selectedOrg.id
|
||||
}, [map, selectedOrg])
|
||||
prevIdRef.current = selectedOrg.id;
|
||||
}, [map, selectedOrg]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AssetMapProps {
|
||||
organizations: AssetOrgCompat[]
|
||||
selectedOrg: AssetOrgCompat
|
||||
onSelectOrg: (o: AssetOrgCompat) => void
|
||||
regionFilter: string
|
||||
onRegionFilterChange: (v: string) => void
|
||||
organizations: AssetOrgCompat[];
|
||||
selectedOrg: AssetOrgCompat;
|
||||
onSelectOrg: (o: AssetOrgCompat) => void;
|
||||
regionFilter: string;
|
||||
onRegionFilterChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function AssetMap({
|
||||
@ -49,15 +49,15 @@ function AssetMap({
|
||||
regionFilter,
|
||||
onRegionFilterChange,
|
||||
}: AssetMapProps) {
|
||||
const currentMapStyle = useBaseMapStyle()
|
||||
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(org: AssetOrgCompat) => {
|
||||
onSelectOrg(org)
|
||||
onSelectOrg(org);
|
||||
},
|
||||
[onSelectOrg],
|
||||
)
|
||||
);
|
||||
|
||||
const markerLayer = useMemo(() => {
|
||||
return new ScatterplotLayer({
|
||||
@ -65,19 +65,19 @@ function AssetMap({
|
||||
data: orgs,
|
||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||
getRadius: (d: AssetOrgCompat) => {
|
||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
|
||||
const isSelected = selectedOrg.id === d.id
|
||||
return isSelected ? baseRadius + 4 : baseRadius
|
||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return isSelected ? baseRadius + 4 : baseRadius;
|
||||
},
|
||||
getFillColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type)
|
||||
const isSelected = selectedOrg.id === d.id
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
|
||||
},
|
||||
getLineColor: (d: AssetOrgCompat) => {
|
||||
const tc = typeColor(d.type)
|
||||
const isSelected = selectedOrg.id === d.id
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
|
||||
const tc = typeColor(d.type);
|
||||
const isSelected = selectedOrg.id === d.id;
|
||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
|
||||
},
|
||||
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
||||
stroked: true,
|
||||
@ -86,7 +86,7 @@ function AssetMap({
|
||||
radiusUnits: 'pixels',
|
||||
pickable: true,
|
||||
onClick: (info: { object?: AssetOrgCompat }) => {
|
||||
if (info.object) handleClick(info.object)
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getRadius: [selectedOrg.id],
|
||||
@ -94,8 +94,8 @@ function AssetMap({
|
||||
getLineColor: [selectedOrg.id],
|
||||
getLineWidth: [selectedOrg.id],
|
||||
},
|
||||
})
|
||||
}, [orgs, selectedOrg, handleClick])
|
||||
});
|
||||
}, [orgs, selectedOrg, handleClick]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
@ -119,13 +119,13 @@ function AssetMap({
|
||||
{ value: '중부', label: '중부청' },
|
||||
{ value: '동해', label: '동해청' },
|
||||
{ value: '제주', label: '제주청' },
|
||||
].map(r => (
|
||||
].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 ${
|
||||
className={`px-2.5 py-1.5 text-caption font-bold rounded font-korean transition-colors ${
|
||||
regionFilter === r.value
|
||||
? 'bg-color-accent/20 text-color-accent border border-color-accent/40'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]'
|
||||
: 'bg-bg-base/80 text-fg-sub border border-stroke hover:bg-bg-surface-hover/80'
|
||||
}`}
|
||||
>
|
||||
@ -136,28 +136,31 @@ function AssetMap({
|
||||
|
||||
{/* Legend overlay */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-bg-base/90 border border-stroke rounded-sm p-2.5 backdrop-blur-sm">
|
||||
<div className="text-[9px] text-fg-disabled font-bold mb-1.5 font-korean">범례</div>
|
||||
<div className="text-caption text-fg-disabled 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) => (
|
||||
'해경관할',
|
||||
'해경경찰서',
|
||||
'파출소',
|
||||
'관련기관',
|
||||
'해양환경공단',
|
||||
'업체',
|
||||
'지자체',
|
||||
'기름저장시설',
|
||||
'정유사',
|
||||
'해군',
|
||||
'기타',
|
||||
].map((label, 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-fg-sub font-korean">{item.label}</span>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
|
||||
style={{ background: typeColor(label).border }}
|
||||
/>
|
||||
<span className="text-caption text-fg-sub font-korean">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default AssetMap
|
||||
export default AssetMap;
|
||||
|
||||
@ -2,15 +2,11 @@ interface TheoryItem {
|
||||
title: string;
|
||||
source: string;
|
||||
desc: string;
|
||||
tags?: { label: string; color: string }[];
|
||||
highlight?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface TheorySection {
|
||||
icon: string;
|
||||
title: string;
|
||||
color: string;
|
||||
bgTint: string;
|
||||
items: TheoryItem[];
|
||||
dividerAfter?: number;
|
||||
dividerLabel?: string;
|
||||
@ -18,10 +14,7 @@ interface TheorySection {
|
||||
|
||||
const THEORY_SECTIONS: TheorySection[] = [
|
||||
{
|
||||
icon: '🚢',
|
||||
title: '방제선 성능 기준',
|
||||
color: 'var(--color-info)',
|
||||
bgTint: 'rgba(59,130,246,.08)',
|
||||
items: [
|
||||
{
|
||||
title: '해양경찰청 방제선 성능기준 고시',
|
||||
@ -43,10 +36,7 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🪢',
|
||||
title: '오일펜스·흡착재 규격',
|
||||
color: 'var(--boom, #f59e0b)',
|
||||
bgTint: 'rgba(245,158,11,.08)',
|
||||
items: [
|
||||
{
|
||||
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
|
||||
@ -61,12 +51,9 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: '방제자원 배치·동원 이론',
|
||||
color: 'var(--color-tertiary)',
|
||||
bgTint: 'rgba(168,85,247,.08)',
|
||||
dividerAfter: 2,
|
||||
dividerLabel: '📐 최적화 수리모델 참고문헌',
|
||||
dividerLabel: '최적화 수리모델 참고문헌',
|
||||
items: [
|
||||
{
|
||||
title:
|
||||
@ -74,7 +61,6 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
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',
|
||||
@ -92,54 +78,32 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
source:
|
||||
'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
|
||||
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'MIP 수리모델', color: 'var(--color-tertiary)' },
|
||||
{ label: '자원 위치 선택', color: 'var(--color-info)' },
|
||||
{ label: '북극해 적용', color: 'var(--color-accent)' },
|
||||
],
|
||||
tags: ['MIP 수리모델', '자원 위치 선택', '북극해 적용'],
|
||||
},
|
||||
{
|
||||
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
|
||||
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.11–16, 2020',
|
||||
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
||||
highlight: true,
|
||||
tags: [
|
||||
{ label: 'GA 메타휴리스틱', color: 'var(--color-tertiary)' },
|
||||
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
|
||||
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
tags: ['GA 메타휴리스틱', '국내 연구', '배치 분포도 분석'],
|
||||
},
|
||||
{
|
||||
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(--color-tertiary)' },
|
||||
{ label: '2단계 최적화', color: 'var(--color-info)' },
|
||||
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
|
||||
],
|
||||
tags: ['확률적 MILP', '2단계 최적화', '환경민감구역'],
|
||||
},
|
||||
{
|
||||
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(--color-tertiary)' },
|
||||
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
|
||||
],
|
||||
tags: ['MINLP 동적 최적화', '오일 풍화 모델 통합'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🗄',
|
||||
title: '자산 현행화·데이터 관리',
|
||||
color: 'var(--green, #22c55e)',
|
||||
bgTint: 'rgba(34,197,94,.08)',
|
||||
items: [
|
||||
{
|
||||
title: '해양오염방제자원 현황관리 지침',
|
||||
@ -155,103 +119,31 @@ const THEORY_SECTIONS: TheorySection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const TAG_COLORS: Record<string, { bg: string; bd: string; fg: string }> = {
|
||||
'var(--color-tertiary)': {
|
||||
bg: 'rgba(168,85,247,0.08)',
|
||||
bd: 'rgba(168,85,247,0.2)',
|
||||
fg: '#a855f7',
|
||||
},
|
||||
'var(--color-info)': { bg: 'rgba(59,130,246,0.08)', bd: 'rgba(59,130,246,0.2)', fg: '#3b82f6' },
|
||||
'var(--color-accent)': { 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 className="bg-bg-card border border-stroke rounded-md overflow-hidden">
|
||||
{/* Section Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b border-stroke flex items-center gap-2"
|
||||
style={{ background: section.bgTint }}
|
||||
>
|
||||
<span className="text-sm">{section.icon}</span>
|
||||
<span className="text-xs font-bold" style={{ color: section.color }}>
|
||||
{section.title}
|
||||
</span>
|
||||
<div className="px-4 py-3 border-b border-stroke">
|
||||
<span className="text-label-1 font-semibold text-fg font-korean">{section.title}</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="px-4 py-3.5 flex flex-col gap-2 text-[9px]">
|
||||
<div className="px-4 py-3.5 flex flex-col gap-2 text-caption">
|
||||
{section.items.map((item, i) => (
|
||||
<div key={i}>
|
||||
{/* Divider */}
|
||||
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
||||
<div
|
||||
className="mt-1 mb-3 pt-2"
|
||||
style={{ borderTop: '1px dashed var(--stroke-default)' }}
|
||||
>
|
||||
<div
|
||||
className="text-[8px] font-bold mb-1.5 opacity-70"
|
||||
style={{ color: section.color }}
|
||||
>
|
||||
<div className="mt-1 mb-3 pt-2 border-t border-dashed border-stroke">
|
||||
<div className="text-caption font-semibold text-fg mb-1.5">
|
||||
{section.dividerLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="grid gap-2 px-2.5 py-2 bg-bg-base rounded-md"
|
||||
style={{
|
||||
gridTemplateColumns: '24px 1fr',
|
||||
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Number badge */}
|
||||
<div
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-[9px] shrink-0"
|
||||
style={{
|
||||
background: badgeBg,
|
||||
fontWeight: item.highlight ? 700 : 400,
|
||||
color: item.highlight ? section.color : undefined,
|
||||
}}
|
||||
>
|
||||
{['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'][i]}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold mb-0.5">{item.title}</div>
|
||||
<div className="text-fg-disabled leading-[1.6]">{item.source}</div>
|
||||
{/* Tags */}
|
||||
{item.tags && (
|
||||
<div className="mt-0.5 flex flex-wrap gap-0.5">
|
||||
{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}
|
||||
className="px-1 py-px rounded text-[8px]"
|
||||
style={{
|
||||
color: tc.fg,
|
||||
background: tc.bg,
|
||||
border: `1px solid ${tc.bd}`,
|
||||
}}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-fg-sub">{item.desc}</div>
|
||||
</div>
|
||||
<div className="px-2.5 py-2 bg-bg-base rounded-md">
|
||||
<div className="font-semibold mb-0.5">{item.title}</div>
|
||||
<div className="text-fg-disabled leading-[1.6]">{item.source}</div>
|
||||
{item.tags && <div className="mt-0.5 text-fg-disabled">{item.tags.join(' | ')}</div>}
|
||||
<div className="mt-0.5 text-fg-sub">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -263,8 +155,8 @@ function TheoryCard({ section }: { section: TheorySection }) {
|
||||
function AssetTheory() {
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="text-[18px] font-bold mb-1">📚 방제자원 이론</div>
|
||||
<div className="text-xs text-fg-disabled mb-6">
|
||||
<div className="text-title-1 font-bold mb-1">📚 방제자원 이론</div>
|
||||
<div className="text-caption text-fg-disabled mb-6">
|
||||
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
||||
</div>
|
||||
|
||||
|
||||
@ -22,19 +22,19 @@ function AssetUpload() {
|
||||
<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>
|
||||
<div className="text-title-4 font-bold mb-3.5 font-korean">📤 자산 데이터 업로드</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-color-accent/40 transition-colors">
|
||||
<div className="border-2 border-dashed border-stroke-light rounded-md py-10 px-5 text-center mb-5 cursor-pointer hover:border-[rgba(6,182,212,0.4)] 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-fg-disabled mb-4 font-korean">
|
||||
<div className="text-label-2 text-fg-disabled 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"
|
||||
className="px-7 py-2.5 text-title-4 font-semibold rounded-sm text-white border-none cursor-pointer font-korean"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-info), #2563eb)' }}
|
||||
>
|
||||
파일 선택
|
||||
@ -120,7 +120,7 @@ function AssetUpload() {
|
||||
{/* 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="text-title-4 font-bold mb-3.5 font-korean">🔐 수정 권한 체계</div>
|
||||
<div className="flex flex-col gap-2 mb-7">
|
||||
{[
|
||||
{
|
||||
@ -164,14 +164,14 @@ function AssetUpload() {
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-korean">{p.desc}</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload History */}
|
||||
<div className="text-[13px] font-bold mb-3.5 font-korean">📋 최근 업로드 이력</div>
|
||||
<div className="text-title-4 font-bold mb-3.5 font-korean">📋 최근 업로드 이력</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{uploadHistory.map((h) => (
|
||||
<div
|
||||
@ -180,11 +180,11 @@ function AssetUpload() {
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-semibold font-korean">{h.fileNm}</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5 font-korean">
|
||||
<div className="text-caption text-fg-disabled mt-0.5 font-korean">
|
||||
{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-[rgba(34,197,94,0.15)] text-color-success">
|
||||
<span className="px-2 py-0.5 rounded-full text-caption font-semibold bg-[rgba(34,197,94,0.15)] text-color-success">
|
||||
완료
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -19,10 +19,10 @@ export function AssetsView() {
|
||||
<div className="flex items-center justify-between border-b border-stroke bg-bg-surface shrink-0">
|
||||
<div className="flex">
|
||||
{[
|
||||
{ id: 'management' as const, icon: '🗂', label: '자산 관리' },
|
||||
// { id: 'upload' as const, icon: '📤', label: '자산 현행화 (업로드)' },
|
||||
{ id: 'theory' as const, icon: '📚', label: '방제자원 이론' },
|
||||
{ id: 'insurance' as const, icon: '🛡', label: '선박 보험정보' },
|
||||
{ id: 'management' as const, label: '자산 관리' },
|
||||
// { id: 'upload' as const, label: '자산 현행화 (업로드)' },
|
||||
{ id: 'theory' as const, label: '방제자원 이론' },
|
||||
{ id: 'insurance' as const, label: '선박 보험정보' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@ -33,16 +33,16 @@ export function AssetsView() {
|
||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-[11px] text-color-info font-korean mr-4"
|
||||
style={{ borderColor: 'rgba(59,130,246,0.3)' }}
|
||||
{/* <div
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 border rounded-full text-label-2 text-color-info font-korean mr-4"
|
||||
style={{ borderColor: 'color-mix(in srgb, var(--color-info) 30%, transparent)' }}
|
||||
>
|
||||
👤 남해청_방제과 (수정 권한 ✅)
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@ -60,10 +60,12 @@ function ShipInsurance() {
|
||||
const isY = yn === 'Y';
|
||||
return (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[9px] font-bold"
|
||||
className="px-1.5 py-0.5 rounded text-caption font-bold"
|
||||
style={{
|
||||
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
|
||||
color: isY ? 'var(--color-success)' : 'var(--text-3)',
|
||||
background: isY
|
||||
? 'color-mix(in srgb, var(--color-success) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--stroke-default) 60%, transparent)',
|
||||
color: isY ? 'var(--color-success)' : 'var(--text-fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{isY ? 'Y' : 'N'}
|
||||
@ -131,17 +133,31 @@ function ShipInsurance() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-auto">
|
||||
{/* 푸터 */}
|
||||
<div className="mt-auto px-4 py-3 border border-stroke rounded-sm mb-6">
|
||||
<div className="text-caption text-fg-disabled leading-[1.7]">
|
||||
<span className="text-fg-sub">데이터 출처:</span> 해양수산부 해운항만물류정보 ·
|
||||
유류오염보장계약관리 공공데이터
|
||||
<br />
|
||||
<span className="text-fg-sub">보장항목:</span> 책임보험, 유류오염, 연료유오염,
|
||||
난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<div className="text-[18px] font-bold">유류오염보장계약 관리</div>
|
||||
<div className="text-title-1 font-bold">유류오염보장계약 관리</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-bold"
|
||||
className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-caption font-bold"
|
||||
style={{
|
||||
background: total > 0 ? 'rgba(34,197,94,.12)' : 'rgba(239,68,68,.12)',
|
||||
background:
|
||||
total > 0
|
||||
? 'color-mix(in srgb, var(--color-success) 12%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
|
||||
color: total > 0 ? 'var(--color-success)' : 'var(--color-danger)',
|
||||
border: `1px solid ${total > 0 ? 'rgba(34,197,94,.25)' : 'rgba(239,68,68,.25)'}`,
|
||||
border: `1px solid ${total > 0 ? 'color-mix(in srgb, var(--color-success) 25%, transparent)' : 'color-mix(in srgb, var(--color-danger) 25%, transparent)'}`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@ -158,23 +174,13 @@ function ShipInsurance() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(59,130,246,.12)',
|
||||
color: 'var(--color-info)',
|
||||
border: '1px solid rgba(59,130,246,.3)',
|
||||
}}
|
||||
className="px-4 py-2 text-label-2 font-bold cursor-pointer rounded-sm bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
|
||||
>
|
||||
한국해운조합 API
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
background: 'rgba(168,85,247,.12)',
|
||||
color: 'var(--color-tertiary)',
|
||||
border: '1px solid rgba(168,85,247,.3)',
|
||||
}}
|
||||
className="px-4 py-2 text-label-2 font-bold cursor-pointer rounded-sm bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
|
||||
>
|
||||
PortMIS
|
||||
</button>
|
||||
@ -182,10 +188,10 @@ function ShipInsurance() {
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md px-5 py-4 mb-4">
|
||||
<div className="border border-stroke rounded-md px-5 py-4 mb-4">
|
||||
<div className="flex gap-2.5 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">
|
||||
<label className="block text-caption font-semibold text-fg-disabled mb-1">
|
||||
검색 (선박명/호출부호/IMO/선주)
|
||||
</label>
|
||||
<input
|
||||
@ -198,7 +204,7 @@ function ShipInsurance() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">
|
||||
<label className="block text-caption font-semibold text-fg-disabled mb-1">
|
||||
선박종류
|
||||
</label>
|
||||
<select
|
||||
@ -212,7 +218,7 @@ function ShipInsurance() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-fg-disabled mb-1">
|
||||
<label className="block text-caption font-semibold text-fg-disabled mb-1">
|
||||
발급기관
|
||||
</label>
|
||||
<select
|
||||
@ -237,28 +243,20 @@ function ShipInsurance() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
|
||||
}}
|
||||
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 bg-bg-base text-fg-sub border border-stroke rounded-sm text-xs cursor-pointer"
|
||||
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={total === 0}
|
||||
className="px-4 py-2 text-xs font-bold cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
background: 'rgba(34,197,94,.12)',
|
||||
color: 'var(--color-success)',
|
||||
border: '1px solid rgba(34,197,94,.3)',
|
||||
}}
|
||||
className="px-4 py-2 text-xs cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
|
||||
>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
@ -276,7 +274,7 @@ function ShipInsurance() {
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<div className="text-[13px] text-fg-sub">보험 데이터 조회 중...</div>
|
||||
<div className="text-title-4 text-fg-sub">보험 데이터 조회 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -291,7 +289,7 @@ function ShipInsurance() {
|
||||
{/* 테이블 */}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden mb-3">
|
||||
<div className="border border-stroke rounded-md overflow-hidden mb-3">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||
<div className="text-xs font-bold">
|
||||
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
||||
@ -303,7 +301,7 @@ function ShipInsurance() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[11px] border-collapse whitespace-nowrap">
|
||||
<table className="w-full text-label-2 border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-bg-base">
|
||||
{[
|
||||
@ -342,18 +340,22 @@ function ShipInsurance() {
|
||||
<tr
|
||||
key={r.insSn}
|
||||
className="border-b border-stroke"
|
||||
style={{ background: isExp ? 'rgba(239,68,68,.03)' : undefined }}
|
||||
style={{
|
||||
background: isExp
|
||||
? 'color-mix(in srgb, var(--color-danger) 3%, transparent)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<td className="px-3 py-2 text-center text-fg-disabled font-mono">
|
||||
{rowNum}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-semibold">{r.shipNm}</td>
|
||||
<td className="px-3 py-2">{r.shipNm}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.callSign || '—'}</td>
|
||||
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className="text-[10px]">{r.shipTp}</span>
|
||||
<span className="text-caption">{r.shipTp}</span>
|
||||
{r.shipTpDetail && (
|
||||
<span className="text-fg-disabled text-[9px] ml-1">
|
||||
<span className="text-fg-disabled text-caption ml-1">
|
||||
({r.shipTpDetail})
|
||||
</span>
|
||||
)}
|
||||
@ -368,28 +370,27 @@ function ShipInsurance() {
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.fuelOilYn)}</td>
|
||||
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
||||
<td
|
||||
className="px-3 py-2 text-center font-mono text-[10px]"
|
||||
className="px-3 py-2 text-center font-mono text-caption"
|
||||
style={{
|
||||
color: isExp
|
||||
? 'var(--color-danger)'
|
||||
: isSoon
|
||||
? 'var(--color-caution)'
|
||||
: undefined,
|
||||
fontWeight: isExp || isSoon ? 700 : undefined,
|
||||
}}
|
||||
>
|
||||
{r.validStart} ~ {r.validEnd}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center text-[10px]">{r.issueOrg}</td>
|
||||
<td className="px-3 py-2 text-center text-caption">{r.issueOrg}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[9px] font-semibold"
|
||||
className="px-2 py-0.5 rounded-full text-caption font-semibold"
|
||||
style={{
|
||||
background: isExp
|
||||
? 'rgba(239,68,68,.15)'
|
||||
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
|
||||
: isSoon
|
||||
? 'rgba(234,179,8,.15)'
|
||||
: 'rgba(34,197,94,.15)',
|
||||
? 'color-mix(in srgb, var(--color-caution) 15%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
|
||||
color: isExp
|
||||
? 'var(--color-danger)'
|
||||
: isSoon
|
||||
@ -414,7 +415,7 @@ function ShipInsurance() {
|
||||
<button
|
||||
onClick={() => loadData(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
className="px-3 py-1.5 text-label-2 rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -426,13 +427,7 @@ function ShipInsurance() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => loadData(p)}
|
||||
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
|
||||
style={{
|
||||
background: p === page ? 'var(--color-accent)' : 'var(--bg-0)',
|
||||
color: p === page ? '#fff' : 'var(--text-2)',
|
||||
borderColor: p === page ? 'var(--color-accent)' : 'var(--stroke-default)',
|
||||
fontWeight: p === page ? 700 : 400,
|
||||
}}
|
||||
className={`w-8 h-8 text-label-2 rounded-sm border cursor-pointer font-mono ${p === page ? 'bg-color-accent text-white border-color-accent font-bold' : 'bg-bg-base text-fg-sub border-stroke'}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
@ -441,7 +436,7 @@ function ShipInsurance() {
|
||||
<button
|
||||
onClick={() => loadData(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-[11px] rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
className="px-3 py-1.5 text-label-2 rounded-sm border border-stroke bg-bg-base cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
@ -449,17 +444,6 @@ function ShipInsurance() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="mt-auto px-4 py-3 bg-bg-card border border-stroke rounded-sm">
|
||||
<div className="text-[10px] text-fg-disabled leading-[1.7]">
|
||||
<span className="text-fg-sub font-bold">데이터 출처:</span> 해양수산부 해운항만물류정보 ·
|
||||
유류오염보장계약관리 공공데이터
|
||||
<br />
|
||||
<span className="text-fg-sub font-bold">보장항목:</span> 책임보험, 유류오염, 연료유오염,
|
||||
난파물제거비용, 선원손해, 여객손해, 선체손해, 부두손상
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -34,18 +34,19 @@ export interface InsuranceRow {
|
||||
}
|
||||
|
||||
export const typeTagCls = (type: string) => {
|
||||
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-color-danger';
|
||||
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-color-info';
|
||||
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-color-success';
|
||||
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-color-tertiary';
|
||||
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-color-accent';
|
||||
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-color-warning';
|
||||
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]';
|
||||
if (type === '해경관할')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger';
|
||||
if (type === '해경경찰서')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)] text-color-info';
|
||||
if (type === '파출소')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] text-color-success';
|
||||
if (type === '관련기관')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-tertiary)_10%,transparent)] text-color-tertiary';
|
||||
if (type === '해양환경공단')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent';
|
||||
if (type === '업체')
|
||||
return 'bg-[color-mix(in_srgb,var(--color-warning)_10%,transparent)] text-color-warning';
|
||||
return 'bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)] text-color-info';
|
||||
};
|
||||
|
||||
export const typeColor = (type: string) => {
|
||||
|
||||
@ -10,12 +10,12 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
MANUAL: '해경매뉴얼',
|
||||
};
|
||||
|
||||
// 카테고리별 배지 색상
|
||||
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지는 중립)
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-green-500/20 text-green-400',
|
||||
QNA: 'bg-purple-500/20 text-purple-400',
|
||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
||||
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
|
||||
DATA: 'bg-bg-elevated text-fg-sub',
|
||||
QNA: 'bg-bg-elevated text-fg-sub',
|
||||
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||
};
|
||||
|
||||
interface BoardDetailViewProps {
|
||||
@ -55,7 +55,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
if (isLoading || !post) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
||||
<p className="text-fg-disabled text-sm">게시글을 불러오는 중...</p>
|
||||
<p className="text-fg-disabled text-label-1">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -69,7 +69,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm font-semibold text-fg-sub hover:text-fg transition-colors"
|
||||
className="flex items-center gap-2 text-label-1 font-semibold text-fg-sub hover:text-fg transition-colors"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>목록으로</span>
|
||||
@ -78,13 +78,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card transition-colors"
|
||||
className="px-4 py-2 text-label-1 font-semibold rounded text-fg bg-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] cursor-pointer"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
className="px-4 py-2 text-label-1 font-semibold rounded text-fg bg-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] border border-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] cursor-pointer"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
@ -99,20 +99,20 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="pb-6 border-b border-stroke">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-bg-elevated text-fg-sub'}`}
|
||||
>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent">
|
||||
📌 고정
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-fg mb-4">{post.title}</h1>
|
||||
<div className="flex items-center gap-4 text-sm text-fg-disabled">
|
||||
<h1 className="text-title-1 font-bold text-fg mb-4">{post.title}</h1>
|
||||
<div className="flex items-center gap-4 text-label-1 text-fg-disabled">
|
||||
<span>
|
||||
작성자: <span className="text-fg-sub font-semibold">{post.authorName}</span>
|
||||
작성자: <span className="text-fg-sub">{post.authorName}</span>
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>작성일: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
@ -130,7 +130,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
{/* 본문 */}
|
||||
<div className="py-8">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-fg text-[15px] leading-relaxed whitespace-pre-wrap">
|
||||
<div className="text-fg text-subtitle leading-relaxed whitespace-pre-wrap">
|
||||
{post.content || '(내용 없음)'}
|
||||
</div>
|
||||
</div>
|
||||
@ -139,7 +139,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
{/* 댓글 섹션 (향후 구현 예정) */}
|
||||
<div className="py-6 border-t border-stroke">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-fg-disabled text-sm">댓글 기능은 향후 업데이트 예정입니다.</p>
|
||||
<p className="text-fg-disabled text-label-1">댓글 기능은 향후 업데이트 예정입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,10 +18,10 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||
];
|
||||
|
||||
const CATEGORY_STYLE: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-blue-500/20 text-blue-400',
|
||||
QNA: 'bg-green-500/20 text-green-400',
|
||||
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
||||
NOTICE: 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent',
|
||||
DATA: 'bg-bg-elevated text-fg-sub',
|
||||
QNA: 'bg-bg-elevated text-fg-sub',
|
||||
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@ -104,9 +104,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
key={cat.label}
|
||||
onClick={() => handleCategoryChange(cat.code)}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded transition-all ${
|
||||
className={`px-4 py-2 text-label-1 font-semibold rounded transition-all ${
|
||||
selectedCategory === cat.code
|
||||
? 'bg-color-accent text-bg-0'
|
||||
? 'bg-color-accent text-white'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
@ -123,13 +123,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
className="px-4 py-2 text-label-1 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
/>
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={onWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2"
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] flex items-center gap-2"
|
||||
>
|
||||
<span>+</span>
|
||||
<span>글쓰기</span>
|
||||
@ -142,27 +142,29 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-fg-disabled text-sm">불러오는 중...</p>
|
||||
<p className="text-fg-disabled text-label-1">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-stroke">
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-20">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-20">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-32">
|
||||
분류
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub">
|
||||
제목
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-32">
|
||||
작성자
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-32">
|
||||
작성일
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-24">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-24">
|
||||
조회수
|
||||
</th>
|
||||
</tr>
|
||||
@ -174,9 +176,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
onClick={() => onPostClick(post.sn)}
|
||||
className="border-b border-stroke hover:bg-bg-elevated cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-fg">
|
||||
<td className="px-4 py-4 text-label-1 text-fg">
|
||||
{post.pinnedYn === 'Y' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-caption font-semibold bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent">
|
||||
공지
|
||||
</span>
|
||||
) : (
|
||||
@ -185,27 +187,23 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-bg-elevated text-fg-sub'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'
|
||||
} hover:text-color-accent transition-colors`}
|
||||
>
|
||||
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-sub">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled">
|
||||
<td className="px-4 py-4 text-label-1 text-fg-sub">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-label-1 text-fg-disabled">
|
||||
{formatDate(post.regDtm)}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled">{post.viewCnt}</td>
|
||||
<td className="px-4 py-4 text-label-1 text-fg-disabled">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -213,7 +211,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-fg-disabled text-sm">검색 결과가 없습니다.</p>
|
||||
<p className="text-fg-disabled text-label-1">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -226,7 +224,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-label-1 rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
@ -234,9 +232,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
className={`px-3 py-1.5 text-label-1 rounded ${
|
||||
page === p
|
||||
? 'bg-color-accent text-bg-0 font-semibold'
|
||||
? 'bg-color-accent text-white font-semibold'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors'
|
||||
}`}
|
||||
>
|
||||
@ -246,7 +244,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
className="px-3 py-1.5 text-label-1 rounded bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg transition-colors disabled:opacity-40"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
|
||||
@ -33,12 +33,12 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
MANUAL: '해경매뉴얼',
|
||||
};
|
||||
|
||||
// 카테고리별 배지 색상
|
||||
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지 중립)
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
NOTICE: 'bg-red-500/20 text-red-400',
|
||||
DATA: 'bg-green-500/20 text-green-400',
|
||||
QNA: 'bg-purple-500/20 text-purple-400',
|
||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
||||
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
|
||||
DATA: 'bg-bg-elevated text-fg-sub',
|
||||
QNA: 'bg-bg-elevated text-fg-sub',
|
||||
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@ -91,6 +91,13 @@ export function BoardView() {
|
||||
setPage(1);
|
||||
}, [activeSubTab]);
|
||||
|
||||
// 서브탭 변경 시 목록 화면으로 복귀
|
||||
useEffect(() => {
|
||||
setViewMode('list');
|
||||
setSelectedPostSn(null);
|
||||
setEditingPostSn(null);
|
||||
}, [activeSubTab]);
|
||||
|
||||
// 상세 보기
|
||||
const handlePostClick = (sn: number) => {
|
||||
setSelectedPostSn(sn);
|
||||
@ -196,19 +203,18 @@ export function BoardView() {
|
||||
|
||||
const filteredManuals = manualList;
|
||||
|
||||
// 카테고리별 색상 (방제매뉴얼만 accent, 나머지 중립)
|
||||
const catColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
case '방제매뉴얼':
|
||||
return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' };
|
||||
case '대응매뉴얼':
|
||||
return { bg: 'rgba(249,115,22,.15)', text: '#f97316' };
|
||||
case '교육자료':
|
||||
return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' };
|
||||
case '법령·규정':
|
||||
return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' };
|
||||
default:
|
||||
return { bg: 'rgba(100,100,100,.15)', text: '#999' };
|
||||
if (cat === '방제매뉴얼') {
|
||||
return {
|
||||
bg: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
||||
text: 'var(--color-accent)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'var(--bg-elevated)',
|
||||
text: 'var(--fg-sub)',
|
||||
};
|
||||
};
|
||||
|
||||
if (activeSubTab === 'manual') {
|
||||
@ -217,38 +223,31 @@ export function BoardView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-8 py-4 border-b"
|
||||
style={{ borderColor: 'var(--stroke-default)', background: 'var(--bg-surface)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📘</span>
|
||||
<span className="text-[15px] font-bold">해경매뉴얼</span>
|
||||
<span className="text-[10px] ml-1 text-fg-disabled">
|
||||
<span className="text-subtitle font-bold">해경매뉴얼</span>
|
||||
<span className="text-caption ml-1 text-fg-disabled">
|
||||
총 {filteredManuals.length}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-4">
|
||||
{manualCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setManualCategory(cat)}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
|
||||
style={{
|
||||
background:
|
||||
manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg-card)',
|
||||
border:
|
||||
manualCategory === cat
|
||||
? '1px solid rgba(6,182,212,.3)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
color:
|
||||
manualCategory === cat ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
{manualCategories.map((cat) => {
|
||||
const isActive = manualCategory === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setManualCategory(cat)}
|
||||
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all border ${
|
||||
isActive
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
|
||||
: 'bg-bg-card border-stroke text-fg-disabled'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -257,24 +256,13 @@ export function BoardView() {
|
||||
placeholder="매뉴얼 검색..."
|
||||
value={manualSearch}
|
||||
onChange={(e) => setManualSearch(e.target.value)}
|
||||
className="px-4 py-2 text-sm rounded w-64"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
outline: 'none',
|
||||
}}
|
||||
className="px-4 py-2 text-label-1 rounded w-64 bg-bg-elevated border border-stroke outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.15)',
|
||||
border: '1px solid rgba(6,182,212,.3)',
|
||||
color: '#22d3ee',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
|
||||
>
|
||||
📤 새로 업로드
|
||||
새로 업로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -283,7 +271,7 @@ export function BoardView() {
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{manualLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-sm text-fg-disabled">로딩 중...</p>
|
||||
<p className="text-label-1 text-fg-disabled">로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@ -295,52 +283,29 @@ export function BoardView() {
|
||||
return (
|
||||
<div
|
||||
key={file.manualSn}
|
||||
className="rounded-xl p-4 transition-all"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
'var(--stroke-default)';
|
||||
}}
|
||||
className="rounded-xl p-4 transition-all bg-bg-card border border-stroke cursor-pointer hover:border-[color-mix(in_srgb,var(--color-accent)_40%,transparent)]"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold"
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold"
|
||||
style={{ background: cc.bg, color: cc.text }}
|
||||
>
|
||||
{file.catgNm}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded"
|
||||
style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}
|
||||
>
|
||||
<span className="text-caption font-semibold px-2 py-0.5 rounded bg-bg-elevated text-fg-sub">
|
||||
{file.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[12px] font-bold mb-3 leading-[1.5]">{file.title}</div>
|
||||
<div className="text-label-1 font-bold mb-3 leading-[1.5]">
|
||||
{file.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded"
|
||||
style={{ background: 'rgba(239,68,68,.08)' }}
|
||||
>
|
||||
<span style={{ fontSize: 12 }}>📄</span>
|
||||
<span
|
||||
className="text-[10px] font-semibold"
|
||||
style={{ color: '#ef4444' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-bg-elevated">
|
||||
<span className="text-caption font-semibold text-fg-sub">
|
||||
{file.fileTp || 'PDF'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
<span className="text-caption text-fg-disabled font-mono">
|
||||
{file.fileSz}
|
||||
</span>
|
||||
</div>
|
||||
@ -358,16 +323,10 @@ export function BoardView() {
|
||||
});
|
||||
setShowUploadModal(true);
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{
|
||||
background: 'rgba(59,130,246,.1)',
|
||||
border: '1px solid rgba(59,130,246,.2)',
|
||||
color: '#3b82f6',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold transition-all bg-bg-elevated border border-stroke text-fg-sub cursor-pointer hover:bg-bg-card"
|
||||
title="수정"
|
||||
>
|
||||
✏️ 수정
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
@ -384,34 +343,19 @@ export function BoardView() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{
|
||||
background: 'rgba(239,68,68,.1)',
|
||||
border: '1px solid rgba(239,68,68,.2)',
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold transition-all bg-bg-elevated border border-stroke text-fg-sub cursor-pointer hover:bg-bg-card"
|
||||
title="삭제"
|
||||
>
|
||||
🗑️ 삭제
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between pt-3"
|
||||
style={{ borderTop: '1px solid var(--stroke-default)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-[10px] text-fg-disabled">
|
||||
<div className="flex items-center justify-between pt-3 border-t border-stroke">
|
||||
<div className="flex items-center gap-3 text-caption text-fg-disabled">
|
||||
<span>{file.authorNm}</span>
|
||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{
|
||||
color: 'var(--fg-disabled)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<span className="text-caption text-fg-disabled font-mono">
|
||||
⬇ {file.dwnldCnt}
|
||||
</span>
|
||||
<button
|
||||
@ -457,15 +401,9 @@ export function BoardView() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="px-3 py-1 rounded text-[10px] font-semibold transition-all"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.1)',
|
||||
border: '1px solid rgba(6,182,212,.25)',
|
||||
color: '#22d3ee',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="px-3 py-1 rounded text-caption font-semibold transition-all cursor-pointer bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_25%,transparent)] text-color-accent"
|
||||
>
|
||||
📥 다운로드
|
||||
다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -477,8 +415,7 @@ export function BoardView() {
|
||||
|
||||
{!manualLoading && filteredManuals.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
|
||||
<p className="text-sm text-fg-disabled">검색 결과가 없습니다.</p>
|
||||
<p className="text-label-1 text-fg-disabled">검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -487,8 +424,7 @@ export function BoardView() {
|
||||
{/* 업로드 모달 */}
|
||||
{showUploadModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
style={{ background: 'rgba(0,0,0,.55)' }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[rgba(0,0,0,.55)]"
|
||||
onClick={() => {
|
||||
setShowUploadModal(false);
|
||||
setEditingManualId(null);
|
||||
@ -500,8 +436,7 @@ export function BoardView() {
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-stroke flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{editingManualId ? '✏️' : '📤'}</span>
|
||||
<span className="text-sm font-bold">
|
||||
<span className="text-label-1 font-bold">
|
||||
{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}
|
||||
</span>
|
||||
</div>
|
||||
@ -510,32 +445,28 @@ export function BoardView() {
|
||||
setShowUploadModal(false);
|
||||
setEditingManualId(null);
|
||||
}}
|
||||
className="cursor-pointer text-fg-disabled text-base leading-none"
|
||||
className="cursor-pointer text-fg-disabled text-label-1 leading-none"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
|
||||
카테고리
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map((cat) => {
|
||||
const cc = catColor(cat);
|
||||
const isActive = uploadForm.category === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setUploadForm((prev) => ({ ...prev, category: cat }))}
|
||||
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? cc.bg : 'var(--bg-card)',
|
||||
border: isActive
|
||||
? `1px solid ${cc.text}40`
|
||||
: '1px solid var(--stroke-default)',
|
||||
color: isActive ? cc.text : 'var(--fg-disabled)',
|
||||
}}
|
||||
className={`flex-1 py-2 px-1 rounded-md text-label-2 font-semibold cursor-pointer border ${
|
||||
isActive
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
|
||||
: 'bg-bg-card border-stroke text-fg-disabled'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
@ -544,7 +475,7 @@ export function BoardView() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
|
||||
매뉴얼 제목
|
||||
</label>
|
||||
<input
|
||||
@ -554,12 +485,11 @@ export function BoardView() {
|
||||
onChange={(e) =>
|
||||
setUploadForm((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none"
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
|
||||
버전
|
||||
</label>
|
||||
<input
|
||||
@ -569,12 +499,11 @@ export function BoardView() {
|
||||
onChange={(e) =>
|
||||
setUploadForm((prev) => ({ ...prev, version: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-elevated border border-stroke outline-none"
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-fg-sub mb-1.5">
|
||||
<label className="block text-label-2 font-semibold text-fg-sub mb-1.5">
|
||||
파일 첨부
|
||||
</label>
|
||||
<div
|
||||
@ -599,10 +528,9 @@ export function BoardView() {
|
||||
>
|
||||
{uploadForm.fileName ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-xl">📄</span>
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">{uploadForm.fileName}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">
|
||||
<div className="text-label-1 font-semibold">{uploadForm.fileName}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{uploadForm.fileSize}
|
||||
</div>
|
||||
</div>
|
||||
@ -611,18 +539,17 @@ export function BoardView() {
|
||||
e.stopPropagation();
|
||||
setUploadForm((prev) => ({ ...prev, fileName: '', fileSize: '' }));
|
||||
}}
|
||||
className="text-xs text-fg-disabled cursor-pointer ml-2"
|
||||
className="text-label-1 text-fg-disabled cursor-pointer ml-2"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
|
||||
<div className="text-[11px] text-fg-disabled">
|
||||
<div className="text-label-2 text-fg-disabled">
|
||||
클릭하여 파일을 선택하세요
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono mt-1">
|
||||
<div className="text-caption text-fg-disabled font-mono mt-1">
|
||||
PDF, DOC, HWP, XLSX (최대 100MB)
|
||||
</div>
|
||||
</>
|
||||
@ -636,7 +563,7 @@ export function BoardView() {
|
||||
setShowUploadModal(false);
|
||||
setEditingManualId(null);
|
||||
}}
|
||||
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer"
|
||||
className="px-5 py-2 rounded-md text-label-1 font-semibold bg-bg-card border border-stroke text-fg-disabled cursor-pointer"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -685,14 +612,9 @@ export function BoardView() {
|
||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.2)',
|
||||
border: '1px solid rgba(6,182,212,.35)',
|
||||
color: '#22d3ee',
|
||||
}}
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
|
||||
>
|
||||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
||||
{editingManualId ? '수정' : '업로드'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -748,7 +670,7 @@ export function BoardView() {
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<div className="text-sm text-fg-disabled">
|
||||
<div className="text-label-1 text-fg-disabled">
|
||||
총 <span className="text-fg font-semibold">{totalCount}</span>건
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@ -758,14 +680,14 @@ export function BoardView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
className="px-4 py-2 text-label-1 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none w-64"
|
||||
/>
|
||||
{hasPermission(getWriteResource(), 'CREATE') && (
|
||||
<button
|
||||
onClick={handleWriteClick}
|
||||
className="px-6 py-2 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity"
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-4 py-2 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]"
|
||||
>
|
||||
✏️ 글쓰기
|
||||
글쓰기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -775,29 +697,29 @@ export function BoardView() {
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-fg-disabled text-sm">로딩 중...</p>
|
||||
<p className="text-fg-disabled text-label-1">로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b-2 border-stroke">
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-16">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-24">
|
||||
분류
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub">
|
||||
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub">
|
||||
제목
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-24">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-24">
|
||||
작성자
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-28">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-28">
|
||||
작성일
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-fg-sub w-16">
|
||||
<th className="px-4 py-3 text-center text-label-1 font-semibold text-fg-sub w-16">
|
||||
조회
|
||||
</th>
|
||||
</tr>
|
||||
@ -808,10 +730,10 @@ export function BoardView() {
|
||||
key={post.sn}
|
||||
className="border-b border-stroke hover:bg-bg-elevated transition-colors"
|
||||
>
|
||||
<td className="px-4 py-4 text-sm text-fg text-center">{post.sn}</td>
|
||||
<td className="px-4 py-4 text-label-1 text-fg text-center">{post.sn}</td>
|
||||
<td className="px-4 py-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-caption font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-bg-elevated text-fg-sub'}`}
|
||||
>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
@ -820,20 +742,18 @@ export function BoardView() {
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
onClick={() => handlePostClick(post.sn)}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'} hover:text-color-accent transition-colors`}
|
||||
>
|
||||
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
|
||||
{post.pinnedYn === 'Y' && '📌 '}
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-sub text-center">
|
||||
<td className="px-4 py-4 text-label-1 text-fg-sub text-center">
|
||||
{post.authorName}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
|
||||
<td className="px-4 py-4 text-label-1 text-fg-disabled text-center">
|
||||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-fg-disabled text-center">
|
||||
<td className="px-4 py-4 text-label-1 text-fg-disabled text-center">
|
||||
{post.viewCnt}
|
||||
</td>
|
||||
</tr>
|
||||
@ -843,7 +763,7 @@ export function BoardView() {
|
||||
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-fg-disabled text-sm">게시글이 없습니다.</p>
|
||||
<p className="text-fg-disabled text-label-1">게시글이 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@ -857,9 +777,9 @@ export function BoardView() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
className={`px-3 py-1.5 text-label-1 rounded transition-colors ${
|
||||
p === page
|
||||
? 'bg-color-accent/20 text-color-accent font-semibold'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-accent)_20%,transparent)] text-color-accent font-semibold'
|
||||
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -126,34 +126,50 @@ export function BoardWriteForm({
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
||||
<p className="text-fg-disabled text-sm">게시글을 불러오는 중...</p>
|
||||
<p className="text-fg-disabled text-label-1">게시글을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-base">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-bg-base">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||
<h2 className="text-lg font-semibold text-fg">
|
||||
<h2 className="text-title-3 font-bold text-fg">
|
||||
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-fg-sub px-4 py-2 bg-bg-elevated border border-stroke"
|
||||
>
|
||||
돌아가기
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="rounded-sm text-label-1 font-semibold cursor-pointer text-color-accent px-4 py-2 bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* 분류 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
분류 <span className="text-red-500">*</span>
|
||||
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||
분류 <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={categoryCd}
|
||||
onChange={(e) => setCategoryCd(e.target.value)}
|
||||
disabled={isEditMode}
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none disabled:opacity-50"
|
||||
className="w-full px-4 py-2.5 text-label-1 bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<option key={opt.code} value={opt.code}>
|
||||
@ -165,8 +181,8 @@ export function BoardWriteForm({
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||
제목 <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -174,14 +190,14 @@ export function BoardWriteForm({
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="제목을 입력하세요"
|
||||
className="w-full px-4 py-2.5 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||
className="w-full px-4 py-2.5 text-label-1 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
||||
내용 <span className="text-red-500">*</span>
|
||||
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||
내용 <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
@ -189,13 +205,13 @@ export function BoardWriteForm({
|
||||
maxLength={10000}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={15}
|
||||
className="w-full px-4 py-3 text-sm bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none resize-none"
|
||||
className="w-full px-4 py-3 text-label-1 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 첨부 (향후 API 연동 예정) */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg-sub mb-2">첨부파일</label>
|
||||
<label className="block text-label-1 font-semibold text-fg-sub mb-2">첨부파일</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
@ -207,34 +223,16 @@ export function BoardWriteForm({
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card cursor-pointer transition-colors"
|
||||
className="px-4 py-2 text-label-1 font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card cursor-pointer transition-colors"
|
||||
>
|
||||
파일 선택
|
||||
</label>
|
||||
<span className="text-sm text-fg-disabled">선택된 파일 없음</span>
|
||||
<span className="text-label-1 text-fg-disabled">선택된 파일 없음</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-3 px-8 py-4 border-t border-stroke bg-bg-surface">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-elevated text-fg border border-stroke hover:bg-bg-card transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-6 py-2.5 text-sm font-semibold rounded bg-color-accent text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -548,7 +548,7 @@ ${styles}
|
||||
SEBC 해양 거동 분류 체계
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent py-[2px] px-2 rounded-md"
|
||||
className="text-caption font-semibold text-color-accent py-[2px] px-2 rounded-md"
|
||||
style={{ background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
Standard European Behaviour Classification
|
||||
@ -585,11 +585,11 @@ ${styles}
|
||||
</div>
|
||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">G</div>
|
||||
<div className="text-label-2 font-bold my-1">Gas</div>
|
||||
<div className="text-[8px] text-fg-sub leading-normal">
|
||||
<div className="text-caption text-fg-sub leading-normal">
|
||||
기체 상태로 대기 중 확산. 증기압이 높아 빠르게 증발
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
|
||||
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||
>
|
||||
대기확산 모델 적용
|
||||
@ -616,11 +616,11 @@ ${styles}
|
||||
</div>
|
||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">E</div>
|
||||
<div className="text-label-2 font-bold my-1">Evaporator</div>
|
||||
<div className="text-[8px] text-fg-sub leading-normal">
|
||||
<div className="text-caption text-fg-sub leading-normal">
|
||||
해수면에서 증발. 부유 후 기화하여 독성 가스 생성
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
|
||||
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||
>
|
||||
대기+해양 복합 대응
|
||||
@ -647,11 +647,11 @@ ${styles}
|
||||
</div>
|
||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">F</div>
|
||||
<div className="text-label-2 font-bold my-1">Floater</div>
|
||||
<div className="text-[8px] text-fg-sub leading-normal">
|
||||
<div className="text-caption text-fg-sub leading-normal">
|
||||
{'해수면 위에 부유. 비중 < 1.0, 불용성 물질'}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
|
||||
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||
>
|
||||
오일펜스 유사 봉쇄
|
||||
@ -678,11 +678,11 @@ ${styles}
|
||||
</div>
|
||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">D</div>
|
||||
<div className="text-label-2 font-bold my-1">Dissolver</div>
|
||||
<div className="text-[8px] text-fg-sub leading-normal">
|
||||
<div className="text-caption text-fg-sub leading-normal">
|
||||
해수에 용해. 수중 확산하여 넓은 범위 오염
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
|
||||
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||
>
|
||||
해양확산 모델 적용
|
||||
@ -709,11 +709,11 @@ ${styles}
|
||||
</div>
|
||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">S</div>
|
||||
<div className="text-label-2 font-bold my-1">Sinker</div>
|
||||
<div className="text-[8px] text-fg-sub leading-normal">
|
||||
<div className="text-caption text-fg-sub leading-normal">
|
||||
{'해저로 침강. 비중 > 1.0, 저층 오염 축적'}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 text-[7px] font-semibold text-color-accent p-[3px]"
|
||||
className="mt-1.5 text-caption font-semibold text-color-accent p-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||
>
|
||||
저층 3D 모니터링 필수
|
||||
@ -724,7 +724,7 @@ ${styles}
|
||||
<div className="rounded-md p-3 border border-stroke bg-bg-card">
|
||||
<div className="text-label-2 font-bold mb-2">🔀 복합 거동 유형</div>
|
||||
<div
|
||||
className="grid text-center text-[8px]"
|
||||
className="grid text-center text-caption"
|
||||
style={{ gridTemplateColumns: 'repeat(5,1fr)', gap: 6 }}
|
||||
>
|
||||
<div className="rounded p-1.5 bg-bg-base">
|
||||
@ -799,20 +799,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
G/GD
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
독성
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">7664-41-7</span>
|
||||
@ -839,7 +839,7 @@ ${styles}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="grid text-center text-[7px]"
|
||||
className="grid text-center text-caption"
|
||||
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
||||
>
|
||||
<div
|
||||
@ -879,7 +879,7 @@ ${styles}
|
||||
<b>300 ppm</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
해상 유출 시 급속 기화 → 독성 가스운 형성. 물에 잘 용해되어 수중 독성도 높음.
|
||||
해풍 환경에서 확산 범위 확대.
|
||||
</div>
|
||||
@ -898,20 +898,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
ED
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
인화성
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">67-56-1</span>
|
||||
@ -938,7 +938,7 @@ ${styles}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="grid text-center text-[7px]"
|
||||
className="grid text-center text-caption"
|
||||
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
||||
>
|
||||
<div
|
||||
@ -978,7 +978,7 @@ ${styles}
|
||||
<b>6,000 ppm</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
해수에 완전 용해 → 수질 오염 장기화. 인화점 낮아 화재 위험. 증발 시 독성 증기
|
||||
발생. 2007 온산항 FODDANGER호 95만L 유출 사고.
|
||||
</div>
|
||||
@ -995,20 +995,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
G
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
폭발
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">1333-74-0</span>
|
||||
@ -1034,7 +1034,7 @@ ${styles}
|
||||
<span className="font-mono text-color-accent">75.0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
폭발 범위 극히 넓음(4~75%). 무색·무취로 감지 불가. 극저온 액화수소 유출 시 BLEVE
|
||||
위험. 급속 상승 확산.
|
||||
</div>
|
||||
@ -1055,20 +1055,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
G
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
인화/폭발
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">74-82-8</span>
|
||||
@ -1094,7 +1094,7 @@ ${styles}
|
||||
<span className="font-mono text-color-accent">15.0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
극저온(-162°C) 유출 시 RPT(급속상변환폭발), Pool Fire 위험. Flash 기화 → 가연성
|
||||
가스운 형성. 인천·평택항 LNG 물동량 상위.
|
||||
</div>
|
||||
@ -1113,20 +1113,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
S/SD
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
독성
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">108-95-2</span>
|
||||
@ -1152,7 +1152,7 @@ ${styles}
|
||||
<span className="font-mono text-color-accent">84 g/L</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
비중 1.07 → <b className="text-color-accent">Sinker 특성</b>으로 저층 축적. ROMS
|
||||
검증 결과 저층 농도가 표층의 3.5배. 해양산업시설 배출 주요 HNS (31.8kg/일).
|
||||
</div>
|
||||
@ -1171,20 +1171,20 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
FE
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-semibold text-color-accent px-1.5 py-0.5"
|
||||
className="text-caption font-semibold text-color-accent px-1.5 py-0.5"
|
||||
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||
>
|
||||
인화성
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-[8px] mb-2">
|
||||
<div className="grid grid-cols-2 gap-1 text-caption mb-2">
|
||||
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||
<span className="font-mono">108-88-3</span>
|
||||
@ -1210,7 +1210,7 @@ ${styles}
|
||||
<span className="font-mono">0.52 g/L</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[8px] text-fg-disabled leading-normal">
|
||||
<div className="mt-1.5 text-caption text-fg-disabled leading-normal">
|
||||
해수면 부유 → 증발. 인화점 극히 낮아(4°C) 화재 위험 상시. 석유화학 산업의 대표적
|
||||
HNS. 울산항 주요 취급물질.
|
||||
</div>
|
||||
@ -1293,7 +1293,7 @@ ${styles}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
className="text-[8px] rounded px-2 py-1.5"
|
||||
className="text-caption rounded px-2 py-1.5"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.03)',
|
||||
border: '1px solid rgba(6,182,212,.1)',
|
||||
@ -1302,7 +1302,7 @@ ${styles}
|
||||
<b className="text-color-accent">ERPG-1</b> — 일시적 건강 영향, 냄새 감지
|
||||
</div>
|
||||
<div
|
||||
className="text-[8px] rounded px-2 py-1.5"
|
||||
className="text-caption rounded px-2 py-1.5"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.03)',
|
||||
border: '1px solid rgba(6,182,212,.1)',
|
||||
@ -1311,7 +1311,7 @@ ${styles}
|
||||
<b className="text-color-accent">ERPG-2</b> — 비가역적 영향, 대피 판단 기준
|
||||
</div>
|
||||
<div
|
||||
className="text-[8px] rounded px-2 py-1.5"
|
||||
className="text-caption rounded px-2 py-1.5"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.03)',
|
||||
border: '1px solid rgba(6,182,212,.1)',
|
||||
@ -1465,7 +1465,7 @@ ${styles}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 text-[7px] text-fg-disabled">
|
||||
<div className="mt-2 text-caption text-fg-disabled">
|
||||
※ AEGL: 60분 기준 / ERPG: 1시간 노출 / IDLH: 30분 / LFL: 폭발하한
|
||||
</div>
|
||||
</div>
|
||||
@ -1556,7 +1556,7 @@ ${styles}
|
||||
🔎 검색
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-disabled leading-[1.6]">
|
||||
<div className="text-caption text-fg-disabled leading-[1.6]">
|
||||
※ 국문명·영문명 검색 시 <b className="text-color-accent">동의어까지 검색</b>{' '}
|
||||
| 약자/제품명 검색 시{' '}
|
||||
<b className="text-color-accent">부호, 띄어쓰기 제외</b> 후 검색 | 총{' '}
|
||||
@ -2080,11 +2080,11 @@ function HmsDetailPanel({
|
||||
{nfpa.reactivity}
|
||||
</text>
|
||||
</svg>
|
||||
<div className="text-center text-[7px] font-semibold text-fg-disabled mt-0.5">
|
||||
<div className="text-center text-caption font-semibold text-fg-disabled mt-0.5">
|
||||
NFPA 704
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1 text-[8px]">
|
||||
<div className="flex-1 flex flex-col gap-1 text-caption">
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
@ -2295,7 +2295,7 @@ function HmsDetailPanel({
|
||||
>
|
||||
<div className="text-base mb-[3px]">🧑🚒</div>
|
||||
<div className="font-bold text-color-accent">근거리</div>
|
||||
<div className="text-[8px] text-fg-disabled mt-0.5">{s.ppeClose}</div>
|
||||
<div className="text-caption text-fg-disabled mt-0.5">{s.ppeClose}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center rounded"
|
||||
@ -2307,7 +2307,7 @@ function HmsDetailPanel({
|
||||
>
|
||||
<div className="text-base mb-[3px]">🦺</div>
|
||||
<div className="font-bold text-color-accent">원거리</div>
|
||||
<div className="text-[8px] text-fg-disabled mt-0.5">{s.ppeFar}</div>
|
||||
<div className="text-caption text-fg-disabled mt-0.5">{s.ppeFar}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -2328,7 +2328,7 @@ function HmsDetailPanel({
|
||||
📄 MSDS 주요 정보
|
||||
</div>
|
||||
<button
|
||||
className="text-[8px] font-semibold cursor-pointer rounded"
|
||||
className="text-caption font-semibold cursor-pointer rounded"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
background: 'rgba(6,182,212,.1)',
|
||||
@ -2339,7 +2339,7 @@ function HmsDetailPanel({
|
||||
📥 전문 다운로드
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-sub leading-[1.7] p-2.5">
|
||||
<div className="text-caption text-fg-sub leading-[1.7] p-2.5">
|
||||
<b>§2 유해성·위험성:</b> {s.msds.hazard}
|
||||
<br />
|
||||
<b>§4 응급조치:</b> {s.msds.firstAid}
|
||||
@ -2607,7 +2607,7 @@ function HmsDetailPanel({
|
||||
<div className="text-label-1 font-bold text-color-accent">
|
||||
📋 화물적부도 화물코드
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-disabled">클릭 시 물질검색창으로 이동</div>
|
||||
<div className="text-caption text-fg-disabled">클릭 시 물질검색창으로 이동</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<table className="w-full border-collapse text-caption">
|
||||
@ -2714,7 +2714,9 @@ function HmsDetailPanel({
|
||||
}}
|
||||
>
|
||||
<div className="text-label-1 font-bold text-color-accent">🏗 항구별 코드</div>
|
||||
<div className="text-[8px] text-fg-disabled">Port-MIS 위험물반입신고현황 연동</div>
|
||||
<div className="text-caption text-fg-disabled">
|
||||
Port-MIS 위험물반입신고현황 연동
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<table className="w-full border-collapse text-caption">
|
||||
|
||||
@ -3849,15 +3849,16 @@ function RealtimeComparePanel() {
|
||||
<button
|
||||
className="px-4 py-1.5 rounded-md text-caption font-bold cursor-pointer text-white"
|
||||
style={{
|
||||
background: 'var(--color-accent)',
|
||||
border: 'none',
|
||||
// background: 'var(--color-accent)',
|
||||
// border: '1px solid var(--color-accent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
▶ 비교 실행
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 rounded-md text-caption font-semibold cursor-pointer text-fg-sub bg-bg-card"
|
||||
style={{ border: '1px solid var(--stroke-default)' }}
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
⚙ 파라미터
|
||||
</button>
|
||||
|
||||
@ -171,7 +171,7 @@ function HNSManualViewer() {
|
||||
<div className="text-label-2 font-bold" style={{ color: `${s.color},1)` }}>
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="text-fg-disabled whitespace-pre-line text-[8px] mt-[3px] leading-[1.3]">
|
||||
<div className="text-fg-disabled whitespace-pre-line text-caption mt-[3px] leading-[1.3]">
|
||||
{s.desc}
|
||||
</div>
|
||||
</div>
|
||||
@ -180,7 +180,7 @@ function HNSManualViewer() {
|
||||
</div>
|
||||
|
||||
{/* 출처 */}
|
||||
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-[8px] leading-[1.5]">
|
||||
<div className="text-fg-disabled rounded-sm bg-bg-card p-[10px] text-caption leading-[1.5]">
|
||||
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo
|
||||
Project, 2024 한국어판)
|
||||
<br />
|
||||
|
||||
@ -113,7 +113,7 @@ function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'forbidden')
|
||||
return (
|
||||
<span
|
||||
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
|
||||
>
|
||||
배출불가
|
||||
@ -122,7 +122,7 @@ function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === 'allowed')
|
||||
return (
|
||||
<span
|
||||
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
|
||||
>
|
||||
배출가능
|
||||
@ -130,7 +130,7 @@ function StatusBadge({ status }: { status: Status }) {
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="text-[8px] font-bold px-1.5 py-0.5 rounded"
|
||||
className="text-caption font-bold px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
|
||||
>
|
||||
조건부
|
||||
@ -173,10 +173,10 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-[8px] text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||
<div className="text-label-2 font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="text-[14px] cursor-pointer text-fg-sub hover:text-fg">
|
||||
<span onClick={onClose} className="text-title-3 cursor-pointer text-fg-sub hover:text-fg">
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
@ -187,14 +187,17 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
style={{ padding: '8px 14px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[9px] text-fg-sub font-korean">선택 위치</span>
|
||||
<span className="text-[9px] text-fg font-mono">
|
||||
<span className="text-caption text-fg-sub font-korean">선택 위치</span>
|
||||
<span className="text-caption text-fg font-mono">
|
||||
{lat.toFixed(4)}°N, {lon.toFixed(4)}°E
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
||||
<span className="text-caption text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
||||
<span
|
||||
className="text-label-2 font-bold font-mono"
|
||||
style={{ color: ZONE_COLORS[zoneIdx] }}
|
||||
>
|
||||
{distanceNm.toFixed(1)} NM
|
||||
</span>
|
||||
</div>
|
||||
@ -242,13 +245,13 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
<div
|
||||
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-fg font-korean">{cat}</span>
|
||||
<span className="text-caption font-bold text-fg font-korean">{cat}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-semibold" style={{ color: summaryColor }}>
|
||||
<span className="text-caption font-semibold" style={{ color: summaryColor }}>
|
||||
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||
</span>
|
||||
<span className="text-[9px] text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -265,7 +268,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] text-fg font-korean">{rule.item}</span>
|
||||
<span className="text-caption text-fg font-korean">{rule.item}</span>
|
||||
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||
</div>
|
||||
))}
|
||||
@ -276,7 +279,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[7px] text-fg-sub font-korean leading-relaxed"
|
||||
className="text-caption text-fg-sub font-korean leading-relaxed"
|
||||
>
|
||||
💡 {r.item}: {r.condition}
|
||||
</div>
|
||||
@ -295,7 +298,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
||||
className="shrink-0"
|
||||
style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="text-[7px] text-fg-sub font-korean leading-relaxed">
|
||||
<div className="text-caption text-fg-sub font-korean leading-relaxed">
|
||||
※ 거리는 최근접 해안선 기준 추정치입니다. 실제 영해기선과 차이가 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -233,10 +233,10 @@ export function IncidentsLeftPanel({
|
||||
setSelectedPeriod('');
|
||||
resetPage();
|
||||
}}
|
||||
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
|
||||
className="bg-bg-base border border-stroke font-mono text-label-2 outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
/>
|
||||
<span className="text-fg-disabled text-[11px]">~</span>
|
||||
<span className="text-fg-disabled text-label-2">~</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
@ -245,12 +245,12 @@ export function IncidentsLeftPanel({
|
||||
setSelectedPeriod('');
|
||||
resetPage();
|
||||
}}
|
||||
className="bg-bg-base border border-stroke font-mono text-[11px] outline-none flex-1"
|
||||
className="bg-bg-base border border-stroke font-mono text-label-2 outline-none flex-1"
|
||||
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={resetPage}
|
||||
className="text-[11px] font-semibold cursor-pointer whitespace-nowrap text-white border-none"
|
||||
className="text-label-2 font-semibold cursor-pointer whitespace-nowrap text-white border-none"
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
|
||||
@ -267,7 +267,7 @@ export function IncidentsLeftPanel({
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePeriodClick(p)}
|
||||
className="text-[10px] font-semibold cursor-pointer"
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
borderRadius: '14px',
|
||||
@ -290,7 +290,7 @@ export function IncidentsLeftPanel({
|
||||
style={{ background: 'rgba(6,182,212,0.03)' }}
|
||||
>
|
||||
<div
|
||||
className="text-[10px] font-bold text-fg-disabled mb-2"
|
||||
className="text-caption font-bold text-fg-disabled mb-2"
|
||||
style={{ letterSpacing: '0.8px' }}
|
||||
>
|
||||
📅 오늘 ({todayLabel}) 사고 현황
|
||||
@ -306,7 +306,7 @@ export function IncidentsLeftPanel({
|
||||
setSelectedRegion(r);
|
||||
resetPage();
|
||||
}}
|
||||
className="text-[11px] cursor-pointer"
|
||||
className="text-label-2 cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
@ -349,7 +349,7 @@ export function IncidentsLeftPanel({
|
||||
setSelectedStatus(s.id);
|
||||
resetPage();
|
||||
}}
|
||||
className="flex items-center gap-1 text-[10px] font-semibold cursor-pointer"
|
||||
className="flex items-center gap-1 text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
@ -372,7 +372,7 @@ export function IncidentsLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="px-4 py-1.5 text-[11px] text-fg-disabled shrink-0 border-b border-stroke">
|
||||
<div className="px-4 py-1.5 text-label-2 text-fg-disabled shrink-0 border-b border-stroke">
|
||||
총 {filteredIncidents.length}건
|
||||
</div>
|
||||
|
||||
@ -385,7 +385,7 @@ export function IncidentsLeftPanel({
|
||||
}}
|
||||
>
|
||||
{pagedIncidents.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-fg-disabled text-[11px]">
|
||||
<div className="px-4 py-10 text-center text-fg-disabled text-label-2">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
@ -449,7 +449,7 @@ export function IncidentsLeftPanel({
|
||||
{inc.name}
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-semibold"
|
||||
className="shrink-0 text-caption font-semibold"
|
||||
style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: '10px',
|
||||
@ -461,7 +461,7 @@ export function IncidentsLeftPanel({
|
||||
</span>
|
||||
</div>
|
||||
{/* Row 2: meta */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-fg-disabled mb-[5px]">
|
||||
<div className="flex items-center gap-2 text-caption text-fg-disabled mb-[5px]">
|
||||
<span>
|
||||
📅 {inc.date} {inc.time}
|
||||
</span>
|
||||
@ -472,7 +472,7 @@ export function IncidentsLeftPanel({
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{inc.causeType && (
|
||||
<span
|
||||
className="text-[10px] font-medium text-fg-sub"
|
||||
className="text-caption font-medium text-fg-sub"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
@ -485,7 +485,7 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
{inc.oilType && (
|
||||
<span
|
||||
className="text-[10px] font-medium text-color-warning"
|
||||
className="text-caption font-medium text-color-warning"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
@ -498,7 +498,7 @@ export function IncidentsLeftPanel({
|
||||
)}
|
||||
{inc.prediction && (
|
||||
<span
|
||||
className="text-[10px] font-medium text-color-success"
|
||||
className="text-caption font-medium text-color-success"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
@ -512,7 +512,7 @@ export function IncidentsLeftPanel({
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="inc-wx-btn cursor-pointer text-[11px]"
|
||||
className="inc-wx-btn cursor-pointer text-label-2"
|
||||
onClick={(e) => handleWeatherClick(e, inc.id)}
|
||||
title="사고 위치 기상정보"
|
||||
style={{
|
||||
@ -537,7 +537,7 @@ export function IncidentsLeftPanel({
|
||||
setMediaModalIncident(inc);
|
||||
}}
|
||||
title="현장정보 조회"
|
||||
className="cursor-pointer text-[11px]"
|
||||
className="cursor-pointer text-label-2"
|
||||
style={{
|
||||
padding: '3px 7px',
|
||||
borderRadius: '4px',
|
||||
@ -548,7 +548,7 @@ export function IncidentsLeftPanel({
|
||||
transition: '0.15s',
|
||||
}}
|
||||
>
|
||||
📹 <span className="text-[8px]">{inc.mediaCount}</span>
|
||||
📹 <span className="text-caption">{inc.mediaCount}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -576,7 +576,7 @@ export function IncidentsLeftPanel({
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between bg-bg-surface shrink-0 border-t border-stroke px-3 py-2">
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
<div className="text-caption text-fg-disabled">
|
||||
총 <b>{filteredIncidents.length}</b>건 중 {(safePage - 1) * pageSize + 1}-
|
||||
{Math.min(safePage * pageSize, filteredIncidents.length)}
|
||||
</div>
|
||||
@ -612,7 +612,7 @@ export function IncidentsLeftPanel({
|
||||
onChange={(e) => {
|
||||
/* page size change placeholder */ void e;
|
||||
}}
|
||||
className="bg-bg-base border border-stroke text-fg-sub text-[9px] outline-none rounded px-1.5 py-[3px]"
|
||||
className="bg-bg-base border border-stroke text-fg-sub text-caption outline-none rounded px-1.5 py-[3px]"
|
||||
>
|
||||
<option>6건</option>
|
||||
<option>10건</option>
|
||||
@ -638,7 +638,7 @@ function PgBtn({
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex items-center justify-center font-mono text-[9px]"
|
||||
className="flex items-center justify-center font-mono text-caption"
|
||||
style={{
|
||||
minWidth: '24px',
|
||||
height: '24px',
|
||||
@ -694,8 +694,8 @@ const WeatherPopup = forwardRef<
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌤</span>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||
<div className="text-fg-disabled font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
||||
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">
|
||||
@ -710,12 +710,12 @@ const WeatherPopup = forwardRef<
|
||||
<div className="text-[28px]">{data?.icon || '❓'}</div>
|
||||
<div>
|
||||
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</div>
|
||||
<div className="text-fg-disabled text-[9px]">{data?.weatherDc || '-'}</div>
|
||||
<div className="text-fg-disabled text-caption">{data?.weatherDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail grid */}
|
||||
<div className="grid grid-cols-2 gap-1.5 text-[9px]">
|
||||
<div className="grid grid-cols-2 gap-1.5 text-caption">
|
||||
<WxCell icon="💨" label="풍향/풍속" value={data?.wind} />
|
||||
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
||||
<WxCell icon="💧" label="습도" value={data?.humid} />
|
||||
@ -735,8 +735,8 @@ const WeatherPopup = forwardRef<
|
||||
>
|
||||
<span className="text-xs">⬆</span>
|
||||
<div>
|
||||
<div className="text-fg-disabled text-[7px]">고조 (만조)</div>
|
||||
<div className="font-bold font-mono text-[10px] text-color-info">
|
||||
<div className="text-fg-disabled text-caption">고조 (만조)</div>
|
||||
<div className="font-bold font-mono text-caption text-color-info">
|
||||
{data?.highTide || '-'}
|
||||
</div>
|
||||
</div>
|
||||
@ -747,8 +747,8 @@ const WeatherPopup = forwardRef<
|
||||
>
|
||||
<span className="text-xs">⬇</span>
|
||||
<div>
|
||||
<div className="text-fg-disabled text-[7px]">저조 (간조)</div>
|
||||
<div className="text-color-accent font-bold font-mono text-[10px]">
|
||||
<div className="text-fg-disabled text-caption">저조 (간조)</div>
|
||||
<div className="text-color-accent font-bold font-mono text-caption">
|
||||
{data?.lowTide || '-'}
|
||||
</div>
|
||||
</div>
|
||||
@ -757,9 +757,9 @@ const WeatherPopup = forwardRef<
|
||||
|
||||
{/* 24h Forecast */}
|
||||
<div className="bg-bg-base mt-2.5 px-2.5 py-2 rounded-md">
|
||||
<div className="font-bold text-fg-disabled text-[8px] mb-1.5">24h 예보</div>
|
||||
<div className="font-bold text-fg-disabled text-caption mb-1.5">24h 예보</div>
|
||||
{forecast.length > 0 ? (
|
||||
<div className="flex justify-between font-mono text-fg-sub text-[8px]">
|
||||
<div className="flex justify-between font-mono text-fg-sub text-caption">
|
||||
{forecast.map((f, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div>{f.hour}</div>
|
||||
@ -769,7 +769,7 @@ const WeatherPopup = forwardRef<
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-fg-disabled text-center text-[8px] py-1">예보 데이터 없음</div>
|
||||
<div className="text-fg-disabled text-center text-caption py-1">예보 데이터 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -782,8 +782,8 @@ const WeatherPopup = forwardRef<
|
||||
border: '1px solid rgba(249,115,22,0.12)',
|
||||
}}
|
||||
>
|
||||
<div className="font-bold text-color-warning text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
||||
<div className="text-fg-sub text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||
<div className="font-bold text-color-warning text-caption mb-[3px]">⚠ 방제 작업 영향</div>
|
||||
<div className="text-fg-sub text-caption leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -794,9 +794,9 @@ WeatherPopup.displayName = 'WeatherPopup';
|
||||
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||
return (
|
||||
<div className="flex items-center bg-bg-base rounded gap-[6px] py-1.5 px-2">
|
||||
<span className="text-[12px]">{icon}</span>
|
||||
<span className="text-label-1">{icon}</span>
|
||||
<div>
|
||||
<div className="text-fg-disabled text-[7px]">{label}</div>
|
||||
<div className="text-fg-disabled text-caption">{label}</div>
|
||||
<div className="font-semibold font-mono">{value || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -311,7 +311,7 @@ export function IncidentsRightPanel({
|
||||
if (!incident) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
|
||||
<div className="text-center text-fg-disabled text-[11px]">
|
||||
<div className="text-center text-fg-disabled text-label-2">
|
||||
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
||||
좌측에서 사고를 선택하면
|
||||
<br />
|
||||
@ -326,7 +326,7 @@ export function IncidentsRightPanel({
|
||||
{/* Header */}
|
||||
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
||||
<div className="text-xs font-bold mb-0.5">🔬 통합분석 조회</div>
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
<div className="text-caption text-fg-disabled">
|
||||
선택: <b className="text-color-accent">{incident.name}</b>
|
||||
</div>
|
||||
</div>
|
||||
@ -350,7 +350,7 @@ export function IncidentsRightPanel({
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-[10px] font-semibold cursor-pointer"
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
@ -364,7 +364,7 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sec.items.length === 0 ? (
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">
|
||||
예측 실행 이력이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
@ -387,15 +387,15 @@ export function IncidentsRightPanel({
|
||||
style={{ accentColor: sec.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[10px] font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
<div className="text-caption font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-fg-disabled font-mono text-[8px]">{item.sub}</div>
|
||||
<div className="text-fg-disabled font-mono text-caption">{item.sub}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={() => removePredItem(item.id)}
|
||||
title="제거"
|
||||
className="text-[10px] cursor-pointer text-fg-disabled shrink-0"
|
||||
className="text-caption cursor-pointer text-fg-disabled shrink-0"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
@ -403,7 +403,7 @@ export function IncidentsRightPanel({
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-fg-disabled">
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>{checkedCount}건</b> · {sec.totalLabel}
|
||||
</div>
|
||||
</div>
|
||||
@ -421,7 +421,7 @@ export function IncidentsRightPanel({
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-[10px] font-semibold cursor-pointer"
|
||||
className="text-caption font-semibold cursor-pointer"
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
@ -433,8 +433,8 @@ export function IncidentsRightPanel({
|
||||
📋 조회
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-[9px] text-fg-disabled">
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
|
||||
선택: <b style={{ color: sec.color }}>0건</b> · 전체 0건
|
||||
</div>
|
||||
</div>
|
||||
@ -448,7 +448,7 @@ export function IncidentsRightPanel({
|
||||
</div>
|
||||
<div className="flex flex-col gap-[3px]">
|
||||
{sensCategories.length === 0 ? (
|
||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">
|
||||
<div className="text-caption text-fg-disabled text-center py-1.5">
|
||||
해당 사고 영역의 민감자원이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
@ -463,7 +463,7 @@ export function IncidentsRightPanel({
|
||||
return (
|
||||
<label
|
||||
key={cat.category}
|
||||
className="flex items-center cursor-pointer text-[9px] gap-[5px] rounded-[3px]"
|
||||
className="flex items-center cursor-pointer text-caption gap-[5px] rounded-[3px]"
|
||||
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
|
||||
>
|
||||
<input
|
||||
@ -499,23 +499,23 @@ export function IncidentsRightPanel({
|
||||
<span className="text-sm">🛡</span>
|
||||
<span className="text-xs font-bold text-color-boom">근처 방제자원</span>
|
||||
{nearbyOrgs.length > 0 && (
|
||||
<span className="ml-auto text-[9px] font-mono text-color-boom">
|
||||
<span className="ml-auto text-caption font-mono text-color-boom">
|
||||
{nearbyOrgs.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedVessel ? (
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px] leading-[1.7]">
|
||||
<div className="py-2.5 text-center text-fg-disabled text-caption leading-[1.7]">
|
||||
<div className="text-xl mb-1 opacity-40">🚢</div>
|
||||
지도에서 선박을 클릭하면
|
||||
<br />
|
||||
부근 방제자원이 표시됩니다
|
||||
</div>
|
||||
) : nearbyLoading ? (
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px]">조회 중...</div>
|
||||
<div className="py-2.5 text-center text-fg-disabled text-caption">조회 중...</div>
|
||||
) : nearbyOrgs.length === 0 ? (
|
||||
<div className="py-2.5 text-center text-fg-disabled text-[10px]">
|
||||
<div className="py-2.5 text-center text-fg-disabled text-caption">
|
||||
반경 내 방제자원 없음
|
||||
</div>
|
||||
) : (
|
||||
@ -532,19 +532,19 @@ export function IncidentsRightPanel({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1 mb-[2px]">
|
||||
<span
|
||||
className="text-[8px] px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
|
||||
className="text-caption px-[4px] py-[1px] rounded-[2px] font-bold shrink-0"
|
||||
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
|
||||
>
|
||||
{org.orgTp}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-fg truncate">{org.orgNm}</span>
|
||||
<span className="text-caption font-bold text-fg truncate">{org.orgNm}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled">
|
||||
<div className="text-caption text-fg-disabled">
|
||||
{org.areaNm}
|
||||
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-color-boom shrink-0">
|
||||
<span className="text-caption font-mono text-color-boom shrink-0">
|
||||
{org.distanceNm.toFixed(1)} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -555,8 +555,8 @@ export function IncidentsRightPanel({
|
||||
{/* Radius slider */}
|
||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
||||
<div className="flex items-center justify-between mb-[5px]">
|
||||
<span className="text-[9px] text-fg-disabled">탐색 반경</span>
|
||||
<span className="text-[10px] font-bold font-mono text-color-boom">
|
||||
<span className="text-caption text-fg-disabled">탐색 반경</span>
|
||||
<span className="text-caption font-bold font-mono text-color-boom">
|
||||
{nearbyRadius} nm
|
||||
</span>
|
||||
</div>
|
||||
@ -593,7 +593,7 @@ export function IncidentsRightPanel({
|
||||
<button
|
||||
key={v.mode}
|
||||
onClick={() => onViewModeChange(v.mode)}
|
||||
className="flex-1 text-[10px] cursor-pointer"
|
||||
className="flex-1 text-caption cursor-pointer"
|
||||
style={{
|
||||
padding: '6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
@ -624,7 +624,7 @@ export function IncidentsRightPanel({
|
||||
const sensChecked = checkedSensCategories.size;
|
||||
onRunAnalysis(checkedSections, sensChecked);
|
||||
}}
|
||||
className="w-full text-[11px] font-bold cursor-pointer"
|
||||
className="w-full text-label-2 font-bold cursor-pointer"
|
||||
style={{
|
||||
padding: '8px',
|
||||
background: analysisActive
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -60,7 +60,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
<div
|
||||
className="text-center text-[12px] text-fg-disabled"
|
||||
className="text-center text-label-1 text-fg-disabled"
|
||||
style={{
|
||||
width: 300,
|
||||
padding: 40,
|
||||
@ -114,8 +114,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<span className="text-lg">📋</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-[800] text-fg">현장정보 — {incident.name}</div>
|
||||
<div className="text-[10px] text-fg-disabled font-mono">
|
||||
<div className="text-title-3 font-[800] text-fg">현장정보 — {incident.name}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} /
|
||||
위성 {media.satCnt} / CCTV {media.cctvCnt}
|
||||
</div>
|
||||
@ -161,7 +161,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{/* Close */}
|
||||
<span
|
||||
onClick={onClose}
|
||||
className="text-[18px] cursor-pointer text-fg-disabled rounded"
|
||||
className="text-title-1 cursor-pointer text-fg-disabled rounded"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
✕
|
||||
@ -174,7 +174,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
className="shrink-0 flex items-center gap-[10px]"
|
||||
style={{ padding: '6px 20px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<span className="text-[9px] text-fg-disabled whitespace-nowrap">TIMELINE</span>
|
||||
<span className="text-caption text-fg-disabled whitespace-nowrap">TIMELINE</span>
|
||||
<div className="flex-1 relative" style={{ height: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
@ -204,7 +204,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 text-[8px] font-mono text-fg-disabled whitespace-nowrap">
|
||||
<div className="flex gap-2 text-caption font-mono text-fg-disabled whitespace-nowrap">
|
||||
<span style={{ color: '#ef4444' }}>● 초기</span>
|
||||
<span style={{ color: '#f59e0b' }}>● 대응</span>
|
||||
<span className="text-fg-disabled">● 종료</span>
|
||||
@ -231,8 +231,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">📷</span>
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
<span className="text-label-1">📷</span>
|
||||
<span className="text-label-1 font-bold text-fg">
|
||||
현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
|
||||
</span>
|
||||
</div>
|
||||
@ -245,10 +245,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||
📷
|
||||
</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">
|
||||
<div className="text-label-1 text-fg-sub font-semibold">
|
||||
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
|
||||
</div>
|
||||
</div>
|
||||
@ -262,7 +262,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
(_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-center text-[14px] cursor-pointer"
|
||||
className="flex items-center justify-center text-title-3 cursor-pointer"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 36,
|
||||
@ -281,10 +281,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
<span className="text-caption text-fg-disabled">
|
||||
📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')}
|
||||
</span>
|
||||
<span className="text-[8px] text-color-tertiary cursor-pointer">🔗 R&D 연계</span>
|
||||
<span className="text-caption text-color-tertiary cursor-pointer">
|
||||
🔗 R&D 연계
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -298,13 +300,13 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">🎬</span>
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
<span className="text-label-1">🎬</span>
|
||||
<span className="text-label-1 font-bold text-fg">
|
||||
드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-bold text-color-danger rounded"
|
||||
className="text-caption font-bold text-color-danger rounded"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(239,68,68,0.15)',
|
||||
@ -317,8 +319,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||
🎬
|
||||
</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">드론 항공 촬영 영상</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
<div className="text-label-1 text-fg-sub font-semibold">드론 항공 촬영 영상</div>
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
|
||||
</div>
|
||||
</div>
|
||||
@ -328,9 +330,9 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<span className="text-[12px] text-fg-disabled cursor-pointer">⏮</span>
|
||||
<span className="text-label-1 text-fg-disabled cursor-pointer">⏮</span>
|
||||
<div
|
||||
className="flex items-center justify-center text-[12px] text-color-tertiary cursor-pointer"
|
||||
className="flex items-center justify-center text-label-1 text-color-tertiary cursor-pointer"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
@ -341,18 +343,18 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
>
|
||||
▶
|
||||
</div>
|
||||
<span className="text-[12px] text-fg-disabled cursor-pointer">⏭</span>
|
||||
<span className="text-[10px] text-fg-disabled font-mono">
|
||||
<span className="text-label-1 text-fg-disabled cursor-pointer">⏭</span>
|
||||
<span className="text-caption text-fg-disabled font-mono">
|
||||
02:34 / {str(media.droneMeta, 'duration')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
<span className="text-caption text-fg-disabled">
|
||||
🎬 영상 {num(media.droneMeta, 'videoCount')}건 · {str(media.droneMeta, 'stage')}
|
||||
</span>
|
||||
<div className="flex gap-[8px]">
|
||||
<span className="text-[8px] text-color-info cursor-pointer">📂 전체보기</span>
|
||||
<span className="text-[8px] text-color-tertiary cursor-pointer">
|
||||
<span className="text-caption text-color-info cursor-pointer">📂 전체보기</span>
|
||||
<span className="text-caption text-color-tertiary cursor-pointer">
|
||||
🔗 R&D 연계
|
||||
</span>
|
||||
</div>
|
||||
@ -369,8 +371,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">🛰</span>
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
<span className="text-label-1">🛰</span>
|
||||
<span className="text-label-1 font-bold text-fg">
|
||||
위성영상 — {str(media.satMeta, 'title', '위성영상')}
|
||||
</span>
|
||||
</div>
|
||||
@ -397,16 +399,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute text-[9px] font-bold text-color-danger font-mono bg-bg-base"
|
||||
className="absolute text-caption font-bold text-color-danger font-mono bg-bg-base"
|
||||
style={{ top: -10, left: 8, padding: '0 4px' }}
|
||||
>
|
||||
{str(media.satMeta, 'detection')}
|
||||
</div>
|
||||
<div className="text-[40px] text-fg-disabled">🛰</div>
|
||||
<div className="text-[11px] text-fg-sub font-semibold">
|
||||
<div className="text-label-2 text-fg-sub font-semibold">
|
||||
{str(media.satMeta, 'title', '위성영상')} 위성영상
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-disabled font-mono">
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{str(media.satMeta, 'date')} · 해상도 {str(media.satMeta, 'resolution')}
|
||||
</div>
|
||||
</div>
|
||||
@ -414,7 +416,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{str(media.satMeta, 'detection') === '—' && (
|
||||
<div className="text-center">
|
||||
<div className="text-[40px] text-fg-disabled">🛰</div>
|
||||
<div className="text-[11px] text-fg-disabled" style={{ marginTop: 8 }}>
|
||||
<div className="text-label-2 text-fg-disabled" style={{ marginTop: 8 }}>
|
||||
위성영상 없음
|
||||
</div>
|
||||
</div>
|
||||
@ -429,7 +431,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-center text-[14px] text-fg-disabled cursor-pointer"
|
||||
className="flex items-center justify-center text-title-3 text-fg-disabled cursor-pointer"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 36,
|
||||
@ -444,10 +446,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
<span className="text-caption text-fg-disabled">
|
||||
🛰 위성 {num(media.satMeta, 'thumbCount')}장 · {str(media.satMeta, 'sensor')}
|
||||
</span>
|
||||
<span className="text-[8px] text-color-info cursor-pointer">🔍 편집/측 비교</span>
|
||||
<span className="text-caption text-color-info cursor-pointer">
|
||||
🔍 편집/측 비교
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -461,15 +465,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="text-[12px]">📹</span>
|
||||
<span className="text-[12px] font-bold text-fg">
|
||||
<span className="text-label-1">📹</span>
|
||||
<span className="text-label-1 font-bold text-fg">
|
||||
CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[6px]">
|
||||
{bool(media.cctvMeta, 'live') && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-color-success rounded"
|
||||
className="text-caption font-bold text-color-success rounded"
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
background: 'rgba(34,197,94,0.15)',
|
||||
@ -484,17 +488,17 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
<div className="flex-1 flex items-center justify-center flex-col gap-2 relative">
|
||||
{bool(media.cctvMeta, 'live') && (
|
||||
<div
|
||||
className="absolute text-[9px] font-bold text-color-danger font-mono"
|
||||
className="absolute text-caption font-bold text-color-danger font-mono"
|
||||
style={{ top: 10, left: 16 }}
|
||||
>
|
||||
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[48px] text-fg-disabled">📹</div>
|
||||
<div className="text-[12px] text-fg-sub font-semibold">
|
||||
<div className="text-label-1 text-fg-sub font-semibold">
|
||||
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono">
|
||||
<div className="text-caption text-fg-disabled font-mono">
|
||||
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} ·{' '}
|
||||
{bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
|
||||
</div>
|
||||
@ -529,13 +533,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[8px] text-fg-disabled">
|
||||
<span className="text-caption text-fg-disabled">
|
||||
📹 CCTV {num(media.cctvMeta, 'camCount')}채널 ·{' '}
|
||||
{str(media.cctvMeta, 'location')}
|
||||
</span>
|
||||
<div className="flex gap-[8px]">
|
||||
<span className="text-[8px] text-color-danger cursor-pointer">🔴 녹화영상</span>
|
||||
<span className="text-[8px] text-color-info cursor-pointer">🎥 PTZ</span>
|
||||
<span className="text-caption text-color-danger cursor-pointer">
|
||||
🔴 녹화영상
|
||||
</span>
|
||||
<span className="text-caption text-color-info cursor-pointer">🎥 PTZ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -552,7 +558,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
borderTop: '1px solid #30363d',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-4 text-[10px] font-mono text-fg-disabled">
|
||||
<div className="flex gap-4 text-caption font-mono text-fg-disabled">
|
||||
<span>
|
||||
📷 사진 <b className="text-fg">{media.photoCnt}</b>
|
||||
</span>
|
||||
@ -601,7 +607,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
||||
function NavBtn({ label }: { label: string }) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center justify-center text-[10px] text-fg-disabled cursor-pointer rounded bg-bg-elevated"
|
||||
className="flex items-center justify-center text-caption text-fg-disabled cursor-pointer rounded bg-bg-elevated"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
@ -628,7 +634,7 @@ function BottomBtn({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] font-bold cursor-pointer rounded-sm"
|
||||
className="flex items-center gap-1 text-caption font-bold cursor-pointer rounded-sm"
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: bg,
|
||||
|
||||
@ -116,7 +116,7 @@ export function BacktrackModal({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-base font-bold m-0">유출유 역추적 분석</h2>
|
||||
<div className="text-[11px] text-fg-disabled mt-[2px]">
|
||||
<div className="text-label-2 text-fg-disabled mt-[2px]">
|
||||
AIS 항적 기반 유출 선박 추정
|
||||
</div>
|
||||
</div>
|
||||
@ -144,7 +144,7 @@ export function BacktrackModal({
|
||||
>
|
||||
{/* Analysis Conditions */}
|
||||
<div>
|
||||
<h3 className="text-[12px] font-bold text-fg-sub mb-[10px]">분석 조건</h3>
|
||||
<h3 className="text-label-1 font-bold text-fg-sub mb-[10px]">분석 조건</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
@ -161,7 +161,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-[9px] text-fg-disabled mb-1">유출 추정 시각</div>
|
||||
<div className="text-caption text-fg-disabled mb-1">유출 추정 시각</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={inputTime}
|
||||
@ -180,7 +180,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-[9px] text-fg-disabled mb-1">분석 범위</div>
|
||||
<div className="text-caption text-fg-disabled mb-1">분석 범위</div>
|
||||
<select
|
||||
value={inputRange}
|
||||
onChange={(e) => setInputRange(e.target.value)}
|
||||
@ -202,7 +202,7 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-[9px] text-fg-disabled mb-1">탐색 반경</div>
|
||||
<div className="text-caption text-fg-disabled mb-1">탐색 반경</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
@ -214,7 +214,7 @@ export function BacktrackModal({
|
||||
step={0.5}
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<span className="text-[10px] text-fg-disabled shrink-0">NM</span>
|
||||
<span className="text-caption text-fg-disabled shrink-0">NM</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -227,8 +227,8 @@ export function BacktrackModal({
|
||||
}}
|
||||
className="border border-stroke"
|
||||
>
|
||||
<div className="text-[9px] text-fg-disabled mb-1">유출 위치</div>
|
||||
<div className="text-[12px] font-semibold font-mono">
|
||||
<div className="text-caption text-fg-disabled mb-1">유출 위치</div>
|
||||
<div className="text-label-1 font-semibold font-mono">
|
||||
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
|
||||
{conditions.spillLocation.lon.toFixed(4)}°E
|
||||
</div>
|
||||
@ -244,10 +244,10 @@ export function BacktrackModal({
|
||||
gridColumn: '1 / -1',
|
||||
}}
|
||||
>
|
||||
<div className="text-[9px] text-fg-disabled mb-1">분석 대상 선박</div>
|
||||
<div className="text-caption text-fg-disabled mb-1">분석 대상 선박</div>
|
||||
<div className="text-sm font-bold text-color-tertiary font-mono">
|
||||
{conditions.totalVessels}척{' '}
|
||||
<span className="text-[10px] font-medium text-fg-disabled">(AIS 수신)</span>
|
||||
<span className="text-caption font-medium text-fg-disabled">(AIS 수신)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -257,7 +257,7 @@ export function BacktrackModal({
|
||||
{phase === 'results' && vessels.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-[12px] font-bold text-fg-sub m-0">분석 결과</h3>
|
||||
<h3 className="text-label-1 font-bold text-fg-sub m-0">분석 결과</h3>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
@ -265,7 +265,7 @@ export function BacktrackModal({
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid rgba(239,68,68,0.3)',
|
||||
}}
|
||||
className="text-[10px] font-bold text-color-danger"
|
||||
className="text-caption font-bold text-color-danger"
|
||||
>
|
||||
{conditions.totalVessels}척 중 {vessels.length}척 의심
|
||||
</div>
|
||||
@ -303,7 +303,7 @@ export function BacktrackModal({
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
||||
className="flex-1 text-title-4 font-bold cursor-pointer"
|
||||
>
|
||||
🔍 역추적 분석 실행
|
||||
</button>
|
||||
@ -318,7 +318,7 @@ export function BacktrackModal({
|
||||
color: 'var(--color-tertiary)',
|
||||
cursor: 'wait',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold border border-stroke"
|
||||
className="flex-1 text-title-4 font-bold border border-stroke"
|
||||
>
|
||||
⏳ AIS 항적 분석중...
|
||||
</button>
|
||||
@ -333,7 +333,7 @@ export function BacktrackModal({
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
}}
|
||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
||||
className="flex-1 text-title-4 font-bold cursor-pointer"
|
||||
>
|
||||
🗺 지도에서 리플레이 보기
|
||||
</button>
|
||||
@ -380,8 +380,8 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
{vessel.rank}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13px] font-bold font-mono">{vessel.name}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono mt-[2px]">
|
||||
<div className="text-title-4 font-bold font-mono">{vessel.name}</div>
|
||||
<div className="text-caption text-fg-disabled font-mono mt-[2px]">
|
||||
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
||||
</div>
|
||||
</div>
|
||||
@ -392,7 +392,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
>
|
||||
{vessel.probability}%
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-disabled">유출 확률</div>
|
||||
<div className="text-caption text-fg-disabled">유출 확률</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -430,12 +430,12 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
: '1px solid var(--stroke-default)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[8px] text-fg-disabled mb-[2px]">{s.label}</div>
|
||||
<div className="text-caption text-fg-disabled mb-[2px]">{s.label}</div>
|
||||
<div
|
||||
style={{
|
||||
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
|
||||
}}
|
||||
className="text-[10px] font-semibold font-mono"
|
||||
className="text-caption font-semibold font-mono"
|
||||
>
|
||||
{s.value}
|
||||
</div>
|
||||
@ -453,7 +453,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
||||
borderRadius: '6px',
|
||||
lineHeight: '1.5',
|
||||
}}
|
||||
className="text-[9px] text-fg-sub"
|
||||
className="text-caption text-fg-sub"
|
||||
>
|
||||
{vessel.description}
|
||||
</div>
|
||||
|
||||
@ -67,7 +67,11 @@ ${styles}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-5" ref={contentRef}>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto scrollbar-thin p-5"
|
||||
ref={contentRef}
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@ -166,8 +166,8 @@ export function RecalcModal({
|
||||
🔄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-[15px] font-bold m-0">확산예측 재계산</h2>
|
||||
<div className="text-[10px] text-fg-disabled mt-[2px]">
|
||||
<h2 className="text-subtitle font-bold m-0">확산예측 재계산</h2>
|
||||
<div className="text-caption text-fg-disabled mt-[2px]">
|
||||
유출유·유출량 등 파라미터를 수정하여 재실행
|
||||
</div>
|
||||
</div>
|
||||
@ -202,10 +202,10 @@ export function RecalcModal({
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div className="text-[9px] font-bold text-color-accent mb-1.5">현재 분석 정보</div>
|
||||
<div className="text-caption font-bold text-color-accent mb-1.5">현재 분석 정보</div>
|
||||
<div
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}
|
||||
className="text-[9px]"
|
||||
className="text-caption"
|
||||
>
|
||||
<InfoItem label="사고명" value={incidentName} />
|
||||
<InfoItem label="유종" value={initOilType} />
|
||||
@ -281,7 +281,7 @@ export function RecalcModal({
|
||||
<FieldGroup label="유출 위치 (좌표)">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex-1">
|
||||
<div className="text-[8px] text-fg-disabled mb-[3px]">위도 (N)</div>
|
||||
<div className="text-caption text-fg-disabled mb-[3px]">위도 (N)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
@ -291,7 +291,7 @@ export function RecalcModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[8px] text-fg-disabled mb-[3px]">경도 (E)</div>
|
||||
<div className="text-caption text-fg-disabled mb-[3px]">경도 (E)</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
@ -347,7 +347,7 @@ export function RecalcModal({
|
||||
background: 'var(--bg-card)',
|
||||
opacity: phase !== 'editing' ? 0.5 : 1,
|
||||
}}
|
||||
className="flex-1 text-[12px] font-semibold border border-stroke text-fg-sub cursor-pointer"
|
||||
className="flex-1 text-label-1 font-semibold border border-stroke text-fg-sub cursor-pointer"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
@ -378,7 +378,7 @@ export function RecalcModal({
|
||||
: '#fff',
|
||||
opacity: models.size === 0 && phase === 'editing' ? 0.5 : 1,
|
||||
}}
|
||||
className="flex-[2] text-[12px] font-bold"
|
||||
className="flex-[2] text-label-1 font-bold"
|
||||
>
|
||||
{phase === 'done'
|
||||
? '✅ 재계산 완료!'
|
||||
@ -395,7 +395,7 @@ export function RecalcModal({
|
||||
function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-fg-sub mb-1.5">{label}</div>
|
||||
<div className="text-caption font-bold text-fg-sub mb-1.5">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -442,7 +442,7 @@ export function RightPanel({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[30px] h-[30px] rounded-md flex items-center justify-center text-[15px]"
|
||||
className="w-[30px] h-[30px] rounded-md flex items-center justify-center text-subtitle"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
|
||||
@ -410,8 +410,8 @@ const S = {
|
||||
marginBottom: '24px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-korean)',
|
||||
fontSize: 'var(--font-size-label-1)',
|
||||
lineHeight: '1.6',
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
@ -421,14 +421,14 @@ const S = {
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
color: 'var(--color-accent)',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontSize: 'var(--font-size-title-4)',
|
||||
fontWeight: 700,
|
||||
marginBottom: '12px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
},
|
||||
subHeader: {
|
||||
fontSize: '14px',
|
||||
fontSize: 'var(--font-size-title-3)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-accent)',
|
||||
marginBottom: '12px',
|
||||
@ -439,7 +439,7 @@ const S = {
|
||||
width: '100%',
|
||||
tableLayout: 'fixed' as const,
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
th: {
|
||||
@ -449,20 +449,20 @@ const S = {
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-sub)',
|
||||
textAlign: 'center' as const,
|
||||
fontSize: '10px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
},
|
||||
td: {
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '5px 10px',
|
||||
textAlign: 'center' as const,
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
color: 'var(--fg-sub)',
|
||||
},
|
||||
tdLeft: {
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '5px 10px',
|
||||
textAlign: 'left' as const,
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
color: 'var(--fg-sub)',
|
||||
},
|
||||
thLabel: {
|
||||
@ -472,7 +472,7 @@ const S = {
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-sub)',
|
||||
textAlign: 'left' as const,
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
width: '120px',
|
||||
},
|
||||
mapPlaceholder: {
|
||||
@ -485,7 +485,7 @@ const S = {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--fg-disabled)',
|
||||
fontSize: '13px',
|
||||
fontSize: 'var(--font-size-title-4)',
|
||||
fontWeight: 600,
|
||||
marginBottom: '16px',
|
||||
},
|
||||
@ -498,7 +498,7 @@ const inputStyle: React.CSSProperties = {
|
||||
border: '1px solid var(--stroke-light)',
|
||||
borderRadius: '3px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
};
|
||||
@ -546,7 +546,7 @@ function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string })
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="px-3 py-1 text-[10px] font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
|
||||
className="px-3 py-1 text-label-2 font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
|
||||
>
|
||||
+ {label || '행 추가'}
|
||||
</button>
|
||||
@ -569,11 +569,11 @@ function Page1({
|
||||
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div
|
||||
className="text-color-accent text-[18px] font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
|
||||
className="text-color-accent text-title-1 font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
|
||||
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.2)' }}
|
||||
>
|
||||
유류오염사고 대응지원 상황도
|
||||
@ -713,7 +713,7 @@ function Page2({
|
||||
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>2. 해양기상정보</div>
|
||||
@ -969,7 +969,7 @@ function Page3({
|
||||
}) {
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>분석</div>
|
||||
@ -985,7 +985,7 @@ function Page3({
|
||||
border: '1px solid var(--stroke-light)',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
fontSize: '13px',
|
||||
fontSize: 'var(--font-size-title-4)',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
lineHeight: '1.8',
|
||||
@ -1002,7 +1002,7 @@ function Page3({
|
||||
padding: '16px',
|
||||
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||||
fontStyle: data.analysis ? 'normal' : 'italic',
|
||||
fontSize: '13px',
|
||||
fontSize: 'var(--font-size-title-4)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: '1.8',
|
||||
}}
|
||||
@ -1684,7 +1684,7 @@ function Page4({
|
||||
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||
@ -1820,7 +1820,9 @@ function Page4({
|
||||
<tbody>
|
||||
{data.esi.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ ...S.td, fontWeight: 600, fontSize: '10px' }}>{e.code}</td>
|
||||
<td style={{ ...S.td, fontWeight: 600, fontSize: 'var(--font-size-caption)' }}>
|
||||
{e.code}
|
||||
</td>
|
||||
<td style={S.tdLeft}>{e.type}</td>
|
||||
<ECell
|
||||
value={e.length}
|
||||
@ -2229,7 +2231,7 @@ function Page5({
|
||||
};
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||||
@ -2283,7 +2285,7 @@ function Page6({
|
||||
};
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>5. 방제전략 수립·실행</div>
|
||||
@ -2414,7 +2416,7 @@ function Page6({
|
||||
border: '1px solid var(--stroke-light)',
|
||||
borderRadius: '4px',
|
||||
padding: '12px',
|
||||
fontSize: '12px',
|
||||
fontSize: 'var(--font-size-label-1)',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
@ -2430,7 +2432,7 @@ function Page6({
|
||||
padding: '12px',
|
||||
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||||
fontStyle: data.etcEquipment ? 'normal' : 'italic',
|
||||
fontSize: '12px',
|
||||
fontSize: 'var(--font-size-label-1)',
|
||||
}}
|
||||
>
|
||||
{data.etcEquipment || '-'}
|
||||
@ -2458,7 +2460,7 @@ function Page7({
|
||||
onChange({ ...data, result: { ...data.result, [k]: v } });
|
||||
return (
|
||||
<div style={S.page}>
|
||||
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
|
||||
<div className="absolute top-2.5 right-4 text-caption text-fg-disabled font-semibold">
|
||||
해양오염방제지원시스템
|
||||
</div>
|
||||
<div style={S.sectionTitle}>방제선/자원 동원 결과</div>
|
||||
@ -2591,25 +2593,25 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-3 py-1.5 text-[12px] font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
|
||||
className="px-3 py-1.5 text-label-1 font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
|
||||
>
|
||||
← 돌아가기
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-[18px] font-bold">
|
||||
<h2 className="text-title-1 font-bold">
|
||||
{editing ? (
|
||||
<input
|
||||
value={data.title}
|
||||
onChange={(e) => setData({ ...data, title: e.target.value })}
|
||||
placeholder="보고서 제목 입력"
|
||||
className="text-[18px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
|
||||
className="text-title-1 font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
|
||||
/>
|
||||
) : (
|
||||
data.title || '유류오염사고 대응지원 상황도'
|
||||
)}
|
||||
</h2>
|
||||
<span
|
||||
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border"
|
||||
className="px-2.5 py-[3px] text-label-2 font-semibold rounded border"
|
||||
style={
|
||||
editing
|
||||
? {
|
||||
@ -2630,7 +2632,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('all')}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
viewMode === 'all'
|
||||
@ -2644,7 +2646,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('page')}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
viewMode === 'page'
|
||||
@ -2659,14 +2661,14 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
{editing && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
|
||||
className="px-4 py-1.5 text-label-2 font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
|
||||
>
|
||||
인쇄 / PDF
|
||||
</button>
|
||||
@ -2680,7 +2682,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i)}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
currentPage === i
|
||||
@ -2707,7 +2709,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
|
||||
className="px-5 py-2 text-label-1 font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
|
||||
style={{
|
||||
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
|
||||
opacity: currentPage === 0 ? 0.4 : 1,
|
||||
@ -2715,13 +2717,13 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-4 py-2 text-[12px] text-fg-sub">
|
||||
<span className="px-4 py-2 text-label-1 text-fg-sub">
|
||||
{currentPage + 1} / {pages.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
|
||||
disabled={currentPage === pages.length - 1}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
|
||||
className="px-5 py-2 text-label-1 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border: '1px solid var(--color-accent)',
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
|
||||
@ -38,15 +38,22 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
|
||||
{/* 라벨 */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span
|
||||
className="text-[11px] font-bold font-korean px-2 py-0.5 rounded"
|
||||
style={{ background: 'rgba(6,182,212,0.12)', color: '#06b6d4', border: '1px solid rgba(6,182,212,0.25)' }}
|
||||
className="text-label-2 font-bold font-korean px-2 py-0.5 rounded"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-accent) 25%, transparent)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 지도 + 캡처 오버레이 */}
|
||||
<div className="relative rounded-lg border border-stroke overflow-hidden" style={{ aspectRatio: '16/9' }}>
|
||||
<div
|
||||
className="relative rounded-lg border border-stroke overflow-hidden"
|
||||
style={{ aspectRatio: '16/9' }}
|
||||
>
|
||||
<MapView
|
||||
center={mapData.center}
|
||||
zoom={mapData.zoom}
|
||||
@ -66,19 +73,28 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
|
||||
<div className="absolute top-2 right-2 z-10" style={{ width: '180px' }}>
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid rgba(6,182,212,0.5)', boxShadow: '0 4px 16px rgba(0,0,0,0.5)' }}
|
||||
style={{
|
||||
border: '1px solid color-mix(in srgb, var(--color-accent) 50%, transparent)',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<img src={captured} alt={`${label} 캡처`} className="w-full block" />
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-1"
|
||||
style={{ background: 'rgba(15,23,42,0.85)', borderTop: '1px solid rgba(6,182,212,0.3)' }}
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
borderTop: '1px solid color-mix(in srgb, var(--color-accent) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[9px] font-korean font-semibold" style={{ color: '#06b6d4' }}>
|
||||
<span
|
||||
className="text-caption font-korean font-semibold"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
📷 캡처 완료
|
||||
</span>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-[9px] font-korean hover:text-fg transition-colors"
|
||||
className="text-caption font-korean hover:text-fg transition-colors"
|
||||
style={{ color: 'rgba(148,163,184,0.8)' }}
|
||||
>
|
||||
다시
|
||||
@ -91,17 +107,21 @@ const MapSlot = ({ label, step, mapData, captured, onCapture, onReset }: MapSlot
|
||||
|
||||
{/* 캡처 버튼 */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<p className="text-[9px] text-fg-disabled font-korean">
|
||||
<p className="text-caption text-fg-disabled font-korean">
|
||||
{captured ? 'PDF 출력 시 포함됩니다.' : '원하는 범위를 선택 후 캡처하세요.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCapture}
|
||||
disabled={isCapturing || !!captured}
|
||||
className="px-2.5 py-1 text-[10px] font-semibold rounded transition-all font-korean flex items-center gap-1"
|
||||
className="px-2.5 py-1 text-label-2 font-semibold rounded transition-all font-korean flex items-center gap-1"
|
||||
style={{
|
||||
background: captured ? 'rgba(6,182,212,0.06)' : 'rgba(6,182,212,0.12)',
|
||||
border: '1px solid rgba(6,182,212,0.4)',
|
||||
color: captured ? 'rgba(6,182,212,0.5)' : '#06b6d4',
|
||||
background: captured
|
||||
? 'color-mix(in srgb, var(--color-accent) 6%, transparent)'
|
||||
: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-accent) 40%, transparent)',
|
||||
color: captured
|
||||
? 'color-mix(in srgb, var(--color-accent) 50%, transparent)'
|
||||
: 'var(--color-accent)',
|
||||
opacity: isCapturing ? 0.6 : 1,
|
||||
cursor: captured ? 'default' : 'pointer',
|
||||
}}
|
||||
@ -124,7 +144,7 @@ const OilSpreadMapPanel = ({
|
||||
}: OilSpreadMapPanelProps) => {
|
||||
if (!mapData) {
|
||||
return (
|
||||
<div className="w-full h-[200px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean mb-4">
|
||||
<div className="w-full h-[200px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean mb-4">
|
||||
확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요.
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -305,8 +305,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-stroke bg-bg-surface">
|
||||
<h2 className="text-[16px] font-bold text-fg font-korean">보고서 생성</h2>
|
||||
<p className="text-[11px] text-fg-disabled font-korean mt-1">
|
||||
<h2 className="text-title-2 font-bold text-fg font-korean">보고서 생성</h2>
|
||||
<p className="text-label-2 text-fg-disabled font-korean mt-1">
|
||||
보고서 유형을 선택하고 포함할 섹션을 구성하여 보고서를 생성합니다.
|
||||
</p>
|
||||
|
||||
@ -329,12 +329,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
>
|
||||
<div className="text-[22px] mb-1">{c.icon}</div>
|
||||
<div
|
||||
className="text-[12px] font-bold"
|
||||
className="text-label-1 font-bold"
|
||||
style={{ color: isActive ? c.color : 'var(--fg-disabled)' }}
|
||||
>
|
||||
{c.label}
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-disabled mt-0.5">{c.desc}</div>
|
||||
<div className="text-caption text-fg-disabled mt-0.5">{c.desc}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@ -346,7 +346,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<div className="w-[250px] min-w-[250px] border-r border-stroke bg-bg-surface flex flex-col overflow-y-auto shrink-0">
|
||||
{/* 템플릿 선택 */}
|
||||
<div className="px-4 py-3 border-b border-stroke">
|
||||
<h3 className="text-[11px] font-bold text-fg-sub font-korean flex items-center gap-2">
|
||||
<h3 className="text-label-2 font-bold text-fg-sub font-korean flex items-center gap-2">
|
||||
📄 보고서 템플릿
|
||||
</h3>
|
||||
</div>
|
||||
@ -361,9 +361,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
background: selectedTemplate === i ? cat.bgActive : 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<span className="text-[14px]">{tmpl.icon}</span>
|
||||
<span className="text-title-3">{tmpl.icon}</span>
|
||||
<span
|
||||
className="text-[11px] font-semibold"
|
||||
className="text-label-2 font-semibold"
|
||||
style={{ color: selectedTemplate === i ? cat.color : 'var(--fg-sub)' }}
|
||||
>
|
||||
{tmpl.label}
|
||||
@ -374,7 +374,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
|
||||
{/* 섹션 체크 */}
|
||||
<div className="px-4 py-3 border-b border-stroke">
|
||||
<h3 className="text-[11px] font-bold text-fg-sub font-korean flex items-center gap-2">
|
||||
<h3 className="text-label-2 font-bold text-fg-sub font-korean flex items-center gap-2">
|
||||
📋 포함 섹션
|
||||
</h3>
|
||||
</div>
|
||||
@ -391,7 +391,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[18px] h-[18px] rounded shrink-0 mt-[1px] flex items-center justify-center text-[10px]"
|
||||
className="w-[18px] h-[18px] rounded shrink-0 mt-[1px] flex items-center justify-center text-label-2"
|
||||
style={{
|
||||
background: sec.checked ? cat.color : 'var(--bg-card)',
|
||||
color: sec.checked ? '#fff' : 'transparent',
|
||||
@ -402,15 +402,15 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[12px]">{sec.icon}</span>
|
||||
<span className="text-label-1">{sec.icon}</span>
|
||||
<span
|
||||
className="text-[11px] font-bold"
|
||||
className="text-label-2 font-bold"
|
||||
style={{ color: sec.checked ? 'var(--fg-default)' : 'var(--fg-disabled)' }}
|
||||
>
|
||||
{sec.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[9px] text-fg-disabled mt-0.5">{sec.desc}</p>
|
||||
<p className="text-caption text-fg-disabled mt-0.5">{sec.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@ -421,10 +421,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<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-stroke bg-bg-surface">
|
||||
<h3 className="text-[13px] font-bold text-fg font-korean flex items-center gap-2">
|
||||
<h3 className="text-title-4 font-bold text-fg font-korean flex items-center gap-2">
|
||||
📄 보고서 미리보기
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded"
|
||||
className="text-label-2 font-semibold px-2 py-0.5 rounded"
|
||||
style={{ background: cat.bgActive, color: cat.color }}
|
||||
>
|
||||
{cat.templates[selectedTemplate].label}
|
||||
@ -433,7 +433,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<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"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded transition-all font-korean flex items-center gap-1.5"
|
||||
style={{
|
||||
background: cat.bgActive,
|
||||
border: `1px solid ${cat.borderColor}`,
|
||||
@ -444,7 +444,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded border border-stroke bg-bg-elevated text-fg hover:bg-bg-surface-hover transition-all font-korean flex items-center gap-1.5"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded border border-stroke bg-bg-elevated text-fg hover:bg-bg-surface-hover transition-all font-korean flex items-center gap-1.5"
|
||||
>
|
||||
💾 저장
|
||||
</button>
|
||||
@ -456,11 +456,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{/* Report Header */}
|
||||
<div className="rounded-lg border border-stroke p-8 mb-6 bg-bg-elevated">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-fg-disabled font-korean mb-2">
|
||||
<p className="text-label-2 text-fg-disabled font-korean mb-2">
|
||||
해양환경 위기대응 통합지원시스템
|
||||
</p>
|
||||
<h2 className="text-[20px] font-bold text-fg font-korean mb-2">{cat.reportName}</h2>
|
||||
<p className="text-[12px] font-korean" style={{ color: cat.color }}>
|
||||
<p className="text-label-1 font-korean" style={{ color: cat.color }}>
|
||||
{cat.templates[selectedTemplate].label}
|
||||
</p>
|
||||
</div>
|
||||
@ -473,7 +473,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
className="rounded-lg border border-stroke mb-4 overflow-hidden bg-bg-elevated"
|
||||
>
|
||||
<div className="px-5 py-3 border-b border-stroke">
|
||||
<h4 className="text-[13px] font-bold text-fg font-korean flex items-center gap-2">
|
||||
<h4 className="text-title-4 font-bold text-fg font-korean flex items-center gap-2">
|
||||
{sec.icon} {sec.title}
|
||||
</h4>
|
||||
</div>
|
||||
@ -492,7 +492,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
/>
|
||||
{oilPayload?.spreadSteps && oilPayload.spreadSteps.length > 0 && (
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[11px]">
|
||||
<table className="w-full border-collapse text-label-2">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke bg-bg-card">
|
||||
<th className="px-3 py-2 text-center font-semibold text-fg-disabled font-korean">
|
||||
@ -558,7 +558,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<p className="text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<p className="text-label-2 text-fg-disabled font-korean mb-1">
|
||||
{m.label}
|
||||
</p>
|
||||
<p
|
||||
@ -576,7 +576,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<>
|
||||
{oilPayload && !oilPayload.hasSimulation && (
|
||||
<div
|
||||
className="mb-3 px-3 py-2 rounded text-[10px] font-korean"
|
||||
className="mb-3 px-3 py-2 rounded text-label-2 font-korean"
|
||||
style={{
|
||||
background: 'rgba(249,115,22,0.08)',
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
@ -615,16 +615,16 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
],
|
||||
].map((row, i) => (
|
||||
<tr key={i} className="border-b border-stroke">
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
|
||||
{row[0]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg font-mono font-semibold text-right">
|
||||
<td className="px-4 py-3 text-label-1 text-fg font-mono font-semibold text-right">
|
||||
{row[1]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[11px] text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
|
||||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean bg-[rgba(255,255,255,0.02)]">
|
||||
{row[2]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg font-mono font-semibold text-right">
|
||||
<td className="px-4 py-3 text-label-1 text-fg font-mono font-semibold text-right">
|
||||
{row[3]}
|
||||
</td>
|
||||
</tr>
|
||||
@ -638,7 +638,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
const resources = oilPayload?.sensitiveResources;
|
||||
if (!resources || resources.length === 0) {
|
||||
return (
|
||||
<p className="text-[12px] text-fg-disabled font-korean italic">
|
||||
<p className="text-label-1 text-fg-disabled font-korean italic">
|
||||
현재 민감자원 데이터가 없습니다.
|
||||
</p>
|
||||
);
|
||||
@ -652,13 +652,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-stroke">
|
||||
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-left bg-[rgba(255,255,255,0.02)]">
|
||||
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-left bg-[rgba(255,255,255,0.02)]">
|
||||
구분
|
||||
</th>
|
||||
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
|
||||
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
|
||||
개소
|
||||
</th>
|
||||
<th className="px-4 py-2 text-[11px] text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
|
||||
<th className="px-4 py-2 text-label-2 text-fg-disabled font-korean text-right bg-[rgba(255,255,255,0.02)]">
|
||||
면적
|
||||
</th>
|
||||
</tr>
|
||||
@ -666,14 +666,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<tbody>
|
||||
{resources.map((r, i) => (
|
||||
<tr key={i} className="border-b border-stroke">
|
||||
<td className="px-4 py-3 text-[12px] text-fg font-korean">
|
||||
<td className="px-4 py-3 text-label-1 text-fg font-korean">
|
||||
{r.category}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg text-right">
|
||||
<td className="px-4 py-3 text-label-1 text-fg text-right">
|
||||
<span className="font-mono">{r.count}</span>
|
||||
<span className="font-korean">개소</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-fg font-mono text-right">
|
||||
<td className="px-4 py-3 text-label-1 text-fg font-mono text-right">
|
||||
{r.totalArea != null ? `${r.totalArea.toFixed(2)} ha` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -686,7 +686,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
(() => {
|
||||
if (!oilPayload) {
|
||||
return (
|
||||
<p className="text-[12px] text-fg-disabled font-korean italic">
|
||||
<p className="text-label-1 text-fg-disabled font-korean italic">
|
||||
현재 해안 부착 데이터가 없습니다.
|
||||
</p>
|
||||
);
|
||||
@ -696,14 +696,14 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
!coastLength || coastLength === '—' || coastLength.startsWith('0.00');
|
||||
if (hasNoCoastal) {
|
||||
return (
|
||||
<p className="text-[12px] text-fg-sub font-korean">
|
||||
<p className="text-label-1 text-fg-sub font-korean">
|
||||
시뮬레이션 결과 유출유의{' '}
|
||||
<span className="font-semibold text-fg">해안 부착이 없습니다</span>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-[12px] text-fg-sub font-korean">
|
||||
<p className="text-label-1 text-fg-sub font-korean">
|
||||
최초 부착시간:{' '}
|
||||
<span className="font-semibold text-fg">
|
||||
{oilPayload.coastal?.firstTime ?? '—'}
|
||||
@ -715,9 +715,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
);
|
||||
})()}
|
||||
{sec.id === 'oil-defense' && (
|
||||
<div className="text-[12px] text-fg-disabled font-korean">
|
||||
<div className="text-label-1 text-fg-disabled font-korean">
|
||||
<p className="mb-2">방제자원 배치 계획에 따른 전략을 수립합니다.</p>
|
||||
<div className="w-full h-[100px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean">
|
||||
<div className="w-full h-[100px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean">
|
||||
[방제자원 배치 지도]
|
||||
</div>
|
||||
</div>
|
||||
@ -727,7 +727,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
const wx = oilPayload?.weather;
|
||||
if (!wx) {
|
||||
return (
|
||||
<p className="text-[12px] text-fg-disabled font-korean italic">
|
||||
<p className="text-label-1 text-fg-disabled font-korean italic">
|
||||
현재 조석·기상 데이터가 없습니다.
|
||||
</p>
|
||||
);
|
||||
@ -767,11 +767,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-[11px] font-semibold text-accent-1 font-korean">
|
||||
<span className="text-label-2 font-semibold text-accent-1 font-korean">
|
||||
{stationLabel}
|
||||
</span>
|
||||
{capturedAt && (
|
||||
<span className="text-[10px] text-fg-disabled font-korean">
|
||||
<span className="text-label-2 text-fg-disabled font-korean">
|
||||
수집: {capturedAt}
|
||||
</span>
|
||||
)}
|
||||
@ -779,10 +779,10 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-fg-disabled font-korean w-[64px] shrink-0">
|
||||
<span className="text-label-2 text-fg-disabled font-korean w-[64px] shrink-0">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="text-[12px] font-semibold text-fg font-mono">
|
||||
<span className="text-label-1 font-semibold text-fg font-mono">
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
@ -803,7 +803,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
style={{ maxHeight: '300px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[140px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean mb-4">
|
||||
<div className="w-full h-[140px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean mb-4">
|
||||
[대기확산 예측 지도]
|
||||
</div>
|
||||
)}
|
||||
@ -832,7 +832,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<p className="text-[10px] text-fg-disabled font-korean mb-1">
|
||||
<p className="text-label-2 text-fg-disabled font-korean mb-1">
|
||||
{m.label}
|
||||
</p>
|
||||
<p
|
||||
@ -841,7 +841,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
>
|
||||
{m.value}
|
||||
</p>
|
||||
<p className="text-[8px] text-fg-disabled font-korean mt-1">{m.desc}</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
{m.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -877,26 +879,29 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<p
|
||||
className="text-[9px] font-bold font-korean mb-1"
|
||||
className="text-caption font-bold font-korean mb-1"
|
||||
style={{ color: h.color }}
|
||||
>
|
||||
{h.label}
|
||||
</p>
|
||||
<p className="text-[18px] font-bold font-mono" style={{ color: h.color }}>
|
||||
<p
|
||||
className="text-title-1 font-bold font-mono"
|
||||
style={{ color: h.color }}
|
||||
>
|
||||
{h.value}
|
||||
</p>
|
||||
{h.area && (
|
||||
<p className="text-[10px] text-fg-disabled font-mono mt-0.5">
|
||||
<p className="text-label-2 text-fg-disabled font-mono mt-0.5">
|
||||
{h.area}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[8px] text-fg-disabled font-korean mt-1">{h.desc}</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">{h.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'hns-substance' && (
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div className="grid grid-cols-2 gap-2 text-label-2">
|
||||
{[
|
||||
{ k: '물질명', v: hnsPayload?.substance.name || '—' },
|
||||
{ k: 'UN번호', v: hnsPayload?.substance.un || '—' },
|
||||
@ -913,7 +918,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
))}
|
||||
<div className="col-span-2 flex justify-between px-3 py-2 bg-bg-surface rounded border border-[rgba(239,68,68,0.3)]">
|
||||
<span className="text-fg-disabled font-korean">독성기준</span>
|
||||
<span className="text-[var(--color-danger)] font-semibold font-mono text-[10px]">
|
||||
<span className="text-[var(--color-danger)] font-semibold font-mono text-label-2">
|
||||
{hnsPayload?.substance.toxicity || '—'}
|
||||
</span>
|
||||
</div>
|
||||
@ -921,7 +926,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
)}
|
||||
{sec.id === 'hns-ppe' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-fg-disabled font-korean text-[11px]">—</span>
|
||||
<span className="text-fg-disabled font-korean text-label-2">—</span>
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'hns-facility' && (
|
||||
@ -935,15 +940,17 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<div className="text-[18px] mb-1">{f.icon}</div>
|
||||
<p className="text-[14px] font-bold text-fg font-mono">{f.value}</p>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1">{f.label}</p>
|
||||
<div className="text-title-1 mb-1">{f.icon}</div>
|
||||
<p className="text-title-3 font-bold text-fg font-mono">{f.value}</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
{f.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'hns-3d' && (
|
||||
<div className="w-full h-[160px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-[12px] font-korean">
|
||||
<div className="w-full h-[160px] bg-bg-card border border-stroke rounded-lg flex items-center justify-center text-fg-disabled text-label-1 font-korean">
|
||||
[3D 농도 분포 시각화]
|
||||
</div>
|
||||
)}
|
||||
@ -967,9 +974,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
|
||||
>
|
||||
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
||||
<p className="text-[13px] font-bold text-fg font-mono">{w.value}</p>
|
||||
<p className="text-[8px] text-fg-disabled font-korean mt-1">{w.label}</p>
|
||||
<div className="text-title-2 mb-0.5">{w.icon}</div>
|
||||
<p className="text-title-4 font-bold text-fg font-mono">{w.value}</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
{w.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -988,8 +997,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
|
||||
>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mb-1">{s.label}</p>
|
||||
<p className="text-[16px] font-bold font-mono" style={{ color: s.color }}>
|
||||
<p className="text-caption text-fg-disabled font-korean mb-1">
|
||||
{s.label}
|
||||
</p>
|
||||
<p
|
||||
className="text-title-2 font-bold font-mono"
|
||||
style={{ color: s.color }}
|
||||
>
|
||||
{s.value}
|
||||
</p>
|
||||
</div>
|
||||
@ -999,7 +1013,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
{sec.id === 'rescue-timeline' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-bg-surface rounded border border-stroke">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">—</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">—</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -1015,17 +1029,19 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mb-1">{c.label}</p>
|
||||
<p className="text-caption text-fg-disabled 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-fg-disabled font-korean mt-0.5">명</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-0.5">명</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sec.id === 'rescue-resource' && (
|
||||
<table className="w-full text-[11px] border-collapse">
|
||||
<table className="w-full text-label-2 border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-stroke">
|
||||
<th className="px-3 py-2 text-left text-fg-disabled font-korean">유형</th>
|
||||
@ -1044,7 +1060,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
<tr className="border-b border-stroke">
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-3 py-3 text-center text-fg-disabled font-korean text-[11px]"
|
||||
className="px-3 py-3 text-center text-fg-disabled font-korean text-label-2"
|
||||
>
|
||||
—
|
||||
</td>
|
||||
@ -1063,8 +1079,13 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-4 text-center"
|
||||
>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mb-1">{g.label}</p>
|
||||
<p className="text-[14px] font-bold font-mono" style={{ color: g.color }}>
|
||||
<p className="text-caption text-fg-disabled font-korean mb-1">
|
||||
{g.label}
|
||||
</p>
|
||||
<p
|
||||
className="text-title-3 font-bold font-mono"
|
||||
style={{ color: g.color }}
|
||||
>
|
||||
{g.value}
|
||||
</p>
|
||||
</div>
|
||||
@ -1083,9 +1104,11 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
|
||||
key={i}
|
||||
className="bg-bg-surface border border-stroke rounded-lg p-3 text-center"
|
||||
>
|
||||
<div className="text-[16px] mb-0.5">{w.icon}</div>
|
||||
<p className="text-[13px] font-bold text-fg font-mono">{w.value}</p>
|
||||
<p className="text-[8px] text-fg-disabled font-korean mt-1">{w.label}</p>
|
||||
<div className="text-title-2 mb-0.5">{w.icon}</div>
|
||||
<p className="text-title-4 font-bold text-fg font-mono">{w.value}</p>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
{w.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -121,11 +121,11 @@ export function ReportsView() {
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-stroke bg-bg-surface">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-fg-disabled font-korean">관할</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">관할</span>
|
||||
<select
|
||||
value={filterJurisdiction}
|
||||
onChange={(e) => setFilterJurisdiction(e.target.value)}
|
||||
className="px-3 py-1.5 text-[11px] bg-bg-elevated border border-stroke rounded text-fg font-korean outline-none focus:border-color-accent"
|
||||
className="px-3 py-1.5 text-label-2 bg-bg-elevated border border-stroke rounded text-fg font-korean outline-none focus:border-color-accent"
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
{jurisdictions.map((j) => (
|
||||
@ -141,9 +141,15 @@ export function ReportsView() {
|
||||
placeholder="보고서명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-52 px-3 py-1.5 text-[11px] bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled font-korean outline-none focus:border-color-accent"
|
||||
className="w-52 px-3 py-1.5 text-label-2 bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled font-korean outline-none focus:border-color-accent"
|
||||
/>
|
||||
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover hover:text-fg transition-all font-korean">
|
||||
<button
|
||||
className="rounded-sm text-label-2 font-semibold cursor-pointer text-fg-sub px-3.5 py-1.5 font-korean"
|
||||
style={{
|
||||
border: '1px solid var(--stroke-default)',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
</button>
|
||||
<button
|
||||
@ -151,7 +157,11 @@ export function ReportsView() {
|
||||
setView({ screen: 'templates' });
|
||||
setActiveSubTab('template');
|
||||
}}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
className="rounded-sm text-label-2 font-semibold cursor-pointer text-color-accent px-3.5 py-1.5 font-korean"
|
||||
style={{
|
||||
border: '1px solid rgba(6,182,212,.3)',
|
||||
background: 'rgba(6,182,212,.08)',
|
||||
}}
|
||||
>
|
||||
+ 수정
|
||||
</button>
|
||||
@ -167,7 +177,7 @@ export function ReportsView() {
|
||||
setView({ screen: 'templates' });
|
||||
setActiveSubTab('template');
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean mt-4"
|
||||
className="px-4 py-2 text-xs font-semibold rounded-md border border-color-accent text-color-accent hover:bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] transition-all font-korean mt-4"
|
||||
>
|
||||
템플릿에서 작성
|
||||
</button>
|
||||
@ -200,43 +210,43 @@ export function ReportsView() {
|
||||
filteredReports.length > 0
|
||||
}
|
||||
onChange={toggleAll}
|
||||
className="accent-[#06b6d4] w-3.5 h-3.5"
|
||||
className="accent-[var(--color-accent)] w-3.5 h-3.5"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
번호
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-disabled text-left font-korean">
|
||||
<th className="px-4 py-3 text-label-2 font-semibold text-fg-disabled text-left font-korean">
|
||||
보고서명
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
보고서 유형
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
분석 종류
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
생성일시
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
작성자
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
관할
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
상태
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
수정
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
지도
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
다운로드
|
||||
</th>
|
||||
<th className="px-3 py-3 text-[11px] font-semibold text-fg-disabled text-center font-korean">
|
||||
<th className="px-3 py-3 text-label-2 font-semibold text-fg-disabled text-center font-korean">
|
||||
삭제
|
||||
</th>
|
||||
</tr>
|
||||
@ -252,10 +262,10 @@ export function ReportsView() {
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(report.id)}
|
||||
onChange={() => toggleSelect(report.id)}
|
||||
className="accent-[#06b6d4] w-3.5 h-3.5"
|
||||
className="accent-[var(--color-accent)] w-3.5 h-3.5"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[12px] text-fg-disabled text-center font-mono">
|
||||
<td className="px-3 py-3 text-label-1 text-fg-disabled text-center font-mono">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td className="px-4 py-3 truncate">
|
||||
@ -268,18 +278,18 @@ export function ReportsView() {
|
||||
setPreviewReport(report);
|
||||
}
|
||||
}}
|
||||
className="text-[12px] font-semibold text-color-accent hover:underline text-left font-korean truncate max-w-full block"
|
||||
className="text-label-1 font-semibold text-color-accent hover:underline text-left font-korean truncate max-w-full block"
|
||||
>
|
||||
{report.title || '제목 없음'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
|
||||
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
|
||||
style={{
|
||||
background:
|
||||
typeColors[report.reportType]?.bg || 'rgba(138,150,168,0.15)',
|
||||
color: typeColors[report.reportType]?.text || '#8a96a8',
|
||||
color: typeColors[report.reportType]?.text || 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{report.reportType}
|
||||
@ -293,7 +303,7 @@ export function ReportsView() {
|
||||
{style ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div
|
||||
className="flex items-center justify-center w-5 h-5 rounded-full text-[11px]"
|
||||
className="flex items-center justify-center w-5 h-5 rounded-full text-label-2"
|
||||
style={{
|
||||
background: style.bg,
|
||||
boxShadow: `0 0 6px ${style.text}25`,
|
||||
@ -302,30 +312,30 @@ export function ReportsView() {
|
||||
{style.icon}
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-semibold font-korean"
|
||||
className="text-caption font-semibold font-korean"
|
||||
style={{ color: style.text }}
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-fg-disabled font-korean">-</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">-</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})()}
|
||||
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-mono">
|
||||
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-mono">
|
||||
{formatDate(report.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-korean">
|
||||
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-korean">
|
||||
{report.author || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[11px] text-fg-sub text-center font-korean">
|
||||
<td className="px-3 py-3 text-label-2 text-fg-sub text-center font-korean">
|
||||
{report.jurisdiction}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
|
||||
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
|
||||
style={{
|
||||
background: statusColors[report.status]?.bg,
|
||||
color: statusColors[report.status]?.text,
|
||||
@ -344,29 +354,29 @@ export function ReportsView() {
|
||||
setView({ screen: 'edit', data: { ...report } });
|
||||
}
|
||||
}}
|
||||
className="text-[11px] text-color-accent hover:underline font-korean"
|
||||
className="text-label-2 text-color-accent hover:underline font-korean"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{report.hasMapCapture || report.capturedMapImage ? (
|
||||
<span title="확산예측 지도 캡처 있음" className="text-[14px]">
|
||||
<span title="확산예측 지도 캡처 있음" className="text-title-3">
|
||||
📷
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-fg-disabled">—</span>
|
||||
<span className="text-label-2 text-fg-disabled">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">
|
||||
<button className="inline-flex items-center gap-1 px-2 py-1 text-caption font-semibold rounded bg-[color-mix(in_srgb,var(--color-danger)_12%,transparent)] text-color-danger border border-[color-mix(in_srgb,var(--color-danger)_25%,transparent)] hover:bg-[color-mix(in_srgb,var(--color-danger)_20%,transparent)] transition-all">
|
||||
PDF
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(report.id)}
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm"
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-color-danger hover:bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] transition-all text-sm"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
@ -473,18 +483,18 @@ export function ReportsView() {
|
||||
)[previewReport.reportType] || '📄'}
|
||||
</div>
|
||||
<div
|
||||
className="text-center font-korean text-[13px] font-bold leading-snug"
|
||||
className="text-center font-korean text-title-4 font-bold leading-snug"
|
||||
style={{ wordBreak: 'keep-all' }}
|
||||
>
|
||||
{previewReport.title || '제목 없음'}
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean"
|
||||
className="inline-block px-2.5 py-1 text-caption font-semibold rounded font-korean"
|
||||
style={{
|
||||
background:
|
||||
typeColors[previewReport.reportType]?.bg || 'rgba(138,150,168,0.15)',
|
||||
color: typeColors[previewReport.reportType]?.text || '#8a96a8',
|
||||
color: typeColors[previewReport.reportType]?.text || 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
{previewReport.reportType}
|
||||
@ -493,19 +503,21 @@ export function ReportsView() {
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex flex-col gap-2.5 font-korean text-[11px] px-[18px] py-3.5 border-b border-stroke">
|
||||
<div className="flex flex-col gap-2.5 font-korean text-label-2 px-[18px] py-3.5 border-b border-stroke">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">
|
||||
<span className="text-fg-disabled text-caption uppercase tracking-wide">
|
||||
작성자
|
||||
</span>
|
||||
<span className="font-semibold">{previewReport.author || '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">관할</span>
|
||||
<span className="text-fg-disabled text-caption uppercase tracking-wide">
|
||||
관할
|
||||
</span>
|
||||
<span className="font-semibold">{previewReport.jurisdiction}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">
|
||||
<span className="text-fg-disabled text-caption uppercase tracking-wide">
|
||||
생성일시
|
||||
</span>
|
||||
<span className="font-mono font-semibold">
|
||||
@ -513,7 +525,9 @@ export function ReportsView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-fg-disabled text-[9px] uppercase tracking-wide">상태</span>
|
||||
<span className="text-fg-disabled text-caption uppercase tracking-wide">
|
||||
상태
|
||||
</span>
|
||||
<b
|
||||
style={{
|
||||
color: statusColors[previewReport.status]?.text || 'var(--fg-default)',
|
||||
@ -531,7 +545,7 @@ export function ReportsView() {
|
||||
setPreviewReport(null);
|
||||
setView({ screen: 'edit', data: { ...previewReport } });
|
||||
}}
|
||||
className="w-full font-korean text-[11px] font-semibold cursor-pointer rounded-md border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)] text-color-accent py-2"
|
||||
className="w-full font-korean text-label-2 font-semibold cursor-pointer rounded-md border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] text-color-accent py-2"
|
||||
>
|
||||
✏ 수정 모드
|
||||
</button>
|
||||
@ -542,7 +556,7 @@ export function ReportsView() {
|
||||
|
||||
{/* 하단 다운로드 버튼 */}
|
||||
<div className="flex flex-col gap-2 px-4 py-3.5 border-t border-stroke">
|
||||
<div className="text-center font-korean mb-0.5 text-[9px] text-fg-disabled">
|
||||
<div className="text-center font-korean mb-0.5 text-caption text-fg-disabled">
|
||||
문서 저장
|
||||
</div>
|
||||
<button
|
||||
@ -567,10 +581,10 @@ export function ReportsView() {
|
||||
exportAsPDF(html, previewReport.title || tpl.label);
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
|
||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-label-1 font-bold cursor-pointer rounded-md py-[11px]"
|
||||
style={{
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 40%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--color-danger) 10%, transparent)',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
@ -606,7 +620,7 @@ export function ReportsView() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
|
||||
className="w-full flex items-center justify-center gap-1.5 font-korean text-label-1 font-bold cursor-pointer rounded-md py-[11px]"
|
||||
style={{
|
||||
border: '1px solid rgba(59,130,246,0.4)',
|
||||
background: 'rgba(59,130,246,0.1)',
|
||||
@ -623,14 +637,14 @@ export function ReportsView() {
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between shrink-0 px-5 py-3.5 border-b border-stroke">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-korean text-[9px] px-2 py-[3px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent font-semibold">
|
||||
<span className="font-korean text-caption px-2 py-[3px] rounded bg-[rgba(6,182,212,0.1)] text-color-accent font-semibold">
|
||||
보기
|
||||
</span>
|
||||
<span className="font-korean text-[12px] text-fg-disabled">보고서 내용</span>
|
||||
<span className="font-korean text-label-1 text-fg-disabled">보고서 내용</span>
|
||||
</div>
|
||||
<span
|
||||
onClick={() => setPreviewReport(null)}
|
||||
className="text-[18px] cursor-pointer text-fg-disabled leading-none hover:text-fg transition-colors"
|
||||
className="text-title-1 cursor-pointer text-fg-disabled leading-none hover:text-fg transition-colors"
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
@ -642,12 +656,12 @@ export function ReportsView() {
|
||||
{/* 1. 사고개요 */}
|
||||
<div>
|
||||
<div
|
||||
className="font-korean text-[12px] font-bold text-color-accent border-b pb-1 mb-2"
|
||||
className="font-korean text-label-1 font-bold text-color-accent border-b pb-1 mb-2"
|
||||
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
|
||||
>
|
||||
1. 사고개요
|
||||
</div>
|
||||
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
|
||||
<div className="font-korean text-label-1 leading-[1.7] whitespace-pre-wrap mt-2">
|
||||
{[
|
||||
previewReport.incident.name && `사고명: ${previewReport.incident.name}`,
|
||||
previewReport.incident.occurTime &&
|
||||
@ -667,12 +681,12 @@ export function ReportsView() {
|
||||
{/* 2. 유출현황 */}
|
||||
<div>
|
||||
<div
|
||||
className="font-korean text-[12px] font-bold text-color-accent border-b pb-1 mb-2"
|
||||
className="font-korean text-label-1 font-bold text-color-accent border-b pb-1 mb-2"
|
||||
style={{ borderColor: 'rgba(6,182,212,0.15)' }}
|
||||
>
|
||||
2. 유출현황
|
||||
</div>
|
||||
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
|
||||
<div className="font-korean text-label-1 leading-[1.7] whitespace-pre-wrap mt-2">
|
||||
{[
|
||||
previewReport.incident.pollutant &&
|
||||
`유출유종: ${previewReport.incident.pollutant}`,
|
||||
@ -698,7 +712,7 @@ export function ReportsView() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{previewReport.step3MapImage && (
|
||||
<div className="relative">
|
||||
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||
<span className="absolute top-1.5 left-1.5 z-10 text-caption font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||
3시간 후 예측
|
||||
</span>
|
||||
<img
|
||||
@ -711,7 +725,7 @@ export function ReportsView() {
|
||||
)}
|
||||
{previewReport.step6MapImage && (
|
||||
<div className="relative">
|
||||
<span className="absolute top-1.5 left-1.5 z-10 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||
<span className="absolute top-1.5 left-1.5 z-10 text-caption font-semibold px-1.5 py-0.5 rounded bg-cyan-500/70 text-white">
|
||||
6시간 후 예측
|
||||
</span>
|
||||
<img
|
||||
|
||||
@ -91,7 +91,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 16,
|
||||
fontSize: '9px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
color: 'var(--fg-disabled)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
@ -102,14 +102,14 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
{/* Section header */}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
background: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontSize: 'var(--font-size-title-4)',
|
||||
fontWeight: 700,
|
||||
marginBottom: '16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid rgba(6,182,212,0.2)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-accent) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
@ -121,7 +121,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
width: '100%',
|
||||
tableLayout: 'fixed',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
@ -139,7 +139,11 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
style={{ border: '1px solid var(--stroke-default)', padding: '12px 14px' }}
|
||||
>
|
||||
<span
|
||||
style={{ color: 'var(--fg-disabled)', fontStyle: 'italic', fontSize: '11px' }}
|
||||
style={{
|
||||
color: 'var(--fg-disabled)',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
{section.title} — 자동 계산 데이터 (확인 전용)
|
||||
</span>
|
||||
@ -161,7 +165,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-sub)',
|
||||
verticalAlign: 'top',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
@ -182,11 +186,10 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
border: '1px solid var(--stroke-light)',
|
||||
borderRadius: '3px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
fontSize: 'var(--font-size-label-1)',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
color: 'var(--fg-default)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@ -206,7 +209,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-sub)',
|
||||
verticalAlign: 'middle',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
@ -220,12 +223,12 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
color: 'var(--fg-sub)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input type="checkbox" style={{ accentColor: '#06b6d4' }} />
|
||||
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} />
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
@ -246,7 +249,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
fontWeight: 600,
|
||||
color: 'var(--fg-sub)',
|
||||
verticalAlign: 'middle',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
@ -260,7 +263,7 @@ function SectionBlock({ section, getVal, setVal }: SectionBlockProps) {
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '11px',
|
||||
fontSize: 'var(--font-size-caption)',
|
||||
outline: 'none',
|
||||
color: 'var(--fg-default)',
|
||||
padding: '2px 0',
|
||||
@ -353,7 +356,7 @@ export default function TemplateEditPage({
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-[12px] font-semibold text-fg-sub hover:text-fg transition-colors whitespace-nowrap"
|
||||
className="text-label-1 font-semibold text-fg-sub hover:text-fg transition-colors whitespace-nowrap"
|
||||
>
|
||||
← 돌아가기
|
||||
</button>
|
||||
@ -361,14 +364,14 @@ export default function TemplateEditPage({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="보고서 제목 입력"
|
||||
className="text-[17px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-[380px] max-w-[480px]"
|
||||
className="text-title-1 font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-[380px] max-w-[480px]"
|
||||
/>
|
||||
<span
|
||||
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border whitespace-nowrap"
|
||||
className="px-2.5 py-[3px] text-label-2 font-semibold rounded border whitespace-nowrap"
|
||||
style={{
|
||||
background: 'rgba(251,191,36,0.15)',
|
||||
color: '#f59e0b',
|
||||
borderColor: 'rgba(251,191,36,0.3)',
|
||||
background: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
|
||||
color: 'var(--color-warning)',
|
||||
borderColor: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
편집 중
|
||||
@ -377,13 +380,16 @@ export default function TemplateEditPage({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('all')}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
viewMode === 'all'
|
||||
? '1px solid var(--color-accent)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
background: viewMode === 'all' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
|
||||
background:
|
||||
viewMode === 'all'
|
||||
? 'color-mix(in srgb, var(--color-accent) 10%, transparent)'
|
||||
: 'var(--bg-elevated)',
|
||||
color: viewMode === 'all' ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
@ -391,13 +397,16 @@ export default function TemplateEditPage({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('page')}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
viewMode === 'page'
|
||||
? '1px solid var(--color-accent)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
background: viewMode === 'page' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
|
||||
background:
|
||||
viewMode === 'page'
|
||||
? 'color-mix(in srgb, var(--color-accent) 10%, transparent)'
|
||||
: 'var(--bg-elevated)',
|
||||
color: viewMode === 'page' ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
@ -405,13 +414,13 @@ export default function TemplateEditPage({
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
|
||||
className="px-4 py-1.5 text-label-2 font-bold rounded cursor-pointer border border-color-success bg-[color-mix(in_srgb,var(--color-success)_15%,transparent)] text-color-success"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
|
||||
className="px-3.5 py-1.5 text-label-2 font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger"
|
||||
>
|
||||
인쇄 / PDF
|
||||
</button>
|
||||
@ -425,13 +434,16 @@ export default function TemplateEditPage({
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setCurrentPage(i)}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
|
||||
className="px-3 py-1.5 text-label-2 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border:
|
||||
currentPage === i
|
||||
? '1px solid var(--color-accent)'
|
||||
: '1px solid var(--stroke-default)',
|
||||
background: currentPage === i ? 'rgba(6,182,212,0.15)' : 'transparent',
|
||||
background:
|
||||
currentPage === i
|
||||
? 'color-mix(in srgb, var(--color-accent) 15%, transparent)'
|
||||
: 'transparent',
|
||||
color: currentPage === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
>
|
||||
@ -457,7 +469,7 @@ export default function TemplateEditPage({
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
|
||||
className="px-5 py-2 text-label-1 font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
|
||||
style={{
|
||||
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
|
||||
opacity: currentPage === 0 ? 0.4 : 1,
|
||||
@ -465,16 +477,16 @@ export default function TemplateEditPage({
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-4 py-2 text-[12px] text-fg-sub">
|
||||
<span className="px-4 py-2 text-label-1 text-fg-sub">
|
||||
{currentPage + 1} / {sections.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(sections.length - 1, p + 1))}
|
||||
disabled={currentPage === sections.length - 1}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
|
||||
className="px-5 py-2 text-label-1 font-semibold rounded cursor-pointer"
|
||||
style={{
|
||||
border: '1px solid var(--color-accent)',
|
||||
background: 'rgba(6,182,212,0.1)',
|
||||
background: 'color-mix(in srgb, var(--color-accent) 10%, transparent)',
|
||||
color:
|
||||
currentPage === sections.length - 1
|
||||
? 'var(--fg-disabled)'
|
||||
|
||||
@ -108,8 +108,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
{/* Left Sidebar - Template Selection */}
|
||||
<div className="w-60 min-w-[240px] border-r border-stroke bg-bg-surface 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-fg font-korean">표준보고서 템플릿 선택</h3>
|
||||
<p className="text-[9px] text-fg-disabled font-korean mt-1">
|
||||
<h3 className="text-title-4 font-bold text-fg font-korean">표준보고서 템플릿 선택</h3>
|
||||
<p className="text-caption text-fg-disabled font-korean mt-1">
|
||||
템플릿을 선택하면 오른쪽에 작성 양식이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
@ -125,11 +125,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
>
|
||||
<span className="text-lg mb-1">{t.icon}</span>
|
||||
<span
|
||||
className={`text-[12px] font-bold font-korean ${selectedType === t.id ? 'text-color-accent' : 'text-fg'}`}
|
||||
className={`text-label-1 font-bold font-korean ${selectedType === t.id ? 'text-color-accent' : 'text-fg'}`}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
<span className="text-[9px] text-fg-disabled font-korean mt-0.5">{t.desc}</span>
|
||||
<span className="text-caption text-fg-disabled font-korean mt-0.5">{t.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -140,16 +140,19 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-stroke bg-bg-surface">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{template.icon}</span>
|
||||
<span className="text-[15px] font-bold text-fg font-korean">{template.label}</span>
|
||||
<span className="text-subtitle font-bold text-fg 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' }}
|
||||
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
작성중
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-fg-disabled font-korean">자동저장:</span>
|
||||
<span className="text-label-2 text-fg-disabled font-korean">자동저장:</span>
|
||||
<button
|
||||
onClick={() => setAutoSave(!autoSave)}
|
||||
className={`relative w-9 h-[18px] rounded-full transition-all ${autoSave ? 'bg-color-accent' : 'bg-bg-card border border-stroke'}`}
|
||||
@ -159,8 +162,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className="text-[10px] font-semibold font-korean"
|
||||
style={{ color: autoSave ? '#06b6d4' : 'var(--fg-disabled)' }}
|
||||
className="text-label-2 font-semibold font-korean"
|
||||
style={{ color: autoSave ? 'var(--color-accent)' : 'var(--fg-disabled)' }}
|
||||
>
|
||||
{autoSave ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
@ -171,7 +174,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<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 text-cyan-500">
|
||||
<h4 className="text-title-4 font-bold font-korean mb-3 text-color-accent">
|
||||
{section.title}
|
||||
</h4>
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
@ -184,7 +187,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<tr key={fIdx} className="border-b border-stroke">
|
||||
{field.label ? (
|
||||
<>
|
||||
<td className="px-4 py-3 text-[11px] font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] align-middle">
|
||||
<td className="px-4 py-3 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] align-middle">
|
||||
{field.label}
|
||||
</td>
|
||||
<td className="px-4 py-2 align-middle">
|
||||
@ -193,7 +196,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
value={getVal(field.key)}
|
||||
onChange={(e) => setVal(field.key, e.target.value)}
|
||||
placeholder={`${field.label} 입력`}
|
||||
className="w-full bg-transparent text-[12px] text-fg font-korean outline-none placeholder-fg-disabled"
|
||||
className="w-full bg-transparent text-label-1 text-fg font-korean outline-none placeholder-fg-disabled"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'checkbox-group' && field.options && (
|
||||
@ -201,11 +204,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
{field.options.map((opt) => (
|
||||
<label
|
||||
key={opt}
|
||||
className="flex items-center gap-1.5 text-[11px] text-fg-sub font-korean cursor-pointer"
|
||||
className="flex items-center gap-1.5 text-label-2 text-fg-sub font-korean cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[#06b6d4] w-3.5 h-3.5"
|
||||
className="accent-[var(--color-accent)] w-3.5 h-3.5"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
@ -220,7 +223,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
value={getVal(field.key)}
|
||||
onChange={(e) => setVal(field.key, e.target.value)}
|
||||
placeholder="내용을 입력하세요..."
|
||||
className="w-full min-h-[120px] bg-bg-elevated border border-stroke rounded-md px-3 py-2 text-[12px] text-fg font-korean outline-none placeholder-fg-disabled resize-y focus:border-color-accent"
|
||||
className="w-full min-h-[120px] bg-bg-elevated border border-stroke rounded-md px-3 py-2 text-label-1 text-fg font-korean outline-none placeholder-fg-disabled resize-y focus:border-color-accent"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
@ -237,13 +240,13 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => doExport('pdf')}
|
||||
className="px-3 py-2 text-[11px] font-semibold rounded bg-color-danger text-white hover:opacity-90 transition-all"
|
||||
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-danger 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"
|
||||
className="px-3 py-2 text-label-2 font-semibold rounded bg-color-info text-white hover:opacity-90 transition-all"
|
||||
>
|
||||
HWPX
|
||||
</button>
|
||||
@ -251,19 +254,19 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 text-[11px] font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
||||
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
||||
>
|
||||
임시저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPreview(true)}
|
||||
className="px-4 py-2 text-[11px] font-semibold rounded border border-color-accent text-color-accent hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||||
className="px-4 py-2 text-label-2 font-semibold rounded border border-color-accent text-color-accent 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-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
|
||||
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean flex items-center gap-1"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
@ -285,10 +288,15 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{template.icon}</span>
|
||||
<span className="text-[15px] font-bold text-fg font-korean">{template.label}</span>
|
||||
<span className="text-subtitle font-bold text-fg 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' }}
|
||||
className="px-2 py-0.5 text-caption font-semibold rounded font-korean"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
미리보기
|
||||
</span>
|
||||
@ -306,13 +314,13 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="w-full">
|
||||
{/* Report Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-[18px] font-bold text-fg font-korean mb-1">
|
||||
<h2 className="text-title-1 font-bold text-fg font-korean mb-1">
|
||||
해양오염방제지원시스템
|
||||
</h2>
|
||||
<h3 className="text-[15px] font-semibold font-korean text-cyan-500">
|
||||
<h3 className="text-subtitle font-semibold font-korean text-color-accent">
|
||||
{formData['incident.name'] || template.label}
|
||||
</h3>
|
||||
<p className="text-[11px] text-fg-disabled font-korean mt-2">
|
||||
<p className="text-label-2 text-fg-disabled font-korean mt-2">
|
||||
작성일시: {reportMeta.writeTime} | 작성자: {reportMeta.author || '-'} | 관할:{' '}
|
||||
{reportMeta.jurisdiction}
|
||||
</p>
|
||||
@ -322,8 +330,11 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
{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)' }}
|
||||
className="text-title-4 font-bold font-korean mb-2 px-2 py-1.5 rounded"
|
||||
style={{
|
||||
color: 'var(--color-accent)',
|
||||
background: 'color-mix(in srgb, var(--color-accent) 6%, transparent)',
|
||||
}}
|
||||
>
|
||||
{section.title}
|
||||
</h4>
|
||||
@ -337,10 +348,10 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
const val = getVal(field.key);
|
||||
return field.label ? (
|
||||
<tr key={fIdx} className="border-b border-stroke">
|
||||
<td className="px-4 py-2.5 text-[11px] font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] border-r border-stroke align-middle">
|
||||
<td className="px-4 py-2.5 text-label-2 font-semibold text-fg-disabled font-korean bg-[rgba(255,255,255,0.03)] border-r border-stroke align-middle">
|
||||
{field.label}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[12px] text-fg font-korean align-middle">
|
||||
<td className="px-4 py-2.5 text-label-1 text-fg font-korean align-middle">
|
||||
{val || <span className="text-fg-disabled">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@ -348,7 +359,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<tr key={fIdx} className="border-b border-stroke">
|
||||
<td
|
||||
colSpan={2}
|
||||
className="px-4 py-3 text-[12px] text-fg font-korean whitespace-pre-wrap"
|
||||
className="px-4 py-3 text-label-1 text-fg font-korean whitespace-pre-wrap"
|
||||
>
|
||||
{val || <span className="text-fg-disabled">내용 없음</span>}
|
||||
</td>
|
||||
@ -366,7 +377,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-3 border-t border-stroke">
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="px-4 py-2 text-[11px] font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
||||
className="px-4 py-2 text-label-2 font-semibold rounded text-fg-disabled hover:text-fg transition-all font-korean"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
@ -375,7 +386,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) {
|
||||
setShowPreview(false);
|
||||
handleSave();
|
||||
}}
|
||||
className="px-5 py-2 text-[11px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
className="px-5 py-2 text-label-2 font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
|
||||
@ -390,9 +390,9 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
icon: '🧪',
|
||||
label: 'HNS 대기확산',
|
||||
desc: 'ALOHA · WRF-Chem',
|
||||
color: 'var(--color-warning)',
|
||||
borderColor: 'rgba(249,115,22,0.4)',
|
||||
bgActive: 'rgba(249,115,22,0.08)',
|
||||
color: 'var(--color-accent)',
|
||||
borderColor: 'rgba(6,182,212,0.4)',
|
||||
bgActive: 'rgba(6,182,212,0.08)',
|
||||
reportName: 'HNS 대기확산 예측보고서',
|
||||
templates: [
|
||||
{ icon: '🧪', label: 'HNS 예측보고서' },
|
||||
@ -456,9 +456,9 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
icon: '🚨',
|
||||
label: '긴급구난',
|
||||
desc: '복원성 · 좌초위험 분석',
|
||||
color: 'var(--color-danger)',
|
||||
borderColor: 'rgba(239,68,68,0.4)',
|
||||
bgActive: 'rgba(239,68,68,0.08)',
|
||||
color: 'var(--color-accent)',
|
||||
borderColor: 'rgba(6,182,212,0.4)',
|
||||
bgActive: 'rgba(6,182,212,0.08)',
|
||||
reportName: '긴급구난 상황보고서',
|
||||
templates: [
|
||||
{ icon: '🚨', label: '긴급구난 상황보고' },
|
||||
|
||||
@ -25,17 +25,17 @@ interface RescueScenario {
|
||||
}
|
||||
|
||||
const SEV_STYLE: Record<Severity, { bg: string; color: string; label: string }> = {
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: '#f87171', label: 'CRITICAL' },
|
||||
HIGH: { bg: 'rgba(249,115,22,0.15)', color: '#fb923c', label: 'HIGH' },
|
||||
MEDIUM: { bg: 'rgba(251,191,36,0.15)', color: '#fbbf24', label: 'MEDIUM' },
|
||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'RESOLVED' },
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)', label: 'CRITICAL' },
|
||||
HIGH: { bg: 'rgba(249,115,22,0.15)', color: 'var(--color-warning)', label: 'HIGH' },
|
||||
MEDIUM: { bg: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)', label: 'MEDIUM' },
|
||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: 'var(--color-success)', label: 'RESOLVED' },
|
||||
};
|
||||
|
||||
const SEV_COLOR: Record<Severity, string> = {
|
||||
CRITICAL: '#f87171',
|
||||
HIGH: '#fb923c',
|
||||
MEDIUM: '#fbbf24',
|
||||
RESOLVED: '#22c55e',
|
||||
CRITICAL: 'var(--color-danger)',
|
||||
HIGH: 'var(--color-warning)',
|
||||
MEDIUM: 'var(--color-caution)',
|
||||
RESOLVED: 'var(--color-success)',
|
||||
};
|
||||
|
||||
/* ─── Color helpers ─── */
|
||||
@ -198,18 +198,11 @@ export function RescueScenarioView() {
|
||||
<div className="flex flex-col flex-1 w-full h-full overflow-hidden bg-bg-base">
|
||||
{/* ── Header ── */}
|
||||
<div className="px-5 py-3.5 border-b border-stroke flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-[10px] flex items-center justify-center text-lg border border-[rgba(6,182,212,.3)]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,rgba(6,182,212,.2),rgba(59,130,246,.15))',
|
||||
}}
|
||||
>
|
||||
📊
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-base">📊</span>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold">긴급구난 시나리오 관리</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">
|
||||
<div className="text-title-4 font-bold">긴급구난 시나리오 관리</div>
|
||||
<div className="text-label-2 text-fg-disabled">
|
||||
시간 단계별 시나리오 비교·검토 및 구난 의사결정 지원 (SFR-009)
|
||||
</div>
|
||||
</div>
|
||||
@ -218,7 +211,7 @@ export function RescueScenarioView() {
|
||||
<select
|
||||
value={selectedIncident}
|
||||
onChange={(e) => setSelectedIncident(Number(e.target.value))}
|
||||
className="px-3 py-1.5 rounded-md border border-stroke bg-bg-card text-[10px] outline-none"
|
||||
className="prd-i w-[280px] text-label-2"
|
||||
>
|
||||
{ops.map((op, i) => (
|
||||
<option key={op.rescueOpsSn} value={i}>
|
||||
@ -228,8 +221,11 @@ export function RescueScenarioView() {
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setNewScnModalOpen(true)}
|
||||
className="px-3.5 py-1.5 rounded-md border-none text-white text-[10px] font-bold cursor-pointer"
|
||||
style={{ background: 'linear-gradient(135deg,var(--color-accent),#3b82f6)' }}
|
||||
className="cursor-pointer whitespace-nowrap font-semibold text-color-accent text-label-2 px-[14px] py-1.5 rounded-sm"
|
||||
style={{
|
||||
border: '1px solid rgba(6,182,212,.3)',
|
||||
background: 'rgba(6,182,212,.08)',
|
||||
}}
|
||||
>
|
||||
+ 신규 시나리오
|
||||
</button>
|
||||
@ -239,26 +235,25 @@ export function RescueScenarioView() {
|
||||
{/* ── Content: Left List + Right Detail ── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ═══ LEFT: 시나리오 목록 ═══ */}
|
||||
<div className="w-[360px] min-w-[360px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex flex-col overflow-hidden shrink-0 border-r border-stroke bg-bg-surface"
|
||||
style={{ width: '370px', minWidth: '370px' }}
|
||||
>
|
||||
{/* Sort bar */}
|
||||
<div className="px-3.5 py-2.5 border-b border-stroke flex items-center justify-between">
|
||||
<div className="text-[11px] font-bold">
|
||||
📋 시나리오 목록{' '}
|
||||
<span className="font-normal text-fg-disabled text-[9px]">
|
||||
({scenarios.length}개)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-stroke px-[14px] py-2.5">
|
||||
<span className="text-label-2 font-bold text-fg-disabled">
|
||||
시나리오 목록 ({scenarios.length}개)
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{(['time', 'risk'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSortBy(s)}
|
||||
className="px-2 py-px rounded text-[9px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
border: `1px solid ${sortBy === s ? 'rgba(6,182,212,.4)' : 'var(--stroke-default)'}`,
|
||||
background: sortBy === s ? 'rgba(6,182,212,.08)' : 'var(--bg-card)',
|
||||
color: sortBy === s ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
}}
|
||||
className={`cursor-pointer px-2 py-[3px] text-caption font-semibold rounded-sm border border-stroke ${
|
||||
sortBy === s
|
||||
? 'bg-[rgba(6,182,212,0.08)] text-color-accent'
|
||||
: 'bg-bg-card text-fg-disabled'
|
||||
}`}
|
||||
>
|
||||
{s === 'time' ? '시간순' : '위험도순'}
|
||||
</button>
|
||||
@ -267,9 +262,12 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2.5 flex flex-col gap-2 scrollbar-thin">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto flex flex-col gap-1.5 p-2"
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||||
>
|
||||
{loading && scenarios.length === 0 && (
|
||||
<div className="text-center py-10 text-[11px] text-fg-disabled">
|
||||
<div className="text-center py-10 text-label-2 text-fg-disabled">
|
||||
시나리오 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
@ -280,98 +278,83 @@ export function RescueScenarioView() {
|
||||
<div
|
||||
key={sc.id}
|
||||
onClick={() => setSelectedId(sc.id)}
|
||||
className={`hns-scn-card${isSel ? ' sel' : ''} p-3 rounded-md cursor-pointer transition-all`}
|
||||
style={{
|
||||
border: `1px solid ${isSel ? 'rgba(6,182,212,.35)' : 'var(--stroke-default)'}`,
|
||||
background: isSel ? 'rgba(6,182,212,.04)' : 'var(--bg-card)',
|
||||
}}
|
||||
className={`hns-scn-card${isSel ? ' sel' : ''}`}
|
||||
>
|
||||
{/* Top: checkbox + ID + severity */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(sc.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCheck(sc.id);
|
||||
}}
|
||||
className="accent-[var(--color-accent)]"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked.has(sc.id)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleCheck(sc.id);
|
||||
}}
|
||||
style={{ accentColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<span className="text-label-1 font-bold">
|
||||
{sc.id} {sc.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-extrabold font-mono"
|
||||
style={{ color: isSel ? 'var(--color-accent)' : 'var(--fg-default)' }}
|
||||
>
|
||||
{sc.id}
|
||||
</span>
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[8px] font-bold font-mono"
|
||||
className="font-bold px-2 py-[2px] rounded-lg text-caption"
|
||||
style={{ background: sev.bg, color: sev.color }}
|
||||
>
|
||||
{sev.label}
|
||||
</span>
|
||||
<span className="ml-auto text-[9px] text-fg-disabled font-mono">
|
||||
</div>
|
||||
{/* Time row */}
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span
|
||||
className="font-bold font-mono text-color-accent text-caption px-1.5 py-[2px] rounded-[3px]"
|
||||
style={{ background: 'rgba(6,182,212,0.1)' }}
|
||||
>
|
||||
{sc.timeStep}
|
||||
</span>
|
||||
<span className="text-caption text-fg-disabled font-mono">{sc.datetime}</span>
|
||||
</div>
|
||||
{/* Name + time */}
|
||||
<div className="text-[11px] font-bold mb-1">{sc.name}</div>
|
||||
<div className="text-[9px] text-fg-disabled font-mono mb-2">{sc.datetime}</div>
|
||||
{/* KPI grid */}
|
||||
<div className="grid grid-cols-4 gap-1 mb-2 text-[8px] text-center">
|
||||
<div className="py-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled">GM</div>
|
||||
<div
|
||||
className="font-bold font-mono"
|
||||
style={{ color: gmColor(parseFloat(sc.gm)) }}
|
||||
>
|
||||
{sc.gm}m
|
||||
<div className="grid grid-cols-4 gap-1 font-mono text-caption">
|
||||
{[
|
||||
{ label: 'GM', value: `${sc.gm}m`, color: gmColor(parseFloat(sc.gm)) },
|
||||
{
|
||||
label: '횡경사',
|
||||
value: `${sc.list}°`,
|
||||
color: listColor(parseFloat(sc.list)),
|
||||
},
|
||||
{ label: '부력', value: `${sc.buoyancy}%`, color: buoyColor(sc.buoyancy) },
|
||||
{
|
||||
label: '유출',
|
||||
value: sc.oilRate.split(' ')[0],
|
||||
color: oilColor(parseFloat(sc.oilRate)),
|
||||
},
|
||||
].map((m, i) => (
|
||||
<div key={i} className="text-center p-[3px] bg-bg-base rounded-[3px]">
|
||||
<div className="text-fg-disabled text-caption">{m.label}</div>
|
||||
<div className="font-bold" style={{ color: m.color }}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled">횡경사</div>
|
||||
<div
|
||||
className="font-bold font-mono"
|
||||
style={{ color: listColor(parseFloat(sc.list)) }}
|
||||
>
|
||||
{sc.list}°
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled">부력</div>
|
||||
<div
|
||||
className="font-bold font-mono"
|
||||
style={{ color: buoyColor(sc.buoyancy) }}
|
||||
>
|
||||
{sc.buoyancy}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1 bg-bg-base rounded">
|
||||
<div className="text-fg-disabled">유출</div>
|
||||
<div
|
||||
className="font-bold font-mono"
|
||||
style={{ color: oilColor(parseFloat(sc.oilRate)) }}
|
||||
>
|
||||
{sc.oilRate.split(' ')[0]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="text-[9px] text-fg-sub leading-[1.5]">{sc.description}</div>
|
||||
<div className="text-fg-sub mt-1.5 text-caption leading-[1.4]">
|
||||
{sc.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="px-3.5 py-2.5 border-t border-stroke flex gap-1.5">
|
||||
<div className="flex gap-2 border-t border-stroke px-[14px] py-2.5">
|
||||
<button
|
||||
onClick={() => setDetailView(1)}
|
||||
className="flex-1 py-2 rounded-md border-none text-white text-[10px] font-bold cursor-pointer"
|
||||
style={{ background: 'linear-gradient(135deg,var(--color-accent),#3b82f6)' }}
|
||||
className="flex-1 cursor-pointer font-bold text-static-white text-label-2 p-2 rounded-sm bg-color-navy hover:bg-color-navy-hover"
|
||||
>
|
||||
📊 선택 시나리오 비교
|
||||
</button>
|
||||
<button className="px-3.5 py-2 rounded-md border border-stroke bg-bg-card text-fg-sub text-[10px] font-semibold cursor-pointer">
|
||||
<button className="cursor-pointer font-semibold text-fg-sub text-label-2 px-[14px] py-2 rounded-sm bg-bg-card border border-stroke">
|
||||
📄 보고서
|
||||
</button>
|
||||
</div>
|
||||
@ -380,18 +363,12 @@ export function RescueScenarioView() {
|
||||
{/* ═══ RIGHT: 상세/비교 ═══ */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{/* Detail tabs */}
|
||||
<div className="flex border-b border-stroke shrink-0">
|
||||
<div className="flex border-b border-stroke shrink-0 px-4 bg-bg-surface">
|
||||
{(['📋 시나리오 상세', '📊 비교 차트', '🗺 지도 오버레이'] as const).map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setDetailView(i as DetailView)}
|
||||
className="rsc-atab flex-1 py-2.5 px-1 border-none cursor-pointer transition-all text-[10px]"
|
||||
style={{
|
||||
background: detailView === i ? 'rgba(6,182,212,.04)' : 'transparent',
|
||||
color: detailView === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
||||
fontWeight: detailView === i ? 700 : 600,
|
||||
borderBottom: `2px solid ${detailView === i ? 'var(--color-accent)' : 'transparent'}`,
|
||||
}}
|
||||
className={`rsc-atab ${detailView === i ? 'on' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
@ -403,20 +380,15 @@ export function RescueScenarioView() {
|
||||
{/* ─── VIEW 0: 시나리오 상세 ─── */}
|
||||
{detailView === 0 && selected && (
|
||||
<div className="p-5">
|
||||
{/* Gradient header */}
|
||||
<div
|
||||
className="px-5 py-4 rounded-[10px] border border-[rgba(6,182,212,.2)] mb-4"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,rgba(6,182,212,.06),rgba(239,68,68,.04))',
|
||||
}}
|
||||
>
|
||||
{/* Header card */}
|
||||
<div className="px-5 py-4 rounded-[10px] bg-bg-card border border-stroke mb-4">
|
||||
<div className="flex items-center gap-2.5 mb-2.5">
|
||||
<span className="text-base font-extrabold font-mono text-color-accent">
|
||||
{selected.id}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{selected.name}</span>
|
||||
<span className="text-label-1 font-bold">{selected.name}</span>
|
||||
<span
|
||||
className="px-2 py-px rounded text-[9px] font-bold font-mono"
|
||||
className="px-2 py-[2px] rounded-lg text-caption font-bold font-mono"
|
||||
style={{
|
||||
background: SEV_STYLE[selected.severity].bg,
|
||||
color: SEV_STYLE[selected.severity].color,
|
||||
@ -424,13 +396,13 @@ export function RescueScenarioView() {
|
||||
>
|
||||
{selected.severity}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-fg-disabled font-mono">
|
||||
<span className="ml-auto text-label-2 text-fg-disabled font-mono">
|
||||
{selected.datetime}
|
||||
</span>
|
||||
</div>
|
||||
{/* 6 KPI cards */}
|
||||
<div
|
||||
className="grid gap-2 text-[8px] text-center"
|
||||
className="grid gap-2 text-caption text-center"
|
||||
style={{ gridTemplateColumns: 'repeat(6,1fr)' }}
|
||||
>
|
||||
{[
|
||||
@ -493,9 +465,7 @@ export function RescueScenarioView() {
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{/* 침수 구획 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||||
<div className="text-[11px] font-bold text-color-accent mb-2.5">
|
||||
🚢 침수 구획 상태
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">🚢 침수 구획 상태</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{selected.compartments.map((c, i) => (
|
||||
<div
|
||||
@ -503,9 +473,9 @@ export function RescueScenarioView() {
|
||||
className="flex items-center justify-between px-2.5 py-1.5 bg-bg-base rounded"
|
||||
style={{ borderLeft: `3px solid ${c.color}` }}
|
||||
>
|
||||
<span className="text-[9px]">{c.name}</span>
|
||||
<span className="text-caption">{c.name}</span>
|
||||
<span
|
||||
className="text-[9px] font-semibold font-mono"
|
||||
className="text-caption font-semibold font-mono"
|
||||
style={{ color: c.color }}
|
||||
>
|
||||
{c.status}
|
||||
@ -516,9 +486,7 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
{/* 구난 판단 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||||
<div className="text-[11px] font-bold text-color-danger mb-2.5">
|
||||
⚠️ 구난 판단 요약
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">⚠️ 구난 판단 요약</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{selected.assessment.map((a, i) => (
|
||||
<div
|
||||
@ -526,10 +494,10 @@ export function RescueScenarioView() {
|
||||
className="px-2.5 py-2 bg-bg-base rounded"
|
||||
style={{ borderLeft: `3px solid ${a.color}` }}
|
||||
>
|
||||
<div className="text-[9px] font-bold" style={{ color: a.color }}>
|
||||
<div className="text-caption font-bold" style={{ color: a.color }}>
|
||||
{a.label}
|
||||
</div>
|
||||
<div className="text-[9px] text-fg-sub mt-0.5">{a.value}</div>
|
||||
<div className="text-caption text-fg-sub mt-0.5">{a.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -538,9 +506,7 @@ export function RescueScenarioView() {
|
||||
|
||||
{/* 대응 조치 이력 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-3.5">
|
||||
<div className="text-[11px] font-bold text-color-warning mb-2.5">
|
||||
📋 대응 조치 이력
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">📋 대응 조치 이력</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{selected.actions.map((a, i) => (
|
||||
<div
|
||||
@ -548,7 +514,7 @@ export function RescueScenarioView() {
|
||||
className="flex items-center gap-2.5 px-2.5 py-1.5 bg-bg-base rounded"
|
||||
>
|
||||
<span
|
||||
className="text-[10px] font-bold font-mono min-w-[40px]"
|
||||
className="text-label-2 font-bold font-mono min-w-[40px]"
|
||||
style={{ color: a.color }}
|
||||
>
|
||||
{a.time}
|
||||
@ -557,7 +523,7 @@ export function RescueScenarioView() {
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ background: a.color }}
|
||||
/>
|
||||
<span className="text-[9px]">{a.text}</span>
|
||||
<span className="text-caption">{a.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -573,15 +539,15 @@ export function RescueScenarioView() {
|
||||
<div className="p-5">
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-5 text-center">
|
||||
<div className="text-[32px] opacity-30 mb-2.5">🗺</div>
|
||||
<div className="text-[13px] font-bold mb-1.5">GIS 기반 시나리오 비교</div>
|
||||
<div className="text-[10px] text-fg-disabled leading-relaxed mb-4">
|
||||
<div className="text-title-4 font-bold mb-1.5">GIS 기반 시나리오 비교</div>
|
||||
<div className="text-label-2 text-fg-disabled leading-relaxed mb-4">
|
||||
선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다.
|
||||
</div>
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
{scenarios.map((sc) => (
|
||||
<div
|
||||
key={sc.id}
|
||||
className="px-3 py-1.5 rounded-md text-[9px]"
|
||||
className="px-3 py-1.5 rounded-md text-caption"
|
||||
style={{
|
||||
border: `1px solid ${SEV_STYLE[sc.severity].color}40`,
|
||||
background: SEV_STYLE[sc.severity].bg,
|
||||
@ -595,7 +561,7 @@ export function RescueScenarioView() {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-[30px] bg-bg-base rounded-md border border-dashed border-stroke">
|
||||
<div className="text-[11px] text-fg-disabled">
|
||||
<div className="text-label-2 text-fg-disabled">
|
||||
지도 뷰 영역 — 구난 분석 지도와 연동하여 침수 구역 오버레이 표시
|
||||
</div>
|
||||
</div>
|
||||
@ -627,20 +593,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
};
|
||||
|
||||
/* ── shared className helpers ── */
|
||||
const labelCls = 'text-[9px] text-fg-disabled block mb-1';
|
||||
const labelCls = 'text-caption text-fg-disabled block mb-1';
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 rounded-md border border-stroke bg-bg-base text-[11px] outline-none';
|
||||
'w-full px-3 py-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none';
|
||||
const numCls = `${inputCls} font-mono`;
|
||||
const sectionIcon = (n: number) => (
|
||||
<div
|
||||
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-[9px] font-bold text-color-accent"
|
||||
className="w-[18px] h-[18px] rounded-[5px] flex items-center justify-center text-caption font-bold text-color-accent"
|
||||
style={{ background: 'rgba(6,182,212,.12)' }}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
);
|
||||
const sectionTitleCls =
|
||||
'text-[11px] font-bold text-color-accent mb-2.5 flex items-center gap-1.5';
|
||||
const sectionTitleCls = 'text-label-2 font-bold mb-2.5 flex items-center gap-1.5';
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -652,28 +617,17 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
style={{ background: 'rgba(0,0,0,.65)', backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
<div
|
||||
className="bg-bg-surface border border-[rgba(6,182,212,.3)] rounded-[14px] w-[700px] max-h-[88vh] flex flex-col overflow-hidden"
|
||||
className="bg-bg-surface border border-stroke rounded-[14px] w-[700px] max-h-[88vh] flex flex-col overflow-hidden"
|
||||
style={{ boxShadow: '0 24px 80px rgba(0,0,0,.6)' }}
|
||||
>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-stroke shrink-0 relative overflow-hidden">
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-0.5"
|
||||
style={{ background: 'linear-gradient(90deg,#06b6d4,#3b82f6,#8b5cf6)' }}
|
||||
/>
|
||||
<div className="px-6 pt-5 pb-4 border-b border-stroke shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center text-lg"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,rgba(6,182,212,.15),rgba(59,130,246,.08))',
|
||||
}}
|
||||
>
|
||||
🚨
|
||||
</div>
|
||||
<span className="text-lg">🚨</span>
|
||||
<div>
|
||||
<div className="text-[15px] font-bold">신규 긴급구난 시나리오 생성</div>
|
||||
<div className="text-[10px] text-fg-disabled mt-0.5">
|
||||
<div className="text-title-4 font-bold">신규 긴급구난 시나리오 생성</div>
|
||||
<div className="text-label-2 text-fg-disabled mt-0.5">
|
||||
선박 사고 조건 및 구난 분석 파라미터를 설정합니다 (SFR-009)
|
||||
</div>
|
||||
</div>
|
||||
@ -857,17 +811,14 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
</div>
|
||||
</div>
|
||||
{/* 침수 상태 */}
|
||||
<div
|
||||
className="mt-2.5 px-3.5 py-2.5 rounded-md border border-[rgba(239,68,68,.12)]"
|
||||
style={{ background: 'rgba(239,68,68,.04)' }}
|
||||
>
|
||||
<div className="text-[9px] font-bold text-[#f87171] mb-2">💧 침수 상태</div>
|
||||
<div className="mt-2.5 px-3.5 py-2.5 rounded-md border border-stroke bg-bg-card">
|
||||
<div className="text-caption font-bold mb-2">💧 침수 상태</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div>
|
||||
<label className="text-[8px] text-fg-disabled block mb-px">침수 구역 수</label>
|
||||
<label className="text-caption text-fg-disabled block mb-px">침수 구역 수</label>
|
||||
<select
|
||||
defaultValue="2개"
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] outline-none"
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 outline-none"
|
||||
>
|
||||
{['1개', '2개', '3개', '4개 이상'].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
@ -875,17 +826,17 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] text-fg-disabled block mb-px">침수량 (톤)</label>
|
||||
<label className="text-caption text-fg-disabled block mb-px">침수량 (톤)</label>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={850}
|
||||
step={50}
|
||||
min={0}
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] text-fg-disabled block mb-px">
|
||||
<label className="text-caption text-fg-disabled block mb-px">
|
||||
침수 진행률 (t/h)
|
||||
</label>
|
||||
<input
|
||||
@ -893,17 +844,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
defaultValue={120}
|
||||
step={10}
|
||||
min={0}
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[8px] text-fg-disabled block mb-px">배수 능력 (t/h)</label>
|
||||
<label className="text-caption text-fg-disabled block mb-px">
|
||||
배수 능력 (t/h)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={80}
|
||||
step={10}
|
||||
min={0}
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-[10px] font-mono outline-none"
|
||||
className="w-full px-2 py-1.5 rounded border border-stroke bg-bg-base text-label-2 font-mono outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -923,7 +876,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<input type="text" defaultValue="128.4217" className={`${inputCls} font-mono`} />
|
||||
</div>
|
||||
<button
|
||||
className="px-3.5 py-2 rounded-md border border-[rgba(6,182,212,.3)] text-color-accent text-[10px] font-semibold cursor-pointer whitespace-nowrap"
|
||||
className="px-3.5 py-2 rounded-md border border-[rgba(6,182,212,.3)] text-color-accent text-label-2 font-semibold cursor-pointer whitespace-nowrap"
|
||||
style={{ background: 'rgba(6,182,212,.08)' }}
|
||||
>
|
||||
📍 지도에서 선택
|
||||
@ -932,12 +885,12 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<div className="mt-2 grid grid-cols-4 gap-2.5">
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
풍향 / 풍속 <span className="text-[#f87171]">*</span>
|
||||
풍향 / 풍속 <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
defaultValue="SW"
|
||||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-[10px] outline-none"
|
||||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
|
||||
>
|
||||
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
|
||||
<option key={d}>{d}</option>
|
||||
@ -948,13 +901,13 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
defaultValue={12.5}
|
||||
step={0.5}
|
||||
min={0}
|
||||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-[10px] font-mono outline-none text-center"
|
||||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
파고 (m) <span className="text-[#f87171]">*</span>
|
||||
파고 (m) <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<input type="number" defaultValue={2.5} step={0.1} min={0} className={numCls} />
|
||||
</div>
|
||||
@ -963,7 +916,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<div className="flex gap-1">
|
||||
<select
|
||||
defaultValue="NE"
|
||||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-[10px] outline-none"
|
||||
className="flex-1 p-2 rounded-md border border-stroke bg-bg-base text-label-2 outline-none"
|
||||
>
|
||||
{['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'].map((d) => (
|
||||
<option key={d}>{d}</option>
|
||||
@ -974,7 +927,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
defaultValue={1.2}
|
||||
step={0.1}
|
||||
min={0}
|
||||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-[10px] font-mono outline-none text-center"
|
||||
className="w-[60px] p-2 rounded-md border border-stroke bg-bg-base text-label-2 font-mono outline-none text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -1017,7 +970,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div>
|
||||
<label className={labelCls}>
|
||||
분석 모델 <span className="text-[#f87171]">*</span>
|
||||
분석 모델 <span className="text-color-danger">*</span>
|
||||
</label>
|
||||
<select defaultValue="R&D 긴급구난 종합분석" className={inputCls}>
|
||||
{[
|
||||
@ -1044,7 +997,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
{['복원성', '예인력', '인양력', '유출 위험'].map((item) => (
|
||||
<label
|
||||
key={item}
|
||||
className="flex items-center gap-px text-[8px] text-fg-sub cursor-pointer"
|
||||
className="flex items-center gap-px text-caption text-fg-sub cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -1058,22 +1011,19 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
</div>
|
||||
</div>
|
||||
{/* R&D 연계 분석 */}
|
||||
<div
|
||||
className="mt-2.5 px-3.5 py-2.5 flex flex-col gap-1.5 rounded-md border border-[rgba(249,115,22,.12)]"
|
||||
style={{ background: 'rgba(249,115,22,.04)' }}
|
||||
>
|
||||
<div className="text-[9px] font-bold text-color-warning">🔗 R&D 연계 분석</div>
|
||||
<div className="mt-2.5 px-3.5 py-2.5 flex flex-col gap-1.5 rounded-md border border-stroke bg-bg-card">
|
||||
<div className="text-caption font-bold">🔗 R&D 연계 분석</div>
|
||||
<div className="flex gap-3">
|
||||
<label className="flex items-center gap-1 text-[9px] text-fg-sub cursor-pointer">
|
||||
<input type="checkbox" style={{ accentColor: 'var(--color-warning)' }} /> 유출유
|
||||
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
|
||||
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} /> 유출유
|
||||
확산예측 동시 실행
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[9px] text-fg-sub cursor-pointer">
|
||||
<input type="checkbox" style={{ accentColor: 'var(--color-warning)' }} /> HNS
|
||||
<label className="flex items-center gap-1 text-caption text-fg-sub cursor-pointer">
|
||||
<input type="checkbox" style={{ accentColor: 'var(--color-accent)' }} /> HNS
|
||||
대기확산 연계 분석
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-[8px] text-fg-disabled leading-[1.5]">
|
||||
<div className="text-caption text-fg-disabled leading-[1.5]">
|
||||
화물 유출 가능성이 있는 경우, 긴급구난 분석 결과와 확산예측을 동시에 수행하여 종합
|
||||
대응 판단을 지원합니다
|
||||
</div>
|
||||
@ -1085,16 +1035,16 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<div className={sectionTitleCls}>{sectionIcon(6)} 비고</div>
|
||||
<textarea
|
||||
placeholder="시나리오 설명, 가정 조건, 현장 상황 특이사항 등을 기록합니다..."
|
||||
className="w-full h-[60px] px-3 py-2.5 rounded-md border border-stroke bg-bg-base text-[10px] outline-none resize-y leading-relaxed scrollbar-thin"
|
||||
className="w-full h-[60px] px-3 py-2.5 rounded-md border border-stroke bg-bg-base text-label-2 outline-none resize-y leading-relaxed scrollbar-thin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 하단 버튼 ── */}
|
||||
<div className="px-6 py-4 border-t border-stroke shrink-0 flex gap-2 items-center">
|
||||
<div className="flex-1 text-[9px] text-fg-disabled leading-[1.5]">
|
||||
<span className="text-[#f87171]">*</span> 필수 입력 항목 · 해상 조건은 기상청/조사원 API
|
||||
연계 시 자동 갱신
|
||||
<div className="flex-1 text-caption text-fg-disabled leading-[1.5]">
|
||||
<span className="text-color-danger">*</span> 필수 입력 항목 · 해상 조건은 기상청/조사원
|
||||
API 연계 시 자동 갱신
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@ -1105,11 +1055,7 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
{done ? (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-7 py-2.5 rounded-md border-none text-white text-xs font-bold cursor-pointer"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg,#22c55e,#10b981)',
|
||||
boxShadow: '0 4px 16px rgba(34,197,94,.3)',
|
||||
}}
|
||||
className="px-7 py-2.5 rounded-md border-none text-static-white text-xs font-bold cursor-pointer bg-color-success"
|
||||
>
|
||||
✅ 생성 완료 — 닫기
|
||||
</button>
|
||||
@ -1117,15 +1063,11 @@ function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: ()
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="px-7 py-2.5 rounded-md border-none text-xs font-bold"
|
||||
style={{
|
||||
background: submitting
|
||||
? 'var(--bg-card)'
|
||||
: 'linear-gradient(135deg,#06b6d4,#3b82f6)',
|
||||
color: submitting ? 'var(--fg-disabled)' : '#fff',
|
||||
cursor: submitting ? 'wait' : 'pointer',
|
||||
boxShadow: submitting ? 'none' : '0 4px 16px rgba(6,182,212,.3)',
|
||||
}}
|
||||
className={`px-7 py-2.5 rounded-md border-none text-xs font-bold ${
|
||||
submitting
|
||||
? 'bg-bg-card text-fg-disabled cursor-wait'
|
||||
: 'bg-color-navy text-static-white cursor-pointer hover:bg-color-navy-hover'
|
||||
}`}
|
||||
>
|
||||
{submitting ? '⏳ 분석 중...' : '🚨 시나리오 생성 · 분석 실행'}
|
||||
</button>
|
||||
@ -1148,7 +1090,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="p-10 text-center text-[11px] text-fg-disabled">
|
||||
<div className="p-10 text-center text-label-2 text-fg-disabled">
|
||||
비교할 시나리오 데이터가 없습니다.
|
||||
</div>
|
||||
);
|
||||
@ -1158,9 +1100,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
<div className="p-5">
|
||||
{/* Chart 1: GM 추이 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4 mb-4">
|
||||
<div className="text-[11px] font-bold text-color-accent mb-2.5">
|
||||
📈 GM (복원심) 변화 추이 (m)
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">📈 GM (복원심) 변화 추이 (m)</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 180 }}>
|
||||
{/* Grid */}
|
||||
{[0, 0.5, 1.0, 1.5, 2.0].map((v) => {
|
||||
@ -1252,9 +1192,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{/* Chart 2: 횡경사 변화 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||||
<div className="text-[11px] font-bold text-color-warning mb-2.5">
|
||||
📉 횡경사 (List) 변화 (°)
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">📉 횡경사 (List) 변화 (°)</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
|
||||
{[0, 5, 10, 15, 20, 25].map((v) => {
|
||||
const y = PY + ph - (v / 25) * ph;
|
||||
@ -1317,9 +1255,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
|
||||
{/* Chart 3: 유출률 변화 (bar) */}
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||||
<div className="text-[11px] font-bold text-color-danger mb-2.5">
|
||||
📊 유출률 변화 (L/min)
|
||||
</div>
|
||||
<div className="text-label-2 font-bold mb-2.5">📊 유출률 변화 (L/min)</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ maxHeight: 160 }}>
|
||||
{[0, 50, 100, 150, 200].map((v) => {
|
||||
const y = PY + ph - (v / 200) * ph;
|
||||
@ -1383,11 +1319,11 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
|
||||
{/* Chart 4: 비교 테이블 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-[10px] p-4">
|
||||
<div className="text-[11px] font-bold mb-2.5">📋 시나리오 종합 비교표</div>
|
||||
<table className="w-full border-collapse text-[9px]">
|
||||
<div className="text-label-2 font-bold mb-2.5">📋 시나리오 종합 비교표</div>
|
||||
<table className="w-full border-collapse text-caption">
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(6,182,212,.06)' }}>
|
||||
<th className="py-[7px] px-2 text-left border-b-2 border-[var(--stroke-light)] text-color-accent">
|
||||
<tr className="bg-bg-base">
|
||||
<th className="py-[7px] px-2 text-left border-b-2 border-[var(--stroke-light)] text-fg-sub">
|
||||
지표
|
||||
</th>
|
||||
{chartData.map((d) => (
|
||||
@ -1398,7 +1334,7 @@ function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
>
|
||||
{d.id}
|
||||
<br />
|
||||
<span className="font-normal text-[8px] text-fg-disabled">{d.label}</span>
|
||||
<span className="font-normal text-caption text-fg-disabled">{d.label}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@ -1,281 +1,446 @@
|
||||
import { sanitizeHtml } from '@common/utils/sanitize';
|
||||
|
||||
export function RescueTheoryView() {
|
||||
const contentHtml = `
|
||||
<div style="font-size:18px;font-weight:700;margin-bottom:4px;font-family:var(--font-korean)">📚 긴급구난모델 이론</div>
|
||||
<div style="font-size:12px;color:var(--fg-disabled);margin-bottom:22px;font-family:var(--font-korean)">해상 긴급구난 자원 배치·최적화 이론 및 참고 연구 문헌</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start">
|
||||
|
||||
<!-- 왼쪽 컬럼 -->
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- 다목적 최적화 모델 -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
|
||||
<div style="padding:12px 16px;background:rgba(6,182,212,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:14px">⚙️</span>
|
||||
<span style="font-size:12px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean)">다목적 최적화 자원 배치</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
|
||||
|
||||
<!-- Dong et al. 2024 -->
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-accent)">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-accent)">①</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">A Multi-Objective Optimization Method for Maritime SAR Resource Allocation: South China Sea</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">Dong, Y. et al. | <i>J. Marine Science and Engineering</i> Vol.12(1):184, 2024 · DOI: <a href="https://doi.org/10.3390/jmse12010184" target="_blank" style="color:var(--color-info);text-decoration:none">10.3390/jmse12010184</a> · MDPI Open Access</div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px">DNSGA-II</span>
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">다목적 최적화</span>
|
||||
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px">구조선·항공기 배치</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">해상 사고 후 구조자원(구조선·항공기) 배치 최적화. 비용·응답시간 동시 최소화 DNSGA-II 진화적 최적화. 남중국해 적용 검증. WING 긴급구난 자산 우선순위 배치 알고리즘의 직접 참고 모델.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zhang et al. 2017 -->
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-accent)">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-accent)">②</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Optimized Maritime Emergency Resource Allocation under Dynamic Demand</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">Zhang, W., Yan, X. & Yang, J. | <i>PLoS ONE</i> 12(12):e0189411, 2017 · DOI: <a href="https://doi.org/10.1371/journal.pone.0189411" target="_blank" style="color:var(--color-info);text-decoration:none">10.1371/journal.pone.0189411</a> · Open Access</div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px">동적 수요 모델</span>
|
||||
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px">강건 최적화(Robust)</span>
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">불확실 수요 대응</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">동적·불확실 수요 반영 긴급구난 자원 배치 다목적 최적화 및 강건 최적화. WING 해상 상황 변화에 따른 실시간 자원 재배치 모델 이론 근거.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ma et al. 2023 -->
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(6,182,212,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0">③</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">A Method for Optimizing Maritime Emergency Resource Allocation in Inland Waterways</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">Ma, Q. et al. | <i>Ocean Engineering</i> Vol.282:116224, 2023 · DOI: <a href="https://doi.org/10.1016/j.oceaneng.2023.116224" target="_blank" style="color:var(--color-info);text-decoration:none">10.1016/j.oceaneng.2023.116224</a> · Open Access</div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:3px;color:var(--color-success);font-size:8px">AHP</span>
|
||||
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px">DEA 효율성 평가</span>
|
||||
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px">내수로 적용</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">AHP(계층분석법)+DEA(자료포락분석)로 긴급구난 자원 효율성 평가 및 최적 배치. WING 자원 효율성 지수 산정·우선순위 결정 방법론 참조.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오일스필 동적 스케줄링 -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
|
||||
<div style="padding:12px 16px;background:rgba(249,115,22,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:14px">🛢</span>
|
||||
<span style="font-size:12px;font-weight:700;color:var(--color-warning);font-family:var(--font-korean)">오일스필 동적 자원 스케줄링</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
|
||||
|
||||
<!-- Zhang 2020 -->
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px;border-left:2px solid var(--color-warning)">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(249,115,22,.2);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0;font-weight:700;color:var(--color-warning)">①</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Dynamic Optimization of Emergency Resource Scheduling in a Large-Scale Maritime Oil Spill Accident</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">Zhang, L. | LJMU Research Online, 2020 · <a href="https://researchonline.ljmu.ac.uk/id/eprint/14880/3/Dynamic%20optimization%20of%20emergency%20resource%20scheduling%20in%20a%20large-scale%20maritime%20oil%20spill%20accident.pdf" target="_blank" style="color:var(--color-info);text-decoration:none">PDF 공개</a></div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(249,115,22,.08);border:1px solid rgba(249,115,22,.2);border-radius:3px;color:var(--color-warning);font-size:8px">Location-Routing 최적화</span>
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">하이브리드 휴리스틱</span>
|
||||
<span style="padding:1px 5px;background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.2);border-radius:3px;color:var(--color-accent);font-size:8px">Pareto 다목적</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">동적 Location-Routing 최적화 모델 + 하이브리드 휴리스틱 알고리즘으로 대규모 해양 오일스필 응급자원 스케줄링·라우팅 다목적 최적화. WING 방제정 동적 라우팅 모델의 이론 선행연구.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 컬럼 -->
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- UAV·예측 기반 SAR -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
|
||||
<div style="padding:12px 16px;background:rgba(168,85,247,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:14px">🚁</span>
|
||||
<span style="font-size:12px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean)">UAV·예측 기반 해상구조 (보조자료)</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:8px;font-size:9px;font-family:var(--font-korean)">
|
||||
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0">①</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">From Forecast to Action: UAV-Based Maritime SAR Deployment Optimization</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2512.09260 · <a href="https://arxiv.org/abs/2512.09260" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2512.09260</a></div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">예측 기반 UAV 배치</span>
|
||||
<span style="padding:1px 5px;background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.2);border-radius:3px;color:var(--color-success);font-size:8px">SAR 최적화</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">해상 조난 예측 → UAV 사전 배치 최적화. 예측-행동 연계 프레임워크. WING 항공탐색 연동 구조자원 선제 배치 참고 연구.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0">②</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">UAV-Assisted SAR Latency Optimization</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2511.00844 · <a href="https://arxiv.org/abs/2511.00844" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2511.00844</a></div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">UAV 지원 구조</span>
|
||||
<span style="padding:1px 5px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:3px;color:var(--color-danger);font-size:8px">응답 지연 최소화</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">UAV 지원 해상 구조 처리·지연 최적화. 통신·처리 지연 모델링 및 UAV 경로 최적화.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0">③</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">UAV Path Planning for SAR: Performance Evaluation</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2402.01494 · <a href="https://arxiv.org/abs/2402.01494" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2402.01494</a></div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">UAV 경로계획</span>
|
||||
<span style="padding:1px 5px;background:rgba(59,130,246,.08);border:1px solid rgba(59,130,246,.2);border-radius:3px;color:var(--color-info);font-size:8px">성능 평가</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">SAR 임무용 UAV 경로계획 알고리즘 성능 비교 평가. 탐색 커버리지·효율성 지표 분석.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:24px 1fr;gap:8px;padding:8px 10px;background:var(--bg-base);border-radius:6px">
|
||||
<div style="width:20px;height:20px;border-radius:4px;background:rgba(168,85,247,.12);display:flex;align-items:center;justify-content:center;font-size:9px;flex-shrink:0">④</div>
|
||||
<div>
|
||||
<div style="color:var(--fg-default);font-weight:700;margin-bottom:2px">Probabilistic Submarine Search and Rescue Strategy</div>
|
||||
<div style="color:var(--fg-disabled);line-height:1.6">arXiv:2505.02186 · <a href="https://arxiv.org/abs/2505.02186" target="_blank" style="color:var(--color-info);text-decoration:none">arxiv.org/abs/2505.02186</a></div>
|
||||
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
|
||||
<span style="padding:1px 5px;background:rgba(168,85,247,.08);border:1px solid rgba(168,85,247,.2);border-radius:3px;color:var(--color-tertiary);font-size:8px">확률론적 탐색</span>
|
||||
<span style="padding:1px 5px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.2);border-radius:3px;color:var(--color-danger);font-size:8px">잠수함 구조</span>
|
||||
</div>
|
||||
<div style="margin-top:2px;color:var(--fg-sub)">잠수함 구조 전략 확률론적 최적화. Bayesian 탐색 이론 기반 구조자원 투입 전략 도출.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 비교 요약 테이블 -->
|
||||
<div style="background:var(--bg-card);border:1px solid var(--stroke-default);border-radius:var(--radius-md);overflow:hidden">
|
||||
<div style="padding:12px 16px;background:rgba(34,197,94,.08);border-bottom:1px solid var(--stroke-default);display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:14px">📊</span>
|
||||
<span style="font-size:12px;font-weight:700;color:var(--color-success);font-family:var(--font-korean)">논문 비교 요약</span>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:9px;font-family:var(--font-korean)">
|
||||
<thead>
|
||||
<tr style="background:var(--bg-base)">
|
||||
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)">논문</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)">키워드</th>
|
||||
<th style="padding:8px 10px;text-align:left;font-weight:700;color:var(--fg-sub);border-bottom:1px solid var(--stroke-default)">WING 활용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid var(--stroke-default)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Dong et al. (2024)</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">DNSGA-II 다목적 최적화</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">구조자산 배치 최적화</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--stroke-default)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Zhang et al. (2017)</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">동적수요·강건 최적화</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">실시간 자원 재배치</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--stroke-default)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-accent)">Ma et al. (2023)</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">AHP + DEA</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">자원 효율성 평가</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--stroke-default)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-warning)">Zhang (2020)</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">동적 Location-Routing</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">방제정 라우팅 모델</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--stroke-default)">
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-tertiary)">arXiv:2512.09260</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">예측→UAV 배치</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">항공탐색 연동 선제배치</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:7px 10px;font-weight:700;color:var(--color-tertiary)">arXiv:2505.02186</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-sub)">확률론적 탐색전략</td>
|
||||
<td style="padding:7px 10px;color:var(--fg-disabled)">구조구역 확률 탐색</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 긴급구난 관련 논문 -->
|
||||
<div style="margin-top:18px;border-radius:12px;padding:16px;background:linear-gradient(135deg,rgba(168,85,247,.05),rgba(59,130,246,.03));border:1px solid rgba(168,85,247,.2);position:relative;overflow:hidden">
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--color-tertiary),var(--color-info))"></div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
||||
<div style="width:30px;height:30px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:14px;background:rgba(168,85,247,.12);border:1px solid rgba(168,85,247,.25);flex-shrink:0">📄</div>
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:700;color:var(--fg-default);font-family:var(--font-korean)">긴급구난 관련 논문</div>
|
||||
<div style="font-size:10px;margin-top:2px;color:var(--fg-disabled);font-family:var(--font-korean)">해양수색구조 의사결정지원 · 실시간 데이터·AI 기반 신속 대응</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="border-radius:9px;padding:12px;background:var(--bg-surface);border:1px solid rgba(168,85,247,.18);border-left:3px solid var(--color-tertiary)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<span style="padding:2px 7px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.22);border-radius:4px;font-size:10px;font-weight:700;color:#3b82f6;font-family:var(--font-korean)">수색구조</span>
|
||||
<span style="padding:2px 7px;background:rgba(168,85,247,.1);border:1px solid rgba(168,85,247,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-tertiary);font-family:var(--font-korean)">의사결정지원</span>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--fg-disabled);font-family:var(--font-korean)">2025</span>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:700;margin-bottom:4px;color:var(--fg-default);font-family:var(--font-korean)">지능형 해양수색구조 의사결정지원시스템: 신속한 대응을 위한 데이터와 기술 활용</div>
|
||||
<div style="font-size:11px;margin-bottom:6px;color:var(--fg-disabled);font-family:var(--font-korean)">김충기, 정해상, 이성숙, 윤종휘 | 한국해양환경·에너지학회 학술대회논문집 | 2025.5 | pp.160</div>
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7">초고해상도 3차원 연안 해양예측모델, 다중모델 앙상블 기법, AI 기반 확률론적 표류경로 예측 기술을 통합한 지능형 해양 수색구조 의사결정지원시스템 개발. 실해역 부유체 표류 실험과 예측 모델 검증을 통해 고정밀 성능을 확보하고, 수색 성공 확률 기반 스마트 수색계획 자동화 및 최적 자원 동원 알고리즘을 개발. 사고 발생부터 표류 예측, 수색계획 수립, 자원배치, 결과보고에 이르는 전 과정을 통합한 플랫폼을 시범 구축하고 시뮬레이션을 통해 현장 활용성을 확인. 해양경찰청 지원(RS-2022-KS221629).</div>
|
||||
</div>
|
||||
<div style="border-radius:9px;padding:12px;background:var(--bg-surface);border:1px solid rgba(59,130,246,.18);border-left:3px solid #3b82f6">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<span style="padding:2px 7px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.22);border-radius:4px;font-size:10px;font-weight:700;color:#3b82f6;font-family:var(--font-korean)">AI·SAR</span>
|
||||
<span style="padding:2px 7px;background:rgba(6,182,212,.1);border:1px solid rgba(6,182,212,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-accent);font-family:var(--font-korean)">한국형 시스템</span>
|
||||
<span style="padding:2px 7px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.22);border-radius:4px;font-size:10px;font-weight:700;color:var(--color-success);font-family:var(--font-korean)">표류예측</span>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--fg-disabled);font-family:var(--font-korean)">2024</span>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:700;margin-bottom:4px;color:var(--fg-default);font-family:var(--font-korean)">AI 기반 한국형 해양수색구조 의사결정 지원시스템</div>
|
||||
<div style="font-size:11px;margin-bottom:6px;color:var(--fg-disabled);font-family:var(--font-korean)">김충기, 정해상, 윤종휘, 박창석, 김종호 | 한국환경연구원 물국토연구본부, 한국해양대학교 해양경찰학부 | 한국해양환경·에너지학회 추계학술대회 | 2024.11 | pp.127</div>
|
||||
<div style="font-size:11px;color:var(--fg-sub);font-family:var(--font-korean);line-height:1.7">한국형 부유체 표류특성을 분석하고, 초고해상도 3차원 연안 해양예측 모델 개발, 다중모델 앙상블 해양기상 예측, AI 기반 부유체 표류경로 예측, AI 기반 수색전략 수립 지원 등 AI 기반 한국형 해양수색구조 의사결정 지원 시스템을 구축. 연간 해양사고 경제적 손실 4,390억~5,420억 원 규모에 대응하여 신속하고 정확한 수색구조 활동을 지원하고 조난자의 생존 확률을 높이고 구조인력의 안전을 보장. 해양경찰청 지원(RS-2022-KS221629, 지능형 해양사고 대응 플랫폼 구축).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
/* ── 논문 아이템 타입 ── */
|
||||
interface PaperItem {
|
||||
number: string;
|
||||
numberBg: string;
|
||||
numberColor?: string;
|
||||
title: string;
|
||||
citation: React.ReactNode;
|
||||
tags: { label: string; color: string }[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
/* ── 논문 카드 ── */
|
||||
function PaperCard({ item }: { item: PaperItem }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-base)',
|
||||
}}
|
||||
className="grid gap-2 rounded-md bg-bg-base p-[8px_10px]"
|
||||
style={{ gridTemplateColumns: '24px 1fr' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--stroke-light) transparent',
|
||||
padding: '28px 36px',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(contentHtml) }}
|
||||
/>
|
||||
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-label-2 font-bold"
|
||||
style={{ background: item.numberBg, color: item.numberColor }}
|
||||
>
|
||||
{item.number}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 text-label-1 font-bold text-fg">{item.title}</div>
|
||||
<div className="text-label-2 leading-relaxed text-fg-disabled">{item.citation}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{item.tags.map((t) => (
|
||||
<span
|
||||
key={t.label}
|
||||
className="rounded px-1.5 py-px text-label-2"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${t.color} 8%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${t.color} 20%, transparent)`,
|
||||
color: t.color,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-0.5 text-label-2 text-fg-sub">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 섹션 카드 (헤더 + 논문 목록) ── */
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
papers,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
papers: PaperItem[];
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-stroke bg-bg-card">
|
||||
<div className="flex items-center gap-2 border-b border-stroke bg-bg-base px-4 py-3">
|
||||
<span className="text-title-3">{icon}</span>
|
||||
<span className="text-label-1 font-bold font-korean text-fg">{title}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-[14px_16px] text-label-2 font-korean">
|
||||
{papers.map((p) => (
|
||||
<PaperCard key={p.number + p.title} item={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 논문 데이터 ── */
|
||||
const DOI_LINK_CLASS = 'text-color-info no-underline';
|
||||
|
||||
const optimizationPapers: PaperItem[] = [
|
||||
{
|
||||
number: '①',
|
||||
numberBg: 'rgba(6,182,212,.2)',
|
||||
numberColor: 'var(--color-accent)',
|
||||
title:
|
||||
'A Multi-Objective Optimization Method for Maritime SAR Resource Allocation: South China Sea',
|
||||
citation: (
|
||||
<>
|
||||
Dong, Y. et al. | <i>J. Marine Science and Engineering</i> Vol.12(1):184, 2024 · DOI:{' '}
|
||||
<a
|
||||
href="https://doi.org/10.3390/jmse12010184"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
10.3390/jmse12010184
|
||||
</a>{' '}
|
||||
· MDPI Open Access
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: 'DNSGA-II', color: 'var(--color-accent)' },
|
||||
{ label: '다목적 최적화', color: 'var(--color-tertiary)' },
|
||||
{ label: '구조선·항공기 배치', color: 'var(--color-info)' },
|
||||
],
|
||||
description:
|
||||
'해상 사고 후 구조자원(구조선·항공기) 배치 최적화. 비용·응답시간 동시 최소화 DNSGA-II 진화적 최적화. 남중국해 적용 검증. WING 긴급구난 자산 우선순위 배치 알고리즘의 직접 참고 모델.',
|
||||
},
|
||||
{
|
||||
number: '②',
|
||||
numberBg: 'rgba(6,182,212,.2)',
|
||||
numberColor: 'var(--color-accent)',
|
||||
title: 'Optimized Maritime Emergency Resource Allocation under Dynamic Demand',
|
||||
citation: (
|
||||
<>
|
||||
Zhang, W., Yan, X. & Yang, J. | <i>PLoS ONE</i> 12(12):e0189411, 2017 · DOI:{' '}
|
||||
<a
|
||||
href="https://doi.org/10.1371/journal.pone.0189411"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
10.1371/journal.pone.0189411
|
||||
</a>{' '}
|
||||
· Open Access
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: '동적 수요 모델', color: 'var(--color-accent)' },
|
||||
{ label: '강건 최적화(Robust)', color: 'var(--color-warning)' },
|
||||
{ label: '불확실 수요 대응', color: 'var(--color-tertiary)' },
|
||||
],
|
||||
description:
|
||||
'동적·불확실 수요 반영 긴급구난 자원 배치 다목적 최적화 및 강건 최적화. WING 해상 상황 변화에 따른 실시간 자원 재배치 모델 이론 근거.',
|
||||
},
|
||||
{
|
||||
number: '③',
|
||||
numberBg: 'rgba(6,182,212,.12)',
|
||||
title: 'A Method for Optimizing Maritime Emergency Resource Allocation in Inland Waterways',
|
||||
citation: (
|
||||
<>
|
||||
Ma, Q. et al. | <i>Ocean Engineering</i> Vol.282:116224, 2023 · DOI:{' '}
|
||||
<a
|
||||
href="https://doi.org/10.1016/j.oceaneng.2023.116224"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
10.1016/j.oceaneng.2023.116224
|
||||
</a>{' '}
|
||||
· Open Access
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: 'AHP', color: 'var(--color-success)' },
|
||||
{ label: 'DEA 효율성 평가', color: 'var(--color-info)' },
|
||||
{ label: '내수로 적용', color: 'var(--color-warning)' },
|
||||
],
|
||||
description:
|
||||
'AHP(계층분석법)+DEA(자료포락분석)로 긴급구난 자원 효율성 평가 및 최적 배치. WING 자원 효율성 지수 산정·우선순위 결정 방법론 참조.',
|
||||
},
|
||||
];
|
||||
|
||||
const oilSpillPapers: PaperItem[] = [
|
||||
{
|
||||
number: '①',
|
||||
numberBg: 'rgba(249,115,22,.2)',
|
||||
numberColor: 'var(--color-warning)',
|
||||
title:
|
||||
'Dynamic Optimization of Emergency Resource Scheduling in a Large-Scale Maritime Oil Spill Accident',
|
||||
citation: (
|
||||
<>
|
||||
Zhang, L. | LJMU Research Online, 2020 ·{' '}
|
||||
<a
|
||||
href="https://researchonline.ljmu.ac.uk/id/eprint/14880/3/Dynamic%20optimization%20of%20emergency%20resource%20scheduling%20in%20a%20large-scale%20maritime%20oil%20spill%20accident.pdf"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
PDF 공개
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: 'Location-Routing 최적화', color: 'var(--color-warning)' },
|
||||
{ label: '하이브리드 휴리스틱', color: 'var(--color-tertiary)' },
|
||||
{ label: 'Pareto 다목적', color: 'var(--color-accent)' },
|
||||
],
|
||||
description:
|
||||
'동적 Location-Routing 최적화 모델 + 하이브리드 휴리스틱 알고리즘으로 대규모 해양 오일스필 응급자원 스케줄링·라우팅 다목적 최적화. WING 방제정 동적 라우팅 모델의 이론 선행연구.',
|
||||
},
|
||||
];
|
||||
|
||||
const uavPapers: PaperItem[] = [
|
||||
{
|
||||
number: '①',
|
||||
numberBg: 'rgba(168,85,247,.12)',
|
||||
title: 'From Forecast to Action: UAV-Based Maritime SAR Deployment Optimization',
|
||||
citation: (
|
||||
<>
|
||||
arXiv:2512.09260 ·{' '}
|
||||
<a
|
||||
href="https://arxiv.org/abs/2512.09260"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
arxiv.org/abs/2512.09260
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: '예측 기반 UAV 배치', color: 'var(--color-tertiary)' },
|
||||
{ label: 'SAR 최적화', color: 'var(--color-success)' },
|
||||
],
|
||||
description:
|
||||
'해상 조난 예측 → UAV 사전 배치 최적화. 예측-행동 연계 프레임워크. WING 항공탐색 연동 구조자원 선제 배치 참고 연구.',
|
||||
},
|
||||
{
|
||||
number: '②',
|
||||
numberBg: 'rgba(168,85,247,.12)',
|
||||
title: 'UAV-Assisted SAR Latency Optimization',
|
||||
citation: (
|
||||
<>
|
||||
arXiv:2511.00844 ·{' '}
|
||||
<a
|
||||
href="https://arxiv.org/abs/2511.00844"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
arxiv.org/abs/2511.00844
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: 'UAV 지원 구조', color: 'var(--color-tertiary)' },
|
||||
{ label: '응답 지연 최소화', color: 'var(--color-danger)' },
|
||||
],
|
||||
description: 'UAV 지원 해상 구조 처리·지연 최적화. 통신·처리 지연 모델링 및 UAV 경로 최적화.',
|
||||
},
|
||||
{
|
||||
number: '③',
|
||||
numberBg: 'rgba(168,85,247,.12)',
|
||||
title: 'UAV Path Planning for SAR: Performance Evaluation',
|
||||
citation: (
|
||||
<>
|
||||
arXiv:2402.01494 ·{' '}
|
||||
<a
|
||||
href="https://arxiv.org/abs/2402.01494"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
arxiv.org/abs/2402.01494
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: 'UAV 경로계획', color: 'var(--color-tertiary)' },
|
||||
{ label: '성능 평가', color: 'var(--color-info)' },
|
||||
],
|
||||
description: 'SAR 임무용 UAV 경로계획 알고리즘 성능 비교 평가. 탐색 커버리지·효율성 지표 분석.',
|
||||
},
|
||||
{
|
||||
number: '④',
|
||||
numberBg: 'rgba(168,85,247,.12)',
|
||||
title: 'Probabilistic Submarine Search and Rescue Strategy',
|
||||
citation: (
|
||||
<>
|
||||
arXiv:2505.02186 ·{' '}
|
||||
<a
|
||||
href="https://arxiv.org/abs/2505.02186"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={DOI_LINK_CLASS}
|
||||
>
|
||||
arxiv.org/abs/2505.02186
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
tags: [
|
||||
{ label: '확률론적 탐색', color: 'var(--color-tertiary)' },
|
||||
{ label: '잠수함 구조', color: 'var(--color-danger)' },
|
||||
],
|
||||
description:
|
||||
'잠수함 구조 전략 확률론적 최적화. Bayesian 탐색 이론 기반 구조자원 투입 전략 도출.',
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 비교 요약 테이블 데이터 ── */
|
||||
const comparisonRows = [
|
||||
{ paper: 'Dong et al. (2024)', keyword: 'DNSGA-II 다목적 최적화', usage: '구조자산 배치 최적화' },
|
||||
{ paper: 'Zhang et al. (2017)', keyword: '동적수요·강건 최적화', usage: '실시간 자원 재배치' },
|
||||
{ paper: 'Ma et al. (2023)', keyword: 'AHP + DEA', usage: '자원 효율성 평가' },
|
||||
{ paper: 'Zhang (2020)', keyword: '동적 Location-Routing', usage: '방제정 라우팅 모델' },
|
||||
{ paper: 'arXiv:2512.09260', keyword: '예측→UAV 배치', usage: '항공탐색 연동 선제배치' },
|
||||
{ paper: 'arXiv:2505.02186', keyword: '확률론적 탐색전략', usage: '구조구역 확률 탐색' },
|
||||
];
|
||||
|
||||
/* ── 긴급구난 관련 논문 데이터 ── */
|
||||
interface RelatedPaper {
|
||||
tags: { label: string; color: string }[];
|
||||
year: string;
|
||||
title: string;
|
||||
meta: string;
|
||||
abstract: string;
|
||||
}
|
||||
|
||||
const relatedPapers: RelatedPaper[] = [
|
||||
{
|
||||
tags: [
|
||||
{ label: '수색구조', color: 'var(--color-info)' },
|
||||
{ label: '의사결정지원', color: 'var(--fg-default)' },
|
||||
],
|
||||
year: '2025',
|
||||
title: '지능형 해양수색구조 의사결정지원시스템: 신속한 대응을 위한 데이터와 기술 활용',
|
||||
meta: '김충기, 정해상, 이성숙, 윤종휘 | 한국해양환경·에너지학회 학술대회논문집 | 2025.5 | pp.160',
|
||||
abstract:
|
||||
'초고해상도 3차원 연안 해양예측모델, 다중모델 앙상블 기법, AI 기반 확률론적 표류경로 예측 기술을 통합한 지능형 해양 수색구조 의사결정지원시스템 개발. 실해역 부유체 표류 실험과 예측 모델 검증을 통해 고정밀 성능을 확보하고, 수색 성공 확률 기반 스마트 수색계획 자동화 및 최적 자원 동원 알고리즘을 개발. 사고 발생부터 표류 예측, 수색계획 수립, 자원배치, 결과보고에 이르는 전 과정을 통합한 플랫폼을 시범 구축하고 시뮬레이션을 통해 현장 활용성을 확인. 해양경찰청 지원(RS-2022-KS221629).',
|
||||
},
|
||||
{
|
||||
tags: [
|
||||
{ label: 'AI·SAR', color: 'var(--color-info)' },
|
||||
{ label: '한국형 시스템', color: 'var(--color-accent)' },
|
||||
{ label: '표류예측', color: 'var(--color-success)' },
|
||||
],
|
||||
year: '2024',
|
||||
title: 'AI 기반 한국형 해양수색구조 의사결정 지원시스템',
|
||||
meta: '김충기, 정해상, 윤종휘, 박창석, 김종호 | 한국환경연구원 물국토연구본부, 한국해양대학교 해양경찰학부 | 한국해양환경·에너지학회 추계학술대회 | 2024.11 | pp.127',
|
||||
abstract:
|
||||
'한국형 부유체 표류특성을 분석하고, 초고해상도 3차원 연안 해양예측 모델 개발, 다중모델 앙상블 해양기상 예측, AI 기반 부유체 표류경로 예측, AI 기반 수색전략 수립 지원 등 AI 기반 한국형 해양수색구조 의사결정 지원 시스템을 구축. 연간 해양사고 경제적 손실 4,390억~5,420억 원 규모에 대응하여 신속하고 정확한 수색구조 활동을 지원하고 조난자의 생존 확률을 높이고 구조인력의 안전을 보장. 해양경찰청 지원(RS-2022-KS221629, 지능형 해양사고 대응 플랫폼 구축).',
|
||||
},
|
||||
];
|
||||
|
||||
/* ═══ 메인 컴포넌트 ═══ */
|
||||
export function RescueTheoryView() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 flex-col overflow-hidden bg-bg-base"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-9 py-7"
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}
|
||||
>
|
||||
{/* ── 헤더 ── */}
|
||||
<div className="mb-1 text-title-1 font-bold font-korean">📚 긴급구난모델 이론</div>
|
||||
<div className="mb-6 text-label-1 font-korean text-fg-disabled">
|
||||
해상 긴급구난 자원 배치·최적화 이론 및 참고 연구 문헌
|
||||
</div>
|
||||
|
||||
{/* ── 2열 그리드 ── */}
|
||||
<div className="grid grid-cols-2 items-start gap-[18px]">
|
||||
{/* 왼쪽 컬럼 */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<SectionCard icon="⚙️" title="다목적 최적화 자원 배치" papers={optimizationPapers} />
|
||||
<SectionCard icon="🛢" title="오일스필 동적 자원 스케줄링" papers={oilSpillPapers} />
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 컬럼 */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<SectionCard icon="🚁" title="UAV·예측 기반 해상구조 (보조자료)" papers={uavPapers} />
|
||||
|
||||
{/* 비교 요약 테이블 */}
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-stroke bg-bg-card">
|
||||
<div className="flex items-center gap-2 border-b border-stroke bg-bg-base px-4 py-3">
|
||||
<span className="text-title-3">📊</span>
|
||||
<span className="text-label-1 font-bold font-korean text-fg">논문 비교 요약</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-label-2 font-korean">
|
||||
<thead>
|
||||
<tr className="bg-bg-base">
|
||||
{['논문', '키워드', 'WING 활용'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="border-b border-stroke px-2.5 py-2 text-left font-bold text-fg-sub"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparisonRows.map((r, i) => (
|
||||
<tr
|
||||
key={r.paper}
|
||||
className={i < comparisonRows.length - 1 ? 'border-b border-stroke' : ''}
|
||||
>
|
||||
<td className="px-2.5 py-[7px] font-bold text-fg">{r.paper}</td>
|
||||
<td className="px-2.5 py-[7px] text-fg-sub">{r.keyword}</td>
|
||||
<td className="px-2.5 py-[7px] text-fg-disabled">{r.usage}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 긴급구난 관련 논문 ── */}
|
||||
<div className="mt-[18px] overflow-hidden rounded-xl border border-stroke bg-bg-card p-4">
|
||||
<div className="mb-3.5 flex items-center gap-2.5">
|
||||
<div className="flex h-[30px] w-[30px] flex-shrink-0 items-center justify-content-center rounded-[7px] border border-stroke bg-bg-base text-title-3">
|
||||
📄
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-label-1 font-bold font-korean text-fg">긴급구난 관련 논문</div>
|
||||
<div className="mt-0.5 text-label-2 font-korean text-fg-disabled">
|
||||
해양수색구조 의사결정지원 · 실시간 데이터·AI 기반 신속 대응
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{relatedPapers.map((p) => (
|
||||
<div key={p.title} className="rounded-[9px] border border-stroke bg-bg-surface p-3">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{p.tags.map((t) => (
|
||||
<span
|
||||
key={t.label}
|
||||
className="rounded px-[7px] py-0.5 text-label-2 font-bold font-korean"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${t.color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${t.color} 22%, transparent)`,
|
||||
color: t.color,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-label-2 font-korean text-fg-disabled">{p.year}</span>
|
||||
</div>
|
||||
<div className="mb-1 text-label-2 font-bold font-korean text-fg">{p.title}</div>
|
||||
<div className="mb-1.5 text-label-2 font-korean text-fg-disabled">{p.meta}</div>
|
||||
<div className="text-label-2 font-korean leading-[1.7] text-fg-sub">
|
||||
{p.abstract}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu';
|
||||
import { RescueTheoryView } from './RescueTheoryView';
|
||||
import { RescueScenarioView } from './RescueScenarioView';
|
||||
@ -190,30 +190,25 @@ function TopInfoBar({ activeType }: { activeType: AccidentType }) {
|
||||
<span className="text-title-3">⚓</span>
|
||||
<span className="text-label-1 font-bold text-fg font-korean">긴급구난지원</span>
|
||||
</div>
|
||||
<div
|
||||
className="px-3.5 py-0.5 rounded-xl text-label-2 font-bold text-color-danger font-korean"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 35%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-label-2 text-color-danger font-korean">
|
||||
{/* <span className="w-2.5 h-2.5 rounded-sm bg-color-danger inline-block" /> */}
|
||||
사고: {d.incident}
|
||||
</div>
|
||||
<div className="flex gap-3 text-caption font-mono text-fg-disabled ml-auto">
|
||||
<span>
|
||||
생존자: <b className="text-fg">{d.survivors}</b>/{d.total}
|
||||
생존자: <span className="text-fg">{d.survivors}</span>/{d.total}
|
||||
</span>
|
||||
<span>
|
||||
실종: <b className="text-color-danger">{d.missing}</b>
|
||||
실종: <span className="text-fg">{d.missing}</span>
|
||||
</span>
|
||||
<span>
|
||||
GM: <b style={{ color: gmColor(d.gm) }}>{d.gm}m</b>
|
||||
GM: <span className="text-fg">{d.gm}m</span>
|
||||
</span>
|
||||
<span>
|
||||
횡경사: <b style={{ color: listColor(d.list) }}>{d.list}°</b>
|
||||
횡경사: <span className="text-fg">{d.list}°</span>
|
||||
</span>
|
||||
<span>
|
||||
유출량: <b className="text-color-warning">{d.oilRate}</b>
|
||||
유출량: <span className="text-fg">{d.oilRate}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-title-4 font-bold text-fg font-mono">{clock}</div>
|
||||
@ -233,7 +228,7 @@ function LeftPanel({
|
||||
return (
|
||||
<div className="w-[208px] min-w-[208px] bg-bg-base border-r border-stroke flex flex-col overflow-y-auto scrollbar-thin p-2 gap-0.5">
|
||||
{/* 사고유형 제목 */}
|
||||
<div className="text-caption font-bold text-color-info font-korean mb-0.5 tracking-wider">
|
||||
<div className="text-caption font-bold text-fg-disabled font-korean mb-0.5 tracking-wider">
|
||||
사고 유형 (INCIDENT TYPE)
|
||||
</div>
|
||||
|
||||
@ -264,16 +259,16 @@ function LeftPanel({
|
||||
<div className="text-caption font-bold text-fg-disabled font-korean mt-2.5 mb-1">
|
||||
긴급 경고 (CRITICAL ALERTS)
|
||||
</div>
|
||||
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-l-[3px] border-l-[var(--color-danger)] rounded-r text-caption font-bold text-color-danger font-korean">
|
||||
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
|
||||
GM 위험 수준 — 전복 위험
|
||||
</div>
|
||||
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] border-l-[3px] border-l-[var(--color-danger)] rounded-r text-caption font-bold text-color-danger font-korean">
|
||||
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
|
||||
승선자 5명 미확인
|
||||
</div>
|
||||
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-warning)_12%,transparent)] border-l-[3px] border-l-[var(--color-warning)] rounded-r text-caption font-bold text-color-warning font-korean">
|
||||
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
|
||||
유류 유출 감지 - 방제 필요
|
||||
</div>
|
||||
<div className="py-1.5 px-2.5 bg-[color-mix(in_srgb,var(--color-caution)_10%,transparent)] border-l-[3px] border-l-[var(--color-caution)] rounded-r text-caption font-bold text-color-caution font-korean">
|
||||
<div className="py-1.5 px-2.5 border-l-2 border-l-[var(--color-danger)] rounded-r text-caption text-fg-disabled font-korean">
|
||||
종강도 한계치 88% 접근
|
||||
</div>
|
||||
|
||||
@ -297,7 +292,6 @@ function LeftPanel({
|
||||
/* ─── 중앙 지도 영역 ─── */
|
||||
function CenterMap({ activeType }: { activeType: AccidentType }) {
|
||||
const d = rscTypeData[activeType];
|
||||
const at = accidentTypes.find((t) => t.id === activeType)!;
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative overflow-hidden bg-bg-base">
|
||||
@ -427,12 +421,12 @@ function CenterMap({ activeType }: { activeType: AccidentType }) {
|
||||
</div>
|
||||
|
||||
{/* 사고 유형 표시 */}
|
||||
<div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
|
||||
{/* <div className="absolute bottom-2.5 right-2.5 z-20 bg-[rgba(13,17,23,0.85)] border border-stroke rounded px-3 py-1.5">
|
||||
<div className="text-caption text-fg-disabled font-korean">현재 사고 유형</div>
|
||||
<div className="text-label-2 font-bold font-korean text-color-accent">
|
||||
{at.icon} {at.label} ({at.eng})
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 타임라인 시뮬레이션 컨트롤 */}
|
||||
<div className="absolute bottom-2.5 left-1/2 -translate-x-1/2 z-20 bg-[rgba(13,17,23,0.9)] border border-stroke rounded-md px-4 py-2 flex items-center gap-4 backdrop-blur-sm">
|
||||
@ -519,19 +513,13 @@ function RightPanel({
|
||||
|
||||
{/* Bottom Action Buttons */}
|
||||
<div className="flex gap-1.5 p-3 border-t border-stroke flex-shrink-0">
|
||||
<button
|
||||
className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer"
|
||||
style={{ border: '1px solid rgba(6,182,212,.3)', background: 'rgba(6,182,212,.08)' }}
|
||||
>
|
||||
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
|
||||
💾 저장
|
||||
</button>
|
||||
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold bg-bg-elevated border border-stroke text-fg font-korean cursor-pointer">
|
||||
🔄 재계산
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer"
|
||||
style={{ border: '1px solid rgba(6,182,212,.3)', background: 'rgba(6,182,212,.08)' }}
|
||||
>
|
||||
<button className="flex-1 py-2 px-1 rounded text-label-2 font-semibold text-color-accent font-korean cursor-pointer border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)]">
|
||||
📄 보고서
|
||||
</button>
|
||||
</div>
|
||||
@ -547,14 +535,8 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
return (
|
||||
<div className="flex flex-col p-2.5 gap-2">
|
||||
<div className="text-label-1 font-bold text-fg font-korean">구난 분석 (RESCUE ANALYSIS)</div>
|
||||
<div
|
||||
className="text-caption text-color-info font-korean px-2 py-1 rounded"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-info) 8%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-info) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
📌 현재 사고유형: {at.label} ({at.eng})
|
||||
<div className="text-caption text-fg-sub font-korean px-2 py-1 rounded bg-bg-card border border-stroke">
|
||||
현재 사고유형: {at.label} ({at.eng})
|
||||
</div>
|
||||
|
||||
{/* 선박 단면도 SVG */}
|
||||
@ -589,14 +571,14 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
width="30"
|
||||
height="18"
|
||||
rx="1"
|
||||
fill="rgba(239,68,68,.12)"
|
||||
stroke="rgba(239,68,68,.35)"
|
||||
fill="color-mix(in srgb, var(--color-danger) 12%, transparent)"
|
||||
stroke="color-mix(in srgb, var(--color-danger) 35%, transparent)"
|
||||
strokeWidth=".6"
|
||||
/>
|
||||
<text
|
||||
x="57"
|
||||
y="39"
|
||||
fill="rgba(239,68,68,.5)"
|
||||
fill="color-mix(in srgb, var(--color-danger) 50%, transparent)"
|
||||
fontSize="4.5"
|
||||
textAnchor="middle"
|
||||
fontFamily="var(--font-mono)"
|
||||
@ -645,7 +627,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
y1="8"
|
||||
x2="130"
|
||||
y2="60"
|
||||
stroke="rgba(251,191,36,.15)"
|
||||
stroke="color-mix(in srgb, var(--fg-disabled) 15%, transparent)"
|
||||
strokeWidth=".4"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
@ -666,7 +648,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
unit="m"
|
||||
color={gmColor(d.gm)}
|
||||
sub={`${parseFloat(d.gm) < 1.0 ? '위험' : parseFloat(d.gm) < 1.5 ? '주의' : '정상'} (기준: 1.5m 이상)`}
|
||||
subColor={gmColor(d.gm)}
|
||||
/>
|
||||
<MetricCard
|
||||
label="LIST (횡경사)"
|
||||
@ -674,7 +655,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
unit="°"
|
||||
color={listColor(d.list)}
|
||||
sub={`${parseFloat(d.list) > 20 ? '위험' : parseFloat(d.list) > 10 ? '주의' : '정상'} (기준: 10° 이내)`}
|
||||
subColor={listColor(d.list)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
@ -684,7 +664,6 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
unit="m"
|
||||
color={trimColor(d.trim)}
|
||||
sub="선미 침하 (AFT SINKAGE)"
|
||||
subColor={trimColor(d.trim)}
|
||||
/>
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">
|
||||
@ -709,77 +688,45 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
{ en: 'BALLAST INJECT', ko: '밸러스트 주입', color: 'var(--color-danger)' },
|
||||
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출', color: 'var(--color-danger)' },
|
||||
{ en: 'ENGINE STOP', ko: '기관 정지', color: 'var(--color-warning)' },
|
||||
{ en: 'ANCHOR DROP', ko: '묘 투하', color: 'var(--color-warning)' },
|
||||
{ en: 'BALLAST INJECT', ko: '밸러스트 주입' },
|
||||
{ en: 'BALLAST DISCHARGE', ko: '밸러스트 배출' },
|
||||
{ en: 'ENGINE STOP', ko: '기관 정지' },
|
||||
{ en: 'ANCHOR DROP', ko: '묘 투하' },
|
||||
].map((btn, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="py-[7px] rounded-[5px] text-center cursor-pointer transition-all hover:brightness-125"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${btn.color} 8%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${btn.color} 25%, transparent)`,
|
||||
}}
|
||||
className="py-[7px] rounded-[5px] text-center cursor-pointer transition-all hover:brightness-125 bg-bg-card border border-stroke"
|
||||
>
|
||||
<div className="text-label-2 font-bold text-fg font-mono">{btn.en}</div>
|
||||
<div className="text-caption font-korean" style={{ color: btn.color }}>
|
||||
{btn.ko}
|
||||
</div>
|
||||
<div className="text-label-2 font-bold text-fg-sub font-mono">{btn.en}</div>
|
||||
<div className="text-caption font-korean text-color-danger">{btn.ko}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 구난 의사결정 프로세스 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
구난 의사결정 프로세스 (KRISO Decision Support)
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-caption font-korean">
|
||||
<div className="flex items-center gap-1">
|
||||
{[
|
||||
{ label: '① 상태평가', color: 'var(--color-accent)' },
|
||||
{ label: '② 사례분석', color: 'var(--color-info)' },
|
||||
{ label: '③ 장비선정', color: 'var(--fg-sub)' },
|
||||
].map((s, i) => (
|
||||
<>
|
||||
{['① 상태평가', '② 사례분석', '③ 장비선정'].map((label, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <span className="text-fg-disabled">→</span>}
|
||||
<div
|
||||
key={i}
|
||||
className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${s.color} 12%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${s.color} 25%, transparent)`,
|
||||
color: s.color,
|
||||
minWidth: '68px',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
|
||||
{label}
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{[
|
||||
{ label: '④ 예인력', color: 'var(--color-caution)' },
|
||||
{ label: '⑤ 이초/인양', color: 'var(--color-warning)' },
|
||||
{ label: '⑥ 유출량', color: 'var(--color-danger)' },
|
||||
].map((s, i) => (
|
||||
<>
|
||||
{['④ 예인력', '⑤ 이초/인양', '⑥ 유출량'].map((label, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <span className="text-fg-disabled">→</span>}
|
||||
<div
|
||||
key={i}
|
||||
className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${s.color} 10%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${s.color} 25%, transparent)`,
|
||||
color: s.color,
|
||||
minWidth: '68px',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
<div className="px-1.5 py-0.5 rounded-sm text-center flex-shrink-0 min-w-[68px] bg-bg-card border border-stroke text-fg-sub">
|
||||
{label}
|
||||
</div>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -787,7 +734,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
|
||||
{/* 유체 정역학 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
유체 정역학 (Hydrostatics)
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
|
||||
@ -799,10 +746,10 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
{ label: 'TPC', value: '22.8 t/cm' },
|
||||
{ label: 'MTC', value: '185 t·m' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
|
||||
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
|
||||
<br />
|
||||
<b className="text-fg">{r.value}</b>
|
||||
<b className="text-fg-sub">{r.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -810,20 +757,20 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
|
||||
{/* 예인력/이초력 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
예인력 / 이초력 (Towing & Refloating)
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
|
||||
{[
|
||||
{ label: '필요 예인력', value: '285 kN', color: 'var(--color-caution)' },
|
||||
{ label: '비상 예인력', value: '420 kN', color: 'var(--color-danger)' },
|
||||
{ label: '이초 반력', value: '1,850 kN', color: 'var(--color-warning)' },
|
||||
{ label: '인양 안전성', value: 'FAIL', color: 'var(--color-danger)' },
|
||||
{ label: '필요 예인력', value: '285 kN' },
|
||||
{ label: '비상 예인력', value: '420 kN' },
|
||||
{ label: '이초 반력', value: '1,850 kN' },
|
||||
{ label: '인양 안전성', value: 'FAIL' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
|
||||
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
|
||||
<br />
|
||||
<b style={{ color: r.color }}>{r.value}</b>
|
||||
<b className="text-fg-sub">{r.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -834,19 +781,19 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
|
||||
{/* 유출량 추정 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
유출량 추정 (Oil Outflow Estimation)
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-caption font-mono">
|
||||
{[
|
||||
{ label: '현재 유출률', value: d.oilRate, color: 'var(--color-warning)' },
|
||||
{ label: '누적 유출량', value: '6.8 kL', color: 'var(--color-danger)' },
|
||||
{ label: '24h 예측', value: '145 kL', color: 'var(--color-danger)' },
|
||||
{ label: '현재 유출률', value: d.oilRate },
|
||||
{ label: '누적 유출량', value: '6.8 kL' },
|
||||
{ label: '24h 예측', value: '145 kL' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm text-center">
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm text-center">
|
||||
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
|
||||
<br />
|
||||
<b style={{ color: r.color }}>{r.value}</b>
|
||||
<b className="text-fg-sub">{r.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -855,7 +802,7 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
className="h-full rounded-sm"
|
||||
style={{
|
||||
width: '68%',
|
||||
background: 'linear-gradient(90deg, var(--color-warning), var(--color-danger))',
|
||||
background: 'var(--color-danger)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -866,49 +813,24 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
|
||||
{/* CBR 사례기반 추론 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
CBR 유사 사고 사례 (Case-Based Reasoning)
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{[
|
||||
{
|
||||
pct: '94%',
|
||||
name: 'Hebei Spirit (2007)',
|
||||
desc: '태안 · 충돌 · 원유 12,547kL 유출',
|
||||
color: 'var(--color-accent)',
|
||||
},
|
||||
{
|
||||
pct: '87%',
|
||||
name: 'Sea Empress (1996)',
|
||||
desc: '밀포드 · 좌초 · 72,000t 유출',
|
||||
color: 'var(--color-info)',
|
||||
},
|
||||
{
|
||||
pct: '82%',
|
||||
name: 'Rena (2011)',
|
||||
desc: '타우랑가 · 좌초 · 350t HFO 유출',
|
||||
color: 'var(--fg-sub)',
|
||||
},
|
||||
{ pct: '94%', name: 'Hebei Spirit (2007)', desc: '태안 · 충돌 · 원유 12,547kL 유출' },
|
||||
{ pct: '87%', name: 'Sea Empress (1996)', desc: '밀포드 · 좌초 · 72,000t 유출' },
|
||||
{ pct: '82%', name: 'Rena (2011)', desc: '타우랑가 · 좌초 · 350t HFO 유출' },
|
||||
].map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:brightness-125"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${c.color} 6%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${c.color} 15%, transparent)`,
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer hover:brightness-125 bg-bg-card border border-stroke"
|
||||
>
|
||||
<div
|
||||
className="w-7 h-4 rounded-sm flex items-center justify-center text-caption font-bold font-mono"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${c.color} 20%, transparent)`,
|
||||
color: c.color,
|
||||
}}
|
||||
>
|
||||
<div className="w-7 h-4 rounded-sm flex items-center justify-center text-caption font-bold font-mono text-fg-sub bg-bg-surface-hover">
|
||||
{c.pct}
|
||||
</div>
|
||||
<div className="flex-1 text-caption font-korean">
|
||||
<b className="text-fg">{c.name}</b>
|
||||
<b className="text-fg-sub">{c.name}</b>
|
||||
<br />
|
||||
<span className="text-fg-disabled">{c.desc}</span>
|
||||
</div>
|
||||
@ -918,37 +840,21 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
</div>
|
||||
|
||||
{/* 위험도 평가 */}
|
||||
<div
|
||||
className="rounded-md p-2"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 4%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 15%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="text-caption font-bold text-color-danger font-korean mb-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
위험도 평가 — 2차사고 시나리오
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-caption font-korean">
|
||||
{[
|
||||
{ label: '침수 확대 → 전복', level: 'HIGH', color: 'var(--color-danger)' },
|
||||
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', color: 'var(--color-warning)' },
|
||||
{ label: '선체 절단 (BM 초과)', level: 'MED', color: 'var(--color-caution)' },
|
||||
{ label: '화재/폭발', level: 'LOW', color: 'var(--fg-sub)' },
|
||||
{ label: '침수 확대 → 전복', level: 'HIGH', danger: true },
|
||||
{ label: '유류 대량 유출 → 해양오염', level: 'HIGH', danger: true },
|
||||
{ label: '선체 절단 (BM 초과)', level: 'MED', danger: true },
|
||||
{ label: '화재/폭발', level: 'LOW', danger: false },
|
||||
].map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between px-1.5 py-0.5 rounded-sm"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${r.color} 8%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span className="text-fg">{r.label}</span>
|
||||
<div key={i} className="flex items-center justify-between px-1.5 py-0.5 rounded-sm">
|
||||
<span className="text-fg-sub">{r.label}</span>
|
||||
<span
|
||||
className="px-1.5 py-px rounded-lg text-caption font-bold"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${r.color} 20%, transparent)`,
|
||||
color: r.color,
|
||||
}}
|
||||
className={`px-1.5 py-px rounded-lg text-caption font-bold ${r.danger ? 'text-color-danger' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{r.level}
|
||||
</span>
|
||||
@ -959,30 +865,24 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) {
|
||||
|
||||
{/* 해상 e-Call */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
해상 e-Call (GMDSS / VHF-DSC)
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-caption font-mono text-fg-disabled">
|
||||
{[
|
||||
{ label: 'MMSI', value: '440123456' },
|
||||
{ label: 'Nature of Distress', value: 'COLLISION', color: 'var(--color-danger)' },
|
||||
{ label: 'DSC Alert', value: 'SENT ✓', color: 'var(--color-success)' },
|
||||
{ label: 'EPIRB', value: 'ACTIVATED ✓', color: 'var(--color-success)' },
|
||||
{ label: 'VTS 인천', value: 'ACK 10:36', color: 'var(--color-info)' },
|
||||
{ label: 'MMSI', value: '440123456', danger: false },
|
||||
{ label: 'Nature of Distress', value: 'COLLISION', danger: true },
|
||||
{ label: 'DSC Alert', value: 'SENT ✓', danger: false },
|
||||
{ label: 'EPIRB', value: 'ACTIVATED ✓', danger: false },
|
||||
{ label: 'VTS 인천', value: 'ACK 10:36', danger: false },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<span className="font-korean">{r.label}</span>
|
||||
<b style={{ color: r.color }}>{r.value}</b>
|
||||
<b className={r.danger ? 'text-color-danger' : 'text-fg-sub'}>{r.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="w-full mt-1 py-1 rounded text-caption font-bold text-color-danger cursor-pointer font-korean"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 12%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
<button className="w-full mt-1 py-1 rounded text-caption font-bold cursor-pointer font-korean bg-bg-card border border-stroke text-fg-sub">
|
||||
📡 DISTRESS RELAY 전송
|
||||
</button>
|
||||
</div>
|
||||
@ -1006,7 +906,7 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
{/* GZ Curve SVG */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
GZ 복원력 곡선 (Righting Lever Curve)
|
||||
</div>
|
||||
<svg viewBox="0 0 260 120" className="w-full" style={{ height: '100px' }}>
|
||||
@ -1060,14 +960,14 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
|
||||
y1="78"
|
||||
x2="255"
|
||||
y2="78"
|
||||
stroke="rgba(251,191,36,.4)"
|
||||
stroke="var(--color-danger)"
|
||||
strokeWidth=".6"
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
<text
|
||||
x="240"
|
||||
y="76"
|
||||
fill="var(--color-caution)"
|
||||
fill="var(--color-danger)"
|
||||
fontSize="4.5"
|
||||
fontFamily="var(--font-mono)"
|
||||
>
|
||||
@ -1150,42 +1050,34 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
|
||||
unit="m"
|
||||
color="var(--color-danger)"
|
||||
sub="기준: 0.2m 이상 ⚠"
|
||||
subColor="var(--color-danger)"
|
||||
/>
|
||||
<MetricCard
|
||||
label="θ_max (최대복원각)"
|
||||
value="28"
|
||||
unit="°"
|
||||
color="var(--color-caution)"
|
||||
color="var(--color-danger)"
|
||||
sub="기준: 25° 이상 ✓"
|
||||
subColor="var(--color-caution)"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">침수 구획수</div>
|
||||
<div className="text-title-3 font-bold text-color-danger font-mono">
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">
|
||||
2 <span className="text-caption">구획</span>
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">#1 선수탱크 / #3 좌현탱크</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">Margin Line 여유</div>
|
||||
<div className="text-title-3 font-bold text-color-danger font-mono">
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">
|
||||
0.12 <span className="text-caption">m</span>
|
||||
</div>
|
||||
<div className="text-caption text-color-danger font-korean">침수 임계점 임박</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">침수 임계점 임박</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SOLAS 판정 */}
|
||||
<div
|
||||
className="rounded-md p-2.5"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-danger) 6%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
|
||||
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
|
||||
⚠ SOLAS 손상복원성 판정: 부적합 (FAIL)
|
||||
</div>
|
||||
@ -1201,20 +1093,20 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
{/* 좌초시 복원성 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
|
||||
좌초시 복원성 (Grounded Stability)
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-caption font-mono">
|
||||
{[
|
||||
{ label: '지반반력', value: '1,240 kN', color: 'var(--color-caution)' },
|
||||
{ label: '지반반력', value: '1,240 kN' },
|
||||
{ label: '접촉 면적', value: '12.5 m²' },
|
||||
{ label: '제거력(Removal)', value: '1,850 kN', color: 'var(--color-danger)' },
|
||||
{ label: '좌초 GM', value: '0.65 m', color: 'var(--color-caution)' },
|
||||
{ label: '제거력(Removal)', value: '1,850 kN' },
|
||||
{ label: '좌초 GM', value: '0.65 m' },
|
||||
].map((r, i) => (
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-base rounded-sm">
|
||||
<div key={i} className="px-1.5 py-1 bg-bg-card rounded-sm">
|
||||
<span className="text-fg-disabled font-korean text-caption">{r.label}</span>
|
||||
<br />
|
||||
<b style={{ color: r.color }}>{r.value}</b>
|
||||
<b className="text-fg-sub">{r.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -1222,27 +1114,31 @@ function DamageStabilityPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
{/* 탱크 상태 */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1">
|
||||
탱크 상태 (Tank Volume Status)
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 text-caption font-korean">
|
||||
{[
|
||||
{ name: '#1 FP Tank', pct: 100, status: '침수', color: 'var(--color-danger)' },
|
||||
{ name: '#3 Port Tank', pct: 85, status: '85%', color: 'var(--color-danger)' },
|
||||
{ name: '#2 DB Tank', pct: 45, status: '45%', color: 'var(--color-success)' },
|
||||
{ name: 'Ballast #4', pct: 72, status: '72%', color: 'var(--color-info)' },
|
||||
{ name: 'Fuel Oil #5', pct: 68, status: '68%', color: 'var(--color-warning)' },
|
||||
{ name: '#1 FP Tank', pct: 100, status: '침수', danger: true },
|
||||
{ name: '#3 Port Tank', pct: 85, status: '85%', danger: true },
|
||||
{ name: '#2 DB Tank', pct: 45, status: '45%', danger: false },
|
||||
{ name: 'Ballast #4', pct: 72, status: '72%', danger: false },
|
||||
{ name: 'Fuel Oil #5', pct: 68, status: '68%', danger: false },
|
||||
].map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm flex-shrink-0" style={{ background: t.color }} />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-sm flex-shrink-0 ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
|
||||
/>
|
||||
<span className="w-[65px] text-fg-disabled">{t.name}</span>
|
||||
<div className="flex-1 h-1 bg-bg-surface-hover rounded-sm">
|
||||
<div
|
||||
className="h-full rounded-sm"
|
||||
style={{ width: `${t.pct}%`, background: t.color }}
|
||||
className={`h-full rounded-sm ${t.danger ? 'bg-color-danger' : 'bg-color-info'}`}
|
||||
style={{ width: `${t.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="min-w-[35px] text-right" style={{ color: t.color }}>
|
||||
<span
|
||||
className={`min-w-[35px] text-right ${t.danger ? 'text-fg-sub' : 'text-fg-disabled'}`}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
</div>
|
||||
@ -1269,7 +1165,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
{/* 전단력 분포 SVG */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
전단력 분포 (Shear Force Distribution)
|
||||
</div>
|
||||
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
|
||||
@ -1280,7 +1176,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
y1="18"
|
||||
x2="255"
|
||||
y2="18"
|
||||
stroke="rgba(239,68,68,.25)"
|
||||
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
|
||||
strokeWidth=".5"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
@ -1289,17 +1185,22 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
y1="72"
|
||||
x2="255"
|
||||
y2="72"
|
||||
stroke="rgba(239,68,68,.25)"
|
||||
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
|
||||
strokeWidth=".5"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
<text x="240" y="15" fill="rgba(239,68,68,.4)" fontSize="4">
|
||||
<text
|
||||
x="240"
|
||||
y="15"
|
||||
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
|
||||
fontSize="4"
|
||||
>
|
||||
LIMIT
|
||||
</text>
|
||||
<polyline
|
||||
points="25,45 50,38 75,28 100,22 120,20 140,25 160,35 180,45 200,52 220,58 240,55 255,50"
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
stroke="var(--color-info)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<polyline
|
||||
@ -1335,7 +1236,7 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
{/* 굽힘모멘트 분포 SVG */}
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
|
||||
<div className="text-caption font-bold text-fg font-korean mb-1.5">
|
||||
<div className="text-caption font-bold text-fg-sub font-korean mb-1.5">
|
||||
굽힘모멘트 분포 (Bending Moment Distribution)
|
||||
</div>
|
||||
<svg viewBox="0 0 260 90" className="w-full" style={{ height: '75px' }}>
|
||||
@ -1346,27 +1247,32 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
y1="15"
|
||||
x2="255"
|
||||
y2="15"
|
||||
stroke="rgba(239,68,68,.25)"
|
||||
stroke="color-mix(in srgb, var(--color-danger) 25%, transparent)"
|
||||
strokeWidth=".5"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
<text x="240" y="12" fill="rgba(239,68,68,.4)" fontSize="4">
|
||||
<text
|
||||
x="240"
|
||||
y="12"
|
||||
fill="color-mix(in srgb, var(--color-danger) 40%, transparent)"
|
||||
fontSize="4"
|
||||
>
|
||||
LIMIT
|
||||
</text>
|
||||
<polyline
|
||||
points="25,45 50,40 75,32 100,22 130,18 160,22 190,32 220,40 255,45"
|
||||
fill="none"
|
||||
stroke="var(--color-success)"
|
||||
stroke="var(--color-info)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<polyline
|
||||
points="25,45 50,38 75,26 100,16 130,12 160,16 190,28 220,38 255,45"
|
||||
fill="none"
|
||||
stroke="var(--color-warning)"
|
||||
stroke="var(--color-danger)"
|
||||
strokeWidth="1.2"
|
||||
strokeDasharray="3,2"
|
||||
/>
|
||||
<text x="115" y="8" fill="var(--color-warning)" fontSize="4.5">
|
||||
<text x="115" y="8" fill="var(--color-danger)" fontSize="4.5">
|
||||
손상 후 BM ▲
|
||||
</text>
|
||||
<text x="2" y="14" fill="var(--fg-disabled)" fontSize="4" fontFamily="var(--font-mono)">
|
||||
@ -1391,50 +1297,44 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">SF 최대/허용 비율</div>
|
||||
<div className="text-title-3 font-bold text-color-caution font-mono">
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">
|
||||
88<span className="text-caption">%</span>
|
||||
</div>
|
||||
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
|
||||
<div className="h-full rounded-sm w-[88%] bg-color-caution" />
|
||||
<div className="h-full rounded-sm w-[88%] bg-color-danger" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">BM 최대/허용 비율</div>
|
||||
<div className="text-title-3 font-bold text-color-warning font-mono">
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">
|
||||
92<span className="text-caption">%</span>
|
||||
</div>
|
||||
<div className="h-[5px] bg-bg-surface-hover rounded-sm mt-0.5">
|
||||
<div className="h-full rounded-sm w-[92%] bg-color-warning" />
|
||||
<div className="h-full rounded-sm w-[92%] bg-color-danger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">Section Modulus 여유</div>
|
||||
<div className="text-title-3 font-bold text-color-success font-mono">1.08</div>
|
||||
<div className="text-caption text-color-success font-korean">Req'd: 1.00 이상 ✓</div>
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">1.08</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.00 이상 ✓</div>
|
||||
</div>
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">Hull Girder ULS</div>
|
||||
<div className="text-title-3 font-bold text-color-caution font-mono">1.12</div>
|
||||
<div className="text-caption text-color-caution font-korean">Req'd: 1.10 이상 ⚠</div>
|
||||
<div className="text-title-3 font-bold text-fg-sub font-mono">1.12</div>
|
||||
<div className="text-caption text-fg-disabled font-korean">Req'd: 1.10 이상 ⚠</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 판정 */}
|
||||
<div
|
||||
className="rounded-md p-2.5"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--color-caution) 6%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--color-caution) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="text-label-2 font-bold text-color-caution font-korean mb-1">
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2.5">
|
||||
<div className="text-label-2 font-bold text-color-danger font-korean mb-1">
|
||||
⚠ 종강도 판정: 주의 (CAUTION)
|
||||
</div>
|
||||
<div className="text-caption text-fg-disabled font-korean leading-snug">
|
||||
· SF 최대: 허용치의 88% — <span className="text-color-caution">주의 구간</span>
|
||||
<br />· BM 최대: 허용치의 92% — <span className="text-color-warning">경고 구간</span>
|
||||
· SF 최대: 허용치의 88% — <span className="text-color-danger">주의 구간</span>
|
||||
<br />· BM 최대: 허용치의 92% — <span className="text-color-danger">경고 구간</span>
|
||||
<br />
|
||||
· 중앙부 Hogging 모멘트 증가 — 추가 침수 시 선체 절단 위험
|
||||
<br />· <span className="text-color-danger">밸러스트 이동으로 BM 분산 필요</span>
|
||||
@ -1446,89 +1346,61 @@ function LongStrengthPanel(_props: { activeType: AccidentType }) {
|
||||
|
||||
/* ─── 하단 바: 이벤트 로그 + 타임라인 ─── */
|
||||
function BottomBar() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
return (
|
||||
<div className="h-[145px] min-h-[145px] border-t border-stroke flex bg-bg-base flex-shrink-0">
|
||||
{/* 이벤트 로그 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1 border-b border-stroke flex-shrink-0">
|
||||
<span className="text-caption font-bold text-fg-disabled font-korean">
|
||||
이벤트 로그 / 통신 기록 (EVENT LOG / COMMUNICATION TRANSCRIPT)
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{[
|
||||
{ label: '전체', color: 'var(--color-accent)' },
|
||||
{ label: '긴급', color: 'var(--color-warning)' },
|
||||
{ label: '통신', color: 'var(--color-info)' },
|
||||
].map((f, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="px-2 py-px rounded-sm text-caption font-bold cursor-pointer font-korean"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${f.color} 15%, transparent)`,
|
||||
border: `1px solid color-mix(in srgb, ${f.color} 30%, transparent)`,
|
||||
color: f.color,
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-1 font-mono text-caption leading-[1.7] scrollbar-thin">
|
||||
{[
|
||||
{
|
||||
time: '10:35',
|
||||
msg: 'SOS FROM M/V SEA GUARDIAN',
|
||||
color: 'var(--color-danger)',
|
||||
bold: true,
|
||||
},
|
||||
{ time: '10:35', msg: 'OIL LEAK DETECTED SENSOR #3', bold: false },
|
||||
{ time: '10:40', msg: 'CG HELO DISPATCHED', color: 'var(--color-danger)', bold: true },
|
||||
{
|
||||
time: '10:41',
|
||||
msg: 'GM CRITICAL ALERT — DAMAGE STABILITY FAIL',
|
||||
color: 'var(--color-danger)',
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
time: '10:42',
|
||||
msg: 'Coast Guard 123 en route — ETA 15 min',
|
||||
color: 'var(--color-info)',
|
||||
bold: false,
|
||||
},
|
||||
{
|
||||
time: '10:43',
|
||||
msg: 'LONGITUDINAL STRENGTH WARNING — BM 92% of LIMIT',
|
||||
color: 'var(--color-caution)',
|
||||
bold: false,
|
||||
},
|
||||
{ time: '10:45', msg: 'BALLAST TRANSFER INITIATED — PORT #2 → STBD #3', bold: false },
|
||||
{
|
||||
time: '10:48',
|
||||
msg: 'LIST INCREASING — 12° → 15°',
|
||||
color: 'var(--color-caution)',
|
||||
bold: false,
|
||||
},
|
||||
{
|
||||
time: '10:50',
|
||||
msg: 'RESCUE HELO ON SCENE — HOISTING OPS',
|
||||
color: 'var(--color-info)',
|
||||
bold: false,
|
||||
},
|
||||
{
|
||||
time: '10:55',
|
||||
msg: '5 SURVIVORS RECOVERED BY HELO',
|
||||
color: 'var(--color-success)',
|
||||
bold: false,
|
||||
},
|
||||
].map((e, i) => (
|
||||
<div key={i}>
|
||||
<span className="text-fg-disabled">[{e.time}]</span>{' '}
|
||||
<span style={{ color: e.color, fontWeight: e.bold ? 700 : 400 }}>{e.msg}</span>
|
||||
</div>
|
||||
<div className="border-t border-stroke flex flex-col bg-bg-base flex-shrink-0">
|
||||
{/* 제목 바 — 항상 표시 */}
|
||||
<div className="flex items-center px-3 py-1 border-b border-stroke flex-shrink-0">
|
||||
<span className="text-caption font-bold text-fg-disabled font-korean">
|
||||
이벤트 로그 / 통신 기록 (EVENT LOG / COMMUNICATION TRANSCRIPT)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex-1 text-center text-caption text-fg-disabled hover:text-fg cursor-pointer transition-colors"
|
||||
>
|
||||
{isOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{['전체', '긴급', '통신'].map((label, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="px-2 py-px rounded-sm text-caption cursor-pointer font-korean border border-stroke text-fg-disabled hover:text-fg"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 이벤트 로그 내용 — 토글 */}
|
||||
{isOpen && (
|
||||
<div className="h-[120px] overflow-y-auto px-3 py-1 font-mono text-caption leading-[1.7] scrollbar-thin">
|
||||
{[
|
||||
{ time: '10:35', msg: 'SOS FROM M/V SEA GUARDIAN', important: true },
|
||||
{ time: '10:35', msg: 'OIL LEAK DETECTED SENSOR #3', important: false },
|
||||
{ time: '10:40', msg: 'CG HELO DISPATCHED', important: true },
|
||||
{ time: '10:41', msg: 'GM CRITICAL ALERT — DAMAGE STABILITY FAIL', important: true },
|
||||
{ time: '10:42', msg: 'Coast Guard 123 en route — ETA 15 min', important: false },
|
||||
{
|
||||
time: '10:43',
|
||||
msg: 'LONGITUDINAL STRENGTH WARNING — BM 92% of LIMIT',
|
||||
important: false,
|
||||
},
|
||||
{
|
||||
time: '10:45',
|
||||
msg: 'BALLAST TRANSFER INITIATED — PORT #2 → STBD #3',
|
||||
important: false,
|
||||
},
|
||||
{ time: '10:48', msg: 'LIST INCREASING — 12° → 15°', important: false },
|
||||
{ time: '10:50', msg: 'RESCUE HELO ON SCENE — HOISTING OPS', important: false },
|
||||
{ time: '10:55', msg: '5 SURVIVORS RECOVERED BY HELO', important: true },
|
||||
].map((e, i) => (
|
||||
<div key={i}>
|
||||
<span className="text-fg-disabled">[{e.time}]</span>{' '}
|
||||
<span className={e.important ? 'text-color-accent' : 'text-fg'}>{e.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1540,25 +1412,21 @@ function MetricCard({
|
||||
unit,
|
||||
color,
|
||||
sub,
|
||||
subColor,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
color: string;
|
||||
sub: string;
|
||||
subColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-bg-card border border-stroke rounded-md p-2">
|
||||
<div className="text-caption text-fg-disabled font-korean">{label}</div>
|
||||
<div className="text-title-2 font-bold font-mono" style={{ color }}>
|
||||
<div className="text-title-3 font-bold font-mono" style={{ color }}>
|
||||
{value}
|
||||
<span className="text-label-2"> {unit}</span>
|
||||
</div>
|
||||
<div className="text-caption font-korean" style={{ color: subColor }}>
|
||||
{sub}
|
||||
<span className="text-caption"> {unit}</span>
|
||||
</div>
|
||||
<div className="text-caption font-korean text-fg-disabled">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,43 +62,43 @@ function SegRow(
|
||||
}}
|
||||
className={`bg-bg-card border border-stroke 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)]'
|
||||
? 'border-status-green bg-[color-mix(in_srgb,var(--color-success)_5%,transparent)]'
|
||||
: 'hover:border-stroke-light hover:bg-bg-surface-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">
|
||||
<span className="text-caption 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"
|
||||
className="text-caption 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]">
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">유형</span>
|
||||
<span className="text-fg font-medium font-mono text-[11px]">{seg.type}</span>
|
||||
<span className="text-fg font-medium font-mono text-label-2">{seg.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">길이</span>
|
||||
<span className="text-fg font-medium font-mono text-[11px]">{seg.length}</span>
|
||||
<span className="text-fg font-medium font-mono text-label-2">{seg.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">민감</span>
|
||||
<span
|
||||
className="font-medium font-mono text-[11px]"
|
||||
className="font-medium font-mono text-label-2"
|
||||
style={{ color: sensColor[seg.sensitivity] }}
|
||||
>
|
||||
{seg.sensitivity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<div className="flex justify-between text-label-2">
|
||||
<span className="text-fg-sub font-korean">현황</span>
|
||||
<span
|
||||
className="font-medium font-mono text-[11px]"
|
||||
className="font-medium font-mono text-label-2"
|
||||
style={{ color: statusColor[seg.status] }}
|
||||
>
|
||||
{seg.status}
|
||||
@ -159,13 +159,13 @@ function ScatLeftPanel({
|
||||
<div className="w-[340px] min-w-[340px] bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
||||
{/* Filters */}
|
||||
<div className="p-3.5 border-b border-stroke">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-fg mb-3">
|
||||
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
||||
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
||||
해안 조사 구역
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
|
||||
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
||||
관할 해경
|
||||
</label>
|
||||
<select
|
||||
@ -182,7 +182,7 @@ function ScatLeftPanel({
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
|
||||
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
||||
관할 구역
|
||||
</label>
|
||||
<select
|
||||
@ -200,7 +200,7 @@ function ScatLeftPanel({
|
||||
</div>
|
||||
|
||||
{/* <div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
|
||||
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
||||
해안 구역
|
||||
</label>
|
||||
<select
|
||||
@ -218,7 +218,7 @@ function ScatLeftPanel({
|
||||
</div> */}
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-fg mb-1 font-korean">
|
||||
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
||||
조사 단계
|
||||
</label>
|
||||
<select
|
||||
@ -255,12 +255,12 @@ function ScatLeftPanel({
|
||||
|
||||
{/* 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-fg mb-2.5">
|
||||
<div className="flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg mb-2.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
||||
해안 구간 목록
|
||||
</span>
|
||||
<span className="text-color-accent font-mono text-[10px]">
|
||||
<span className="text-color-accent font-mono text-caption">
|
||||
총 {filtered.length}개 구간
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1,66 +1,72 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
import type { ScatSegment } from './scatTypes'
|
||||
import type { ApiZoneItem } from '../services/scatApi'
|
||||
import { esiColor } from './scatConstants'
|
||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import { esiColor } from './scatConstants';
|
||||
import { hexToRgba } from '@common/components/map/mapUtils';
|
||||
|
||||
interface ScatMapProps {
|
||||
segments: ScatSegment[]
|
||||
zones: ApiZoneItem[]
|
||||
selectedSeg: ScatSegment
|
||||
jurisdictionFilter: string
|
||||
onSelectSeg: (s: ScatSegment) => void
|
||||
onOpenPopup: (idx: number) => void
|
||||
segments: ScatSegment[];
|
||||
zones: ApiZoneItem[];
|
||||
selectedSeg: ScatSegment;
|
||||
jurisdictionFilter: string;
|
||||
onSelectSeg: (s: ScatSegment) => void;
|
||||
onOpenPopup: (idx: number) => void;
|
||||
}
|
||||
|
||||
// ── DeckGLOverlay ──────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||
overlay.setProps({ layers })
|
||||
return null
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ─────────
|
||||
function FlyToController({ selectedSeg, zones }: { selectedSeg: ScatSegment; zones: ApiZoneItem[] }) {
|
||||
const { current: map } = useMap()
|
||||
const prevIdRef = useRef<number | undefined>(undefined)
|
||||
const prevZonesLenRef = useRef<number>(0)
|
||||
function FlyToController({
|
||||
selectedSeg,
|
||||
zones,
|
||||
}: {
|
||||
selectedSeg: ScatSegment;
|
||||
zones: ApiZoneItem[];
|
||||
}) {
|
||||
const { current: map } = useMap();
|
||||
const prevIdRef = useRef<number | undefined>(undefined);
|
||||
const prevZonesLenRef = useRef<number>(0);
|
||||
|
||||
// 선택 구간 변경 시
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
if (!map) return;
|
||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) {
|
||||
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 })
|
||||
map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 });
|
||||
}
|
||||
prevIdRef.current = selectedSeg.id
|
||||
}, [map, selectedSeg])
|
||||
prevIdRef.current = selectedSeg.id;
|
||||
}, [map, selectedSeg]);
|
||||
|
||||
// 관할해경(zones) 변경 시 지도 중심 이동
|
||||
useEffect(() => {
|
||||
if (!map || zones.length === 0) return
|
||||
if (prevZonesLenRef.current === zones.length) return
|
||||
prevZonesLenRef.current = zones.length
|
||||
const validZones = zones.filter(z => z.latCenter && z.lngCenter)
|
||||
if (validZones.length === 0) return
|
||||
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length
|
||||
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length
|
||||
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 })
|
||||
}, [map, zones])
|
||||
if (!map || zones.length === 0) return;
|
||||
if (prevZonesLenRef.current === zones.length) return;
|
||||
prevZonesLenRef.current = zones.length;
|
||||
const validZones = zones.filter((z) => z.latCenter && z.lngCenter);
|
||||
if (validZones.length === 0) return;
|
||||
const avgLat = validZones.reduce((a, z) => a + z.latCenter, 0) / validZones.length;
|
||||
const avgLng = validZones.reduce((a, z) => a + z.lngCenter, 0) / validZones.length;
|
||||
map.flyTo({ center: [avgLng, avgLat], zoom: 9, duration: 800 });
|
||||
}, [map, zones]);
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 줌 기반 스케일 계산 ─────────────────────────────────
|
||||
function getZoomScale(zoom: number) {
|
||||
const zScale = Math.max(0, zoom - 9) / 5
|
||||
const zScale = Math.max(0, zoom - 9) / 5;
|
||||
return {
|
||||
polyWidth: 1 + zScale * 4,
|
||||
selPolyWidth: 2 + zScale * 5,
|
||||
@ -68,7 +74,7 @@ function getZoomScale(zoom: number) {
|
||||
halfLenScale: 0.15 + zScale * 0.85,
|
||||
markerRadius: Math.round(6 + zScale * 16),
|
||||
showStatusMarker: zoom >= 11,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ──────────
|
||||
@ -78,46 +84,53 @@ function buildSegCoords(
|
||||
halfLenScale: number,
|
||||
segments: ScatSegment[],
|
||||
): [number, number][] {
|
||||
const idx = segments.indexOf(seg)
|
||||
const prev = idx > 0 ? segments[idx - 1] : seg
|
||||
const next = idx < segments.length - 1 ? segments[idx + 1] : seg
|
||||
const dlat = next.lat - prev.lat
|
||||
const dlng = next.lng - prev.lng
|
||||
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 idx = segments.indexOf(seg);
|
||||
const prev = idx > 0 ? segments[idx - 1] : seg;
|
||||
const next = idx < segments.length - 1 ? segments[idx + 1] : seg;
|
||||
const dlat = next.lat - prev.lat;
|
||||
const dlng = next.lng - prev.lng;
|
||||
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;
|
||||
return [
|
||||
[seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen],
|
||||
[seg.lng, seg.lat],
|
||||
[seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// ── 툴팁 상태 ───────────────────────────────────────────
|
||||
interface TooltipState {
|
||||
x: number
|
||||
y: number
|
||||
seg: ScatSegment
|
||||
x: number;
|
||||
y: number;
|
||||
seg: ScatSegment;
|
||||
}
|
||||
|
||||
// ── ScatMap ─────────────────────────────────────────────
|
||||
function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg, onOpenPopup }: ScatMapProps) {
|
||||
const currentMapStyle = useBaseMapStyle()
|
||||
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||
function ScatMap({
|
||||
segments,
|
||||
zones,
|
||||
selectedSeg,
|
||||
jurisdictionFilter,
|
||||
onSelectSeg,
|
||||
onOpenPopup,
|
||||
}: ScatMapProps) {
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
const [zoom, setZoom] = useState(10)
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||
const [zoom, setZoom] = useState(10);
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(seg: ScatSegment) => {
|
||||
onSelectSeg(seg)
|
||||
onOpenPopup(seg.id)
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
},
|
||||
[onSelectSeg, onOpenPopup],
|
||||
)
|
||||
);
|
||||
|
||||
const zs = useMemo(() => getZoomScale(zoom), [zoom])
|
||||
const zs = useMemo(() => getZoomScale(zoom), [zoom]);
|
||||
|
||||
// 제주도 해안선 레퍼런스 라인 — 하드코딩 제거, 추후 DB 기반 해안선으로 대체 예정
|
||||
// const coastlineLayer = useMemo(
|
||||
@ -153,7 +166,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
},
|
||||
}),
|
||||
[selectedSeg, segments, zs.glowWidth, zs.halfLenScale],
|
||||
)
|
||||
);
|
||||
|
||||
// ESI 색상 세그먼트 폴리라인
|
||||
const segPathLayer = useMemo(
|
||||
@ -163,9 +176,9 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
data: segments,
|
||||
getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale, segments),
|
||||
getColor: (d: ScatSegment) => {
|
||||
const isSelected = selectedSeg.id === d.id
|
||||
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum)
|
||||
return hexToRgba(hexCol, isSelected ? 242 : 178)
|
||||
const isSelected = selectedSeg.id === d.id;
|
||||
const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum);
|
||||
return hexToRgba(hexCol, isSelected ? 242 : 178);
|
||||
},
|
||||
getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth),
|
||||
capRounded: true,
|
||||
@ -174,13 +187,13 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
pickable: true,
|
||||
onHover: (info: { object?: ScatSegment; x: number; y: number }) => {
|
||||
if (info.object) {
|
||||
setTooltip({ x: info.x, y: info.y, seg: info.object })
|
||||
setTooltip({ x: info.x, y: info.y, seg: info.object });
|
||||
} else {
|
||||
setTooltip(null)
|
||||
setTooltip(null);
|
||||
}
|
||||
},
|
||||
onClick: (info: { object?: ScatSegment }) => {
|
||||
if (info.object) handleClick(info.object)
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getColor: [selectedSeg.id],
|
||||
@ -189,25 +202,25 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
},
|
||||
}),
|
||||
[segments, selectedSeg, zs, handleClick],
|
||||
)
|
||||
);
|
||||
|
||||
// 조사 상태 마커 (줌 >= 11 시 표시)
|
||||
const markerLayer = useMemo(() => {
|
||||
if (!zs.showStatusMarker) return null
|
||||
if (!zs.showStatusMarker) return null;
|
||||
return new ScatterplotLayer({
|
||||
id: 'scat-status-markers',
|
||||
data: segments,
|
||||
getPosition: (d: ScatSegment) => [d.lng, d.lat],
|
||||
getRadius: zs.markerRadius,
|
||||
getFillColor: (d: ScatSegment) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 51]
|
||||
if (d.status === '진행중') return [234, 179, 8, 51]
|
||||
return [100, 116, 139, 51]
|
||||
if (d.status === '완료') return [34, 197, 94, 51];
|
||||
if (d.status === '진행중') return [234, 179, 8, 51];
|
||||
return [100, 116, 139, 51];
|
||||
},
|
||||
getLineColor: (d: ScatSegment) => {
|
||||
if (d.status === '완료') return [34, 197, 94, 200]
|
||||
if (d.status === '진행중') return [234, 179, 8, 200]
|
||||
return [100, 116, 139, 200]
|
||||
if (d.status === '완료') return [34, 197, 94, 200];
|
||||
if (d.status === '진행중') return [234, 179, 8, 200];
|
||||
return [100, 116, 139, 200];
|
||||
},
|
||||
getLineWidth: 1,
|
||||
stroked: true,
|
||||
@ -216,32 +229,27 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
radiusUnits: 'pixels',
|
||||
pickable: true,
|
||||
onClick: (info: { object?: ScatSegment }) => {
|
||||
if (info.object) handleClick(info.object)
|
||||
if (info.object) handleClick(info.object);
|
||||
},
|
||||
updateTriggers: {
|
||||
getRadius: [zs.markerRadius],
|
||||
},
|
||||
})
|
||||
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
|
||||
});
|
||||
}, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const deckLayers: any[] = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layers: any[] = [glowLayer, segPathLayer]
|
||||
if (markerLayer) layers.push(markerLayer)
|
||||
return layers
|
||||
}, [glowLayer, segPathLayer, markerLayer])
|
||||
const layers: any[] = [glowLayer, segPathLayer];
|
||||
if (markerLayer) layers.push(markerLayer);
|
||||
return layers;
|
||||
}, [glowLayer, segPathLayer, markerLayer]);
|
||||
|
||||
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 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
|
||||
.filter((s) => s.sensitivity === '최상' || s.sensitivity === '상')
|
||||
.reduce((a, s) => a + s.lengthM, 0);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
@ -257,7 +265,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
mapStyle={currentMapStyle}
|
||||
className="w-full h-full"
|
||||
attributionControl={false}
|
||||
onZoom={e => setZoom(e.viewState.zoom)}
|
||||
onZoom={(e) => setZoom(e.viewState.zoom)}
|
||||
>
|
||||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||||
<DeckGLOverlay layers={deckLayers} />
|
||||
@ -267,27 +275,22 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
{/* 호버 툴팁 */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
className="pointer-events-none text-fg rounded-[6px] px-2 py-1 font-korean text-label-2"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: tooltip.x + 12,
|
||||
top: tooltip.y - 48,
|
||||
background: 'rgba(15,21,36,0.92)',
|
||||
border: '1px solid rgba(30,42,66,0.8)',
|
||||
color: '#e4e8f1',
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
fontSize: 11,
|
||||
fontFamily: "'Noto Sans KR', sans-serif",
|
||||
zIndex: 1000,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
<div className="font-bold">
|
||||
{tooltip.seg.code} {tooltip.seg.area}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, opacity: 0.7 }}>
|
||||
<div className="text-caption opacity-70">
|
||||
ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '}
|
||||
{tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '}
|
||||
{tooltip.seg.status}
|
||||
@ -297,11 +300,11 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
|
||||
{/* 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-stroke rounded-full text-[11px] text-fg-sub font-korean">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-color-success shadow-[0_0_6px_var(--color-success)]" />
|
||||
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-stroke rounded-full text-[11px] text-fg-sub font-korean">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-full text-label-2 text-fg-sub font-korean">
|
||||
{jurisdictionFilter || '전체'} 관할 해안 · {segments.length}개 구간
|
||||
</div>
|
||||
</div>
|
||||
@ -309,8 +312,10 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
{/* 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-stroke 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-fg-disabled mb-2.5">ESI 민감도 분류 범례</div>
|
||||
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled mb-2.5">
|
||||
ESI 민감도 분류 범례
|
||||
</div>
|
||||
{[
|
||||
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
|
||||
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
|
||||
@ -321,37 +326,61 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
{ 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 }} />
|
||||
<div key={i} className="flex items-center gap-2 py-1 text-label-2">
|
||||
<span
|
||||
className="w-3.5 h-1.5 rounded-sm flex-shrink-0"
|
||||
style={{ background: item.color }}
|
||||
/>
|
||||
<span className="text-fg-sub font-korean">{item.label}</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-fg">{item.esi}</span>
|
||||
<span className="ml-auto font-mono text-caption text-fg">{item.esi}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke 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-fg-disabled 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(--color-success)' }} />
|
||||
<div className="h-full transition-all duration-500" style={{ width: `${progPct}%`, background: 'var(--color-warning)' }} />
|
||||
<div className="h-full transition-all duration-500" style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-[9px] font-mono text-color-success">완료 {donePct}%</span>
|
||||
<span className="text-[9px] font-mono text-color-warning">진행 {progPct}%</span>
|
||||
<span className="text-[9px] font-mono text-fg-disabled">미조사 {notPct}%</span>
|
||||
<div className="bg-[color-mix(in_srgb,var(--bg-base)_92%,transparent)] backdrop-blur-xl border border-stroke rounded-md p-3.5 shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
|
||||
<div className="text-caption font-bold uppercase tracking-wider text-fg-disabled 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(--color-success)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${progPct}%`, background: 'var(--color-warning)' }}
|
||||
/>
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{ width: `${notPct}%`, background: 'var(--stroke-default)' }}
|
||||
/>
|
||||
</div> */}
|
||||
{/* <div className="flex justify-between mt-1">
|
||||
<span className="text-caption font-mono text-color-success">완료 {donePct}%</span>
|
||||
<span className="text-caption font-mono text-color-warning">진행 {progPct}%</span>
|
||||
<span className="text-caption font-mono text-fg-disabled">미조사 {notPct}%</span>
|
||||
</div> */}
|
||||
<div className="mt-2.5">
|
||||
{[
|
||||
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
|
||||
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--color-success)'],
|
||||
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--color-danger)'],
|
||||
['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}개`, 'var(--color-warning)'],
|
||||
[
|
||||
'방제 우선 구간',
|
||||
`${segments.filter((s) => s.sensitivity === '최상').length}개`,
|
||||
'var(--color-warning)',
|
||||
],
|
||||
].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]">
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between py-1.5 border-b border-stroke last:border-b-0 text-label-2"
|
||||
>
|
||||
<span className="text-fg-sub font-korean">{label}</span>
|
||||
<span className="font-mono font-medium text-[11px]" style={{ color: color || undefined }}>
|
||||
<span
|
||||
className="font-mono font-medium text-label-2"
|
||||
style={{ color: color || undefined }}
|
||||
>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
@ -361,7 +390,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
</div>
|
||||
|
||||
{/* Coordinates */}
|
||||
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-[11px] text-fg-sub flex gap-3.5">
|
||||
{/* <div className="absolute bottom-3 left-3 z-[1000] bg-[rgba(18,25,41,0.85)] backdrop-blur-xl border border-stroke rounded-sm px-3 py-1.5 font-mono text-label-2 text-fg-sub flex gap-3.5">
|
||||
<span>
|
||||
위도 <span className="text-color-success font-medium">{selectedSeg.lat.toFixed(4)}°N</span>
|
||||
</span>
|
||||
@ -373,7 +402,7 @@ function ScatMap({ segments, zones, selectedSeg, jurisdictionFilter, onSelectSeg
|
||||
</span>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatMap
|
||||
export default ScatMap;
|
||||
|
||||
@ -108,27 +108,22 @@ function PopupMap({
|
||||
</Map>
|
||||
{/* 마커 레이블 오버레이 */}
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
className="pointer-events-none absolute text-fg font-korean text-[10px] rounded-[6px] text-center"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: 'calc(50% - 44px)',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,21,36,0.92)',
|
||||
border: '1px solid rgba(30,42,66,0.8)',
|
||||
color: '#e4e8f1',
|
||||
borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--bg-base) 92%, transparent)',
|
||||
border: '1px solid var(--stroke-default)',
|
||||
padding: '3px 7px',
|
||||
fontSize: 10,
|
||||
fontFamily: "'Noto Sans KR', sans-serif",
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 11 }}>
|
||||
<div className="text-label-2 font-bold">
|
||||
{code} {name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.7 }}>ESI {esi}</div>
|
||||
<div className="opacity-70">ESI {esi}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -142,8 +137,6 @@ interface ScatPopupProps {
|
||||
}
|
||||
|
||||
function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
console.log(segCode, '코드');
|
||||
|
||||
const [popTab, setPopTab] = useState(0);
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
@ -179,12 +172,12 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-bold text-color-success font-mono px-2.5 py-1 bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.25)] rounded-sm">
|
||||
<span className="text-title-4 font-bold text-color-success font-mono px-2.5 py-1 bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] border border-[color-mix(in_srgb,var(--color-success)_25%,transparent)] rounded-sm">
|
||||
{data.code}
|
||||
</span>
|
||||
<span className="text-base font-bold font-korean">{data.name}</span>
|
||||
<span className="text-title-3 font-bold font-korean">{data.name}</span>
|
||||
<span
|
||||
className="text-[11px] font-bold px-2.5 py-0.5 rounded-xl text-white"
|
||||
className="text-label-2 font-bold px-2.5 py-0.5 rounded-xl text-white"
|
||||
style={{ background: data.esiColor }}
|
||||
>
|
||||
ESI {data.esi}
|
||||
@ -192,7 +185,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-[rgba(239,68,68,0.15)] hover:text-color-danger hover:border-[rgba(239,68,68,0.3)] transition-colors text-lg"
|
||||
className="w-9 h-9 rounded-sm border border-stroke bg-bg-card text-fg-sub flex items-center justify-center cursor-pointer hover:bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] hover:text-color-danger hover:border-[color-mix(in_srgb,var(--color-danger)_30%,transparent)] transition-colors text-lg"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@ -243,14 +236,14 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
<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">
|
||||
<div className="absolute top-2 left-2 px-2 py-0.5 bg-[color-mix(in_srgb,var(--bg-base)_80%,transparent)] border border-stroke rounded text-caption font-bold text-fg font-mono backdrop-blur-sm">
|
||||
{segCode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Survey Info */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
|
||||
🏖 해안 기본정보
|
||||
</div>
|
||||
{[
|
||||
@ -288,40 +281,40 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
].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"
|
||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-sub font-korean">{k}</span>
|
||||
<span className={`text-white font-semibold font-korean ${cls}`}>{v}</span>
|
||||
<span className={`text-fg font-korean ${cls}`}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sensitive Resources */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] 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"
|
||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-sub font-korean">{s.t}</span>
|
||||
<span className="text-white font-semibold font-korean">{s.v}</span>
|
||||
<span className="text-fg font-korean">{s.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cleanup Methods */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] 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-fg font-medium font-korean"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 bg-bg-base border border-stroke rounded text-caption text-fg font-medium font-korean"
|
||||
>
|
||||
{c}
|
||||
</span>
|
||||
@ -331,10 +324,10 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
|
||||
{/* End Criteria */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] 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-fg-sub leading-[1.7] font-korean">
|
||||
<div className="px-3 py-2.5 bg-[color-mix(in_srgb,var(--color-caution)_6%,transparent)] border border-[color-mix(in_srgb,var(--color-caution)_15%,transparent)] rounded-sm text-label-2 text-fg-sub 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-color-caution">•</span>
|
||||
@ -346,10 +339,10 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] 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-fg-sub leading-[1.7] font-korean">
|
||||
<div className="px-3 py-2.5 bg-[color-mix(in_srgb,var(--color-caution)_6%,transparent)] border border-[color-mix(in_srgb,var(--color-caution)_15%,transparent)] rounded-sm text-label-2 text-fg-sub 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-color-caution">•</span>
|
||||
@ -391,7 +384,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
].map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1.5 text-[10px] text-fg-sub font-korean"
|
||||
className="flex items-center gap-1.5 text-caption text-fg-sub font-korean"
|
||||
>
|
||||
<span className="w-5 h-1 rounded-sm" style={{ background: item.color }} />
|
||||
{item.label}
|
||||
@ -401,7 +394,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
|
||||
{/* Coordinates */}
|
||||
<div className="mb-4">
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
|
||||
📍 좌표 정보
|
||||
</div>
|
||||
{[
|
||||
@ -412,7 +405,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
].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"
|
||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="text-color-success font-mono font-medium">{v}</span>
|
||||
@ -422,7 +415,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
|
||||
{/* Survey parameters */}
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-color-success 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 className="text-label-2 font-bold text-color-success uppercase tracking-wider mb-2.5 pb-1.5 border-b border-[color-mix(in_srgb,var(--color-success)_15%,transparent)] font-korean flex items-center gap-1.5">
|
||||
⚙ 조사 파라미터
|
||||
</div>
|
||||
{[
|
||||
@ -435,7 +428,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
].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"
|
||||
className="flex justify-between py-1.5 text-xs border-b border-stroke last:border-b-0"
|
||||
>
|
||||
<span className="text-fg-disabled font-korean">{k}</span>
|
||||
<span className="text-fg font-medium font-korean">{v}</span>
|
||||
@ -448,7 +441,7 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
|
||||
{popTab === 1 && (
|
||||
<div className="p-6 overflow-y-auto h-full scrollbar-thin">
|
||||
<div className="text-sm font-bold font-korean mb-4">
|
||||
<div className="text-caption font-bold font-korean mb-4">
|
||||
{data.code} {data.name} — 조사 이력
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
@ -465,17 +458,19 @@ function ScatPopup({ data, segCode, onClose }: ScatPopupProps) {
|
||||
<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 ${
|
||||
className={`text-caption font-bold px-2 py-0.5 rounded-lg ${
|
||||
h.type === 'Pre-SCAT'
|
||||
? 'bg-[rgba(34,197,94,0.15)] text-color-success'
|
||||
: 'bg-[rgba(59,130,246,0.15)] text-color-info'
|
||||
? 'bg-[color-mix(in_srgb,var(--color-success)_15%,transparent)] text-color-success'
|
||||
: 'bg-[color-mix(in_srgb,var(--color-info)_15%,transparent)] text-color-info'
|
||||
}`}
|
||||
>
|
||||
{h.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-sub font-korean mb-1">조사팀: {h.team}</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">{h.note}</div>
|
||||
<div className="text-label-2 text-fg-sub font-korean mb-1">
|
||||
조사팀: {h.team}
|
||||
</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">{h.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -25,7 +25,7 @@ export default function ScatRightPanel({
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px] h-full">
|
||||
<div className="text-3xl mb-2">🏖️</div>
|
||||
<div className="text-center text-fg-disabled text-[11px] leading-relaxed">
|
||||
<div className="text-center text-fg-disabled text-label-2 leading-relaxed">
|
||||
좌측 목록에서 구간을
|
||||
<br />
|
||||
선택하면 상세 정보가
|
||||
@ -43,14 +43,14 @@ export default function ScatRightPanel({
|
||||
{detail ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-bold text-white"
|
||||
className="px-2 py-0.5 rounded text-caption font-bold text-white"
|
||||
style={{ background: detail.esiColor || 'var(--color-accent)' }}
|
||||
>
|
||||
{detail.esi}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-bold truncate">{detail.name}</div>
|
||||
<div className="text-[10px] text-fg-disabled">{detail.code}</div>
|
||||
<div className="text-caption text-fg-disabled">{detail.code}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@ -64,7 +64,7 @@ export default function ScatRightPanel({
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2 text-center text-[11px] font-semibold cursor-pointer transition-colors ${
|
||||
className={`flex-1 py-2 text-center text-label-2 font-semibold cursor-pointer transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'text-color-accent border-b-2 border-color-accent'
|
||||
: 'text-fg-disabled hover:text-fg-sub'
|
||||
@ -78,7 +78,7 @@ export default function ScatRightPanel({
|
||||
{/* 스크롤 영역 */}
|
||||
<div className="flex-1 h-0 overflow-y-auto p-2.5 scrollbar-thin">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled text-[11px]">
|
||||
<div className="flex items-center justify-center h-full text-fg-disabled text-label-2">
|
||||
데이터 로딩 중...
|
||||
</div>
|
||||
) : detail ? (
|
||||
@ -93,12 +93,12 @@ export default function ScatRightPanel({
|
||||
{/* 하단 버튼 */}
|
||||
{/* <div className="flex flex-col gap-1.5 p-2.5 border-t border-stroke shrink-0">
|
||||
<button onClick={onOpenReport}
|
||||
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-color-accent"
|
||||
className="w-full py-2 text-label-2 font-semibold rounded-md cursor-pointer text-color-accent"
|
||||
style={{ background: 'rgba(6,182,212,.08)', border: '1px solid rgba(6,182,212,.25)' }}>
|
||||
📄 해안평가 보고서
|
||||
</button>
|
||||
<button onClick={onNewSurvey}
|
||||
className="w-full py-2 text-[11px] font-semibold rounded-md cursor-pointer text-color-success"
|
||||
className="w-full py-2 text-label-2 font-semibold rounded-md cursor-pointer text-color-success"
|
||||
style={{ background: 'rgba(34,197,94,.08)', border: '1px solid rgba(34,197,94,.25)' }}>
|
||||
➕ 신규 조사
|
||||
</button>
|
||||
@ -127,19 +127,18 @@ function DetailTab({ detail }: { detail: ScatDetail }) {
|
||||
|
||||
{/* 접근성 */}
|
||||
<Section title="접근성">
|
||||
<InfoRow label="접근 방법" value={detail.access || '-'} />
|
||||
<InfoRow label="접근 포인트" value={detail.accessPt || '-'} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<InfoBlock label="접근 방법" value={detail.access || '-'} />
|
||||
<InfoBlock label="접근 포인트" value={detail.accessPt || '-'} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 민감 자원 */}
|
||||
{detail.sensitive && detail.sensitive.length > 0 && (
|
||||
<Section title="민감 자원">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
{detail.sensitive.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[10px]">
|
||||
<span className="text-color-accent font-bold shrink-0">{s.t}</span>
|
||||
<span className="text-fg-sub">{s.v}</span>
|
||||
</div>
|
||||
<InfoBlock key={i} label={s.t} value={s.v} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
@ -157,7 +156,7 @@ function PhotoTab({ detail }: { detail: ScatDetail }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
||||
<div className="text-3xl">📷</div>
|
||||
<div className="text-[11px] text-fg-disabled text-center leading-relaxed">
|
||||
<div className="text-label-2 text-fg-disabled text-center leading-relaxed">
|
||||
해당 구간의 사진이
|
||||
<br />
|
||||
등록되지 않았습니다.
|
||||
@ -176,7 +175,7 @@ function PhotoTab({ detail }: { detail: ScatDetail }) {
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled text-center">{detail.code} 해안 조사 사진</div>
|
||||
<div className="text-caption text-fg-disabled text-center">{detail.code} 해안 조사 사진</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -192,18 +191,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
|
||||
{detail.cleanup.map((method, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold text-color-accent"
|
||||
style={{
|
||||
background: 'rgba(6,182,212,.08)',
|
||||
border: '1px solid rgba(6,182,212,.2)',
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-caption font-semibold text-color-accent bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] border border-[color-mix(in_srgb,var(--color-accent)_20%,transparent)]"
|
||||
>
|
||||
{method}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-fg-disabled">등록된 방제 방법 없음</div>
|
||||
<div className="text-caption text-fg-disabled">등록된 방제 방법 없음</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@ -212,14 +207,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
|
||||
{detail.endCriteria && detail.endCriteria.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{detail.endCriteria.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[10px] text-fg-sub">
|
||||
<div key={i} className="flex items-start gap-1.5 text-caption text-fg-sub">
|
||||
<span className="text-color-success font-bold shrink-0">✓</span>
|
||||
<span>{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-fg-disabled">등록된 종료 기준 없음</div>
|
||||
<div className="text-caption text-fg-disabled">등록된 종료 기준 없음</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@ -230,14 +225,14 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
|
||||
{detail.notes.map((note, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-[10px] text-fg-sub leading-[1.6] px-2 py-1.5 rounded bg-bg-base"
|
||||
className="text-caption text-fg-sub leading-[1.6] px-2 py-1.5 rounded bg-bg-base"
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-fg-disabled">등록된 참고사항 없음</div>
|
||||
<div className="text-caption text-fg-disabled">등록된 참고사항 없음</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
@ -248,7 +243,7 @@ function CleanupTab({ detail }: { detail: ScatDetail }) {
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5">
|
||||
<div className="text-[11px] font-bold mb-2 text-fg-sub">{title}</div>
|
||||
<div className="text-label-2 font-bold mb-2 text-fg-sub">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@ -264,10 +259,10 @@ function InfoRow({
|
||||
valueColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-0.5">
|
||||
<span className="text-[10px] text-fg-disabled">{label}</span>
|
||||
<div className="flex items-center justify-between py-0.5 gap-2">
|
||||
<span className="text-caption text-fg-disabled shrink-0">{label}</span>
|
||||
<span
|
||||
className="text-[10px] font-semibold"
|
||||
className="text-caption text-right min-w-0 break-all"
|
||||
style={valueColor ? { color: valueColor } : undefined}
|
||||
>
|
||||
{value}
|
||||
@ -275,3 +270,15 @@ function InfoRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBlock({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-caption text-fg-disabled mb-0.5">{label}</div>
|
||||
<div className="flex items-start gap-1.5 text-caption text-fg-sub pl-1">
|
||||
<span className="text-color-accent shrink-0 leading-tight">•</span>
|
||||
<span className="break-all">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
||||
{displaySegs.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono cursor-pointer ${i === currentIdx ? 'text-color-success font-semibold' : 'text-fg-disabled'}`}
|
||||
className={`text-caption font-mono cursor-pointer ${i === currentIdx ? 'text-color-success font-semibold' : 'text-fg-disabled'}`}
|
||||
onClick={() => onSeek(i)}
|
||||
>
|
||||
{s.code}
|
||||
@ -137,14 +137,14 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
||||
>
|
||||
{s.status === '완료' && (
|
||||
<span
|
||||
className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-[10px] cursor-pointer"
|
||||
className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption 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 className="absolute -top-[18px] left-1/2 -translate-x-1/2 text-caption">
|
||||
⏳
|
||||
</span>
|
||||
)}
|
||||
@ -166,19 +166,19 @@ function ScatTimeline({ segments, currentIdx, onSeek }: ScatTimelineProps) {
|
||||
구간 {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="flex items-center gap-1.5 text-label-2">
|
||||
<span className="text-fg-sub font-korean">완료</span>
|
||||
<span className="text-fg font-semibold font-mono text-color-success">
|
||||
{doneCount}/{segments.length}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="flex items-center gap-1.5 text-label-2">
|
||||
<span className="text-fg-sub font-korean">진행중</span>
|
||||
<span className="text-fg font-semibold font-mono text-color-warning">
|
||||
{progCount}/{segments.length}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="flex items-center gap-1.5 text-label-2">
|
||||
<span className="text-fg-sub font-korean">총 해안선</span>
|
||||
<span className="text-fg font-semibold font-mono">
|
||||
{(totalLen / 1000).toFixed(1)} km
|
||||
|
||||
@ -144,7 +144,7 @@ export function WeatherMapOverlay({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, #ff6b35 0%, #ff8c42 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
🌡️
|
||||
@ -153,12 +153,12 @@ export function WeatherMapOverlay({
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.temperature.current.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
🌊
|
||||
@ -167,12 +167,12 @@ export function WeatherMapOverlay({
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.wave.height.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-label-2 text-white font-bold"
|
||||
style={{ background: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)', boxShadow: '0 2px 4px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
💨
|
||||
@ -181,7 +181,7 @@ export function WeatherMapOverlay({
|
||||
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{station.wind.speed.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||
<span className="text-caption text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -90,7 +90,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col bg-bg-surface border-l border-stroke overflow-hidden w-[320px] shrink-0">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-fg-disabled text-[13px] font-korean">
|
||||
<p className="text-fg-disabled text-title-4 font-korean">
|
||||
지도에서 해양 지점을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
@ -110,14 +110,14 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[13px] font-bold text-color-accent font-korean">
|
||||
<span className="text-title-4 font-bold text-color-accent font-korean">
|
||||
📍 {weatherData.stationName}
|
||||
</span>
|
||||
<span className="px-1.5 py-px text-[11px] rounded bg-color-accent/15 text-color-accent font-bold">
|
||||
<span className="px-1.5 py-px text-label-2 rounded bg-[rgba(6,182,212,0.15)] text-color-accent font-bold">
|
||||
기상예보관
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-disabled font-mono">
|
||||
<p className="text-label-2 text-fg-disabled font-mono">
|
||||
{weatherData.location.lat.toFixed(2)}°N, {weatherData.location.lon.toFixed(2)}°E ·{' '}
|
||||
{weatherData.currentTime}
|
||||
</p>
|
||||
@ -134,25 +134,25 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: windColor(wSpd) }}>
|
||||
{wSpd.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">풍속 (m/s)</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">풍속 (m/s)</div>
|
||||
</div>
|
||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||
<div className="text-[20px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||||
{wHgt.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">파고 (m)</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">파고 (m)</div>
|
||||
</div>
|
||||
<div className="text-center py-2.5 bg-bg-base border border-stroke rounded-md">
|
||||
<div className="text-[20px] font-bold font-mono text-color-accent">
|
||||
{wTemp.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-disabled font-korean">수온 (°C)</div>
|
||||
<div className="text-label-2 text-fg-disabled font-korean">수온 (°C)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 바람 상세 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🌬️ 바람 현황
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
@ -209,32 +209,38 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
<circle cx="25" cy="25" r="3" fill={windColor(wSpd)} />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-[11px]">
|
||||
<div className="flex-1 grid grid-cols-2 gap-x-3 gap-y-1.5 text-label-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">풍향</span>
|
||||
<span className="text-fg font-mono font-bold text-[13px]">
|
||||
<span className="text-fg font-mono font-bold text-title-4">
|
||||
{windDir} {wind.direction}°
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">기압</span>
|
||||
<span className="text-fg font-mono text-[13px]">{pressure} hPa</span>
|
||||
<span className="text-fg font-mono text-title-4">{pressure} hPa</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">1k 최고</span>
|
||||
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_1k) }}>
|
||||
<span
|
||||
className="font-mono text-title-4"
|
||||
style={{ color: windColor(wind.speed_1k) }}
|
||||
>
|
||||
{Number(wind.speed_1k).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-fg-disabled">3k 평균</span>
|
||||
<span className="font-mono text-[13px]" style={{ color: windColor(wind.speed_3k) }}>
|
||||
<span
|
||||
className="font-mono text-title-4"
|
||||
style={{ color: windColor(wind.speed_3k) }}
|
||||
>
|
||||
{Number(wind.speed_3k).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-fg-disabled">가시거리</span>
|
||||
<span className="text-fg font-mono text-[13px]">{visibility} km</span>
|
||||
<span className="text-fg font-mono text-title-4">{visibility} km</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -249,7 +255,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
|
||||
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
|
||||
{wSpd.toFixed(1)}/20
|
||||
</span>
|
||||
</div>
|
||||
@ -257,29 +263,29 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 파도 상세 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">🌊 파도</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||||
<div className="text-title-3 font-bold font-mono" style={{ color: waveColor(wHgt) }}>
|
||||
{wHgt.toFixed(1)}m
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled">유의파고</div>
|
||||
<div className="text-caption text-fg-disabled">유의파고</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-color-danger">
|
||||
<div className="text-title-3 font-bold font-mono text-color-danger">
|
||||
{wave.maxHeight.toFixed(1)}m
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled">최고파고</div>
|
||||
<div className="text-caption text-fg-disabled">최고파고</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-color-accent">
|
||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
||||
{wave.period}s
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled">주기</div>
|
||||
<div className="text-caption text-fg-disabled">주기</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-fg">{wave.direction}</div>
|
||||
<div className="text-[10px] text-fg-disabled">파향</div>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">{wave.direction}</div>
|
||||
<div className="text-caption text-fg-disabled">파향</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 파고 게이지 바 */}
|
||||
@ -293,7 +299,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] font-mono text-fg-disabled shrink-0">
|
||||
<span className="text-label-2 font-mono text-fg-disabled shrink-0">
|
||||
{wHgt.toFixed(1)}/5m
|
||||
</span>
|
||||
</div>
|
||||
@ -301,32 +307,32 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
|
||||
{/* ── 수온/공기 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🌡️ 수온 · 공기
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-color-accent">
|
||||
<div className="text-title-3 font-bold font-mono text-color-accent">
|
||||
{wTemp.toFixed(1)}°
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled">수온</div>
|
||||
<div className="text-caption text-fg-disabled">수온</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-fg">
|
||||
<div className="text-title-3 font-bold font-mono text-fg">
|
||||
{temperature.feelsLike.toFixed(1)}°
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-disabled">기온</div>
|
||||
<div className="text-caption text-fg-disabled">기온</div>
|
||||
</div>
|
||||
<div className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-[14px] font-bold font-mono text-fg">{salinity.toFixed(1)}</div>
|
||||
<div className="text-[10px] text-fg-disabled">염분(PSU)</div>
|
||||
<div className="text-title-3 font-bold font-mono text-fg">{salinity.toFixed(1)}</div>
|
||||
<div className="text-caption text-fg-disabled">염분(PSU)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 시간별 예보 ── */}
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
⏰ 시간별 예보
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
@ -335,10 +341,10 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
key={i}
|
||||
className="flex flex-col items-center py-2 px-1 bg-bg-base border border-stroke rounded"
|
||||
>
|
||||
<span className="text-[11px] text-fg-disabled mb-0.5">{f.hour}</span>
|
||||
<span className="text-label-2 text-fg-disabled mb-0.5">{f.hour}</span>
|
||||
<span className="text-lg mb-0.5">{f.icon}</span>
|
||||
<span className="text-[13px] font-bold text-fg">{f.temperature}°</span>
|
||||
<span className="text-[11px] text-fg-disabled font-mono">{f.windSpeed}</span>
|
||||
<span className="text-title-4 font-bold text-fg">{f.temperature}°</span>
|
||||
<span className="text-label-2 text-fg-disabled font-mono">{f.windSpeed}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -347,7 +353,7 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* ── 천문/조석 ── */}
|
||||
{astronomy && (
|
||||
<div className="px-3 py-2 border-b border-stroke">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
☀️ 천문 · 조석
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
@ -359,12 +365,12 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
].map((item, i) => (
|
||||
<div key={i} className="text-center py-2 bg-bg-base border border-stroke rounded">
|
||||
<div className="text-base mb-0.5">{item.icon}</div>
|
||||
<div className="text-[10px] text-fg-disabled">{item.label}</div>
|
||||
<div className="text-[13px] font-bold font-mono text-fg">{item.value}</div>
|
||||
<div className="text-caption text-fg-disabled">{item.label}</div>
|
||||
<div className="text-title-4 font-bold font-mono text-fg">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-[11px]">
|
||||
<div className="flex items-center gap-2 mt-1.5 px-2 py-1 bg-bg-base border border-stroke rounded text-label-2">
|
||||
<span className="text-sm">🌓</span>
|
||||
<span className="text-fg-disabled">{astronomy.moonPhase}</span>
|
||||
<span className="ml-auto text-fg font-mono">조차 {astronomy.tidalRange}m</span>
|
||||
@ -375,16 +381,16 @@ export function WeatherRightPanel({ weatherData }: WeatherRightPanelProps) {
|
||||
{/* ── 날씨 특보 ── */}
|
||||
{alert && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[11px] font-bold text-fg-disabled font-korean mb-2">
|
||||
<div className="text-label-2 font-bold text-fg-disabled font-korean mb-2">
|
||||
🚨 날씨 특보
|
||||
</div>
|
||||
<div
|
||||
className="px-2.5 py-2 rounded border"
|
||||
style={{ background: 'rgba(239,68,68,.06)', borderColor: 'rgba(239,68,68,.2)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<div className="flex items-center gap-2 text-label-2">
|
||||
<span
|
||||
className="px-1.5 py-px rounded text-[11px] font-bold"
|
||||
className="px-1.5 py-px rounded text-label-2 font-bold"
|
||||
style={{ background: 'rgba(239,68,68,.15)', color: 'var(--color-danger)' }}
|
||||
>
|
||||
주의
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
||||
import { useMapStore } from '@common/store/mapStore'
|
||||
import { WeatherRightPanel } from './WeatherRightPanel'
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay'
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import type { Layer } from '@deck.gl/core';
|
||||
import type { MapLayerMouseEvent } from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import { WeatherRightPanel } from './WeatherRightPanel';
|
||||
import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay';
|
||||
// import { OceanForecastOverlay } from './OceanForecastOverlay'
|
||||
// import { useOceanCurrentLayers } from './OceanCurrentLayer'
|
||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||
import { WindParticleLayer } from './WindParticleLayer'
|
||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
import { useWaterTemperatureLayers } from './WaterTemperatureLayer';
|
||||
import { WindParticleLayer } from './WindParticleLayer';
|
||||
import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer';
|
||||
import { useWeatherData } from '../hooks/useWeatherData';
|
||||
// import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls'
|
||||
import { degreesToCardinal } from '../services/weatherUtils'
|
||||
import { WeatherMapControls } from './WeatherMapControls';
|
||||
import { degreesToCardinal } from '../services/weatherUtils';
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9'
|
||||
type TimeOffset = '0' | '3' | '6' | '9';
|
||||
|
||||
interface WeatherStation {
|
||||
id: string
|
||||
name: string
|
||||
location: { lat: number; lon: number }
|
||||
id: string;
|
||||
name: string;
|
||||
location: { lat: number; lon: number };
|
||||
wind: {
|
||||
speed: number
|
||||
direction: number
|
||||
speed_1k: number
|
||||
speed_3k: number
|
||||
}
|
||||
speed: number;
|
||||
direction: number;
|
||||
speed_1k: number;
|
||||
speed_3k: number;
|
||||
};
|
||||
wave: {
|
||||
height: number
|
||||
period: number
|
||||
}
|
||||
height: number;
|
||||
period: number;
|
||||
};
|
||||
temperature: {
|
||||
current: number
|
||||
feelsLike: number
|
||||
}
|
||||
pressure: number
|
||||
visibility: number
|
||||
salinity?: number
|
||||
current: number;
|
||||
feelsLike: number;
|
||||
};
|
||||
pressure: number;
|
||||
visibility: number;
|
||||
salinity?: number;
|
||||
}
|
||||
|
||||
interface WeatherForecast {
|
||||
time: string
|
||||
hour: string
|
||||
icon: string
|
||||
temperature: number
|
||||
windSpeed: number
|
||||
time: string;
|
||||
hour: string;
|
||||
icon: string;
|
||||
temperature: number;
|
||||
windSpeed: number;
|
||||
}
|
||||
|
||||
// Base weather station locations
|
||||
@ -64,50 +64,50 @@ const BASE_STATIONS = [
|
||||
{ id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } },
|
||||
{ id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } },
|
||||
{ id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } },
|
||||
]
|
||||
];
|
||||
|
||||
// Generate forecast data based on time offset
|
||||
const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => {
|
||||
const baseHour = parseInt(timeOffset)
|
||||
const forecasts: WeatherForecast[] = []
|
||||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️']
|
||||
const baseHour = parseInt(timeOffset);
|
||||
const forecasts: WeatherForecast[] = [];
|
||||
const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hour = baseHour + i * 3
|
||||
const hour = baseHour + i * 3;
|
||||
forecasts.push({
|
||||
time: `+${hour}시`,
|
||||
hour: `${hour}시`,
|
||||
icon: icons[i % icons.length],
|
||||
temperature: Math.floor(Math.random() * 5) + 5,
|
||||
windSpeed: Math.floor(Math.random() * 5) + 6,
|
||||
})
|
||||
});
|
||||
}
|
||||
return forecasts
|
||||
}
|
||||
return forecasts;
|
||||
};
|
||||
|
||||
// 한국 해역 중심 좌표 (한반도 중앙)
|
||||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat]
|
||||
const WEATHER_MAP_ZOOM = 7
|
||||
const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5]; // [lng, lat]
|
||||
const WEATHER_MAP_ZOOM = 7;
|
||||
|
||||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||||
overlay.setProps({ layers })
|
||||
return null
|
||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||
overlay.setProps({ layers });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||
*/
|
||||
interface WeatherMapInnerProps {
|
||||
weatherStations: WeatherStation[]
|
||||
enabledLayers: Set<string>
|
||||
selectedStationId: string | null
|
||||
onStationClick: (station: WeatherStation) => void
|
||||
mapCenter: [number, number]
|
||||
mapZoom: number
|
||||
clickedLocation: { lat: number; lon: number } | null
|
||||
weatherStations: WeatherStation[];
|
||||
enabledLayers: Set<string>;
|
||||
selectedStationId: string | null;
|
||||
onStationClick: (station: WeatherStation) => void;
|
||||
mapCenter: [number, number];
|
||||
mapZoom: number;
|
||||
clickedLocation: { lat: number; lon: number } | null;
|
||||
}
|
||||
|
||||
function WeatherMapInner({
|
||||
@ -124,8 +124,8 @@ function WeatherMapInner({
|
||||
weatherStations,
|
||||
enabledLayers,
|
||||
selectedStationId,
|
||||
onStationClick
|
||||
)
|
||||
onStationClick,
|
||||
);
|
||||
// const oceanCurrentLayers = useOceanCurrentLayers({
|
||||
// visible: enabledLayers.has('oceanCurrent'),
|
||||
// opacity: 0.7,
|
||||
@ -133,12 +133,12 @@ function WeatherMapInner({
|
||||
const waterTempLayers = useWaterTemperatureLayers({
|
||||
visible: enabledLayers.has('waterTemperature'),
|
||||
opacity: 0.5,
|
||||
})
|
||||
});
|
||||
|
||||
const deckLayers = useMemo(
|
||||
() => [...waterTempLayers, ...weatherDeckLayers],
|
||||
[waterTempLayers, weatherDeckLayers]
|
||||
)
|
||||
[waterTempLayers, weatherDeckLayers],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -153,9 +153,7 @@ function WeatherMapInner({
|
||||
/> */}
|
||||
|
||||
{/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||
<OceanCurrentParticleLayer
|
||||
visible={enabledLayers.has('oceanCurrentParticle')}
|
||||
/>
|
||||
<OceanCurrentParticleLayer visible={enabledLayers.has('oceanCurrentParticle')} />
|
||||
|
||||
{/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */}
|
||||
<WeatherMapOverlay
|
||||
@ -166,18 +164,11 @@ function WeatherMapInner({
|
||||
/>
|
||||
|
||||
{/* 바람 파티클 애니메이션 (Canvas 직접 조작) */}
|
||||
<WindParticleLayer
|
||||
visible={enabledLayers.has('windParticle')}
|
||||
stations={weatherStations}
|
||||
/>
|
||||
<WindParticleLayer visible={enabledLayers.has('windParticle')} stations={weatherStations} />
|
||||
|
||||
{/* 클릭 위치 마커 */}
|
||||
{clickedLocation && (
|
||||
<Marker
|
||||
longitude={clickedLocation.lon}
|
||||
latitude={clickedLocation.lat}
|
||||
anchor="bottom"
|
||||
>
|
||||
<Marker longitude={clickedLocation.lon} latitude={clickedLocation.lat} anchor="bottom">
|
||||
<div className="flex flex-col items-center pointer-events-none">
|
||||
{/* 펄스 링 */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
@ -187,7 +178,7 @@ function WeatherMapInner({
|
||||
{/* 핀 꼬리 */}
|
||||
<div className="w-px h-3 bg-color-accent" />
|
||||
{/* 좌표 라벨 */}
|
||||
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-[10px] text-color-accent whitespace-nowrap backdrop-blur-sm">
|
||||
<div className="px-2 py-1 bg-bg-base/90 border border-color-accent rounded text-caption text-color-accent whitespace-nowrap backdrop-blur-sm">
|
||||
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||
</div>
|
||||
</div>
|
||||
@ -197,14 +188,13 @@ function WeatherMapInner({
|
||||
{/* 줌 컨트롤 */}
|
||||
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function WeatherView() {
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||
const currentMapStyle = useBaseMapStyle()
|
||||
const mapToggles = useMapStore((s) => s.mapToggles)
|
||||
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS);
|
||||
const currentMapStyle = useBaseMapStyle();
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
// const {
|
||||
// selectedForecast,
|
||||
@ -214,58 +204,55 @@ export function WeatherView() {
|
||||
// selectForecast,
|
||||
// } = useOceanForecast('KOREA')
|
||||
|
||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0')
|
||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null)
|
||||
const [timeOffset, setTimeOffset] = useState<TimeOffset>('0');
|
||||
const [selectedStationRaw, setSelectedStation] = useState<WeatherStation | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||||
null
|
||||
)
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||
null,
|
||||
);
|
||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']));
|
||||
// const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||
|
||||
// 첫 관측소 자동 선택 (파생 값)
|
||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null
|
||||
const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null;
|
||||
|
||||
const handleStationClick = useCallback(
|
||||
(station: WeatherStation) => {
|
||||
setSelectedStation(station)
|
||||
setSelectedLocation(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleStationClick = useCallback((station: WeatherStation) => {
|
||||
setSelectedStation(station);
|
||||
setSelectedLocation(null);
|
||||
}, []);
|
||||
|
||||
const handleMapClick = useCallback(
|
||||
(e: MapLayerMouseEvent) => {
|
||||
const { lat, lng } = e.lngLat
|
||||
if (weatherStations.length === 0) return
|
||||
const { lat, lng } = e.lngLat;
|
||||
if (weatherStations.length === 0) return;
|
||||
|
||||
// 가장 가까운 관측소 선택
|
||||
const nearestStation = weatherStations.reduce((nearest, station) => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2)
|
||||
)
|
||||
Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2),
|
||||
);
|
||||
const nearestDistance = Math.sqrt(
|
||||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2)
|
||||
)
|
||||
return distance < nearestDistance ? station : nearest
|
||||
}, weatherStations[0])
|
||||
Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2),
|
||||
);
|
||||
return distance < nearestDistance ? station : nearest;
|
||||
}, weatherStations[0]);
|
||||
|
||||
setSelectedStation(nearestStation)
|
||||
setSelectedLocation({ lat, lon: lng })
|
||||
setSelectedStation(nearestStation);
|
||||
setSelectedLocation({ lat, lon: lng });
|
||||
},
|
||||
[weatherStations]
|
||||
)
|
||||
[weatherStations],
|
||||
);
|
||||
|
||||
const toggleLayer = useCallback((layer: string) => {
|
||||
setEnabledLayers((prev) => {
|
||||
const next = new Set(prev)
|
||||
const next = new Set(prev);
|
||||
if (next.has(layer)) {
|
||||
next.delete(layer)
|
||||
next.delete(layer);
|
||||
} else {
|
||||
next.add(layer)
|
||||
next.add(layer);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const weatherData = selectedStation
|
||||
? {
|
||||
@ -301,7 +288,7 @@ export function WeatherView() {
|
||||
alert: '풍랑주의보 예상 08:00~',
|
||||
forecast: generateForecast(timeOffset),
|
||||
}
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@ -369,8 +356,11 @@ export function WeatherView() {
|
||||
</Map>
|
||||
|
||||
{/* 레이어 컨트롤 */}
|
||||
<div className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px' }}>
|
||||
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||
<div
|
||||
className="absolute top-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||||
style={{ padding: '6px 10px' }}
|
||||
>
|
||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 레이어</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -379,7 +369,7 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('windParticle')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌬️ 바람 흐름</span>
|
||||
<span className="text-caption text-fg-sub">🌬️ 바람 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -388,7 +378,7 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('wind')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌬️ 바람 벡터</span>
|
||||
<span className="text-caption text-fg-sub">🌬️ 바람 벡터</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -397,7 +387,7 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('waves')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌊 파고 분포</span>
|
||||
<span className="text-caption text-fg-sub">🌊 파고 분포</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -406,7 +396,7 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('temperature')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌡️ 수온 분포</span>
|
||||
<span className="text-caption text-fg-sub">🌡️ 수온 분포</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -415,7 +405,7 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('oceanCurrentParticle')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌊 해류 흐름</span>
|
||||
<span className="text-caption text-fg-sub">🌊 해류 흐름</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
@ -424,18 +414,23 @@ export function WeatherView() {
|
||||
onChange={() => toggleLayer('waterTemperature')}
|
||||
className="w-3 h-3 rounded border-stroke bg-bg-elevated text-color-accent accent-[var(--color-accent)]"
|
||||
/>
|
||||
<span className="text-[9px] text-fg-sub">🌡️ 수온 색상도</span>
|
||||
<span className="text-caption text-fg-sub">🌡️ 수온 색상도</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
<div className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10" style={{ padding: '6px 10px', maxWidth: 180 }}>
|
||||
<div className="text-[9px] font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
||||
<div
|
||||
className="absolute bottom-4 left-4 bg-bg-surface/85 border border-stroke rounded-md backdrop-blur-sm z-10"
|
||||
style={{ padding: '6px 10px', maxWidth: 180 }}
|
||||
>
|
||||
<div className="text-caption font-semibold text-fg mb-1.5 font-korean">기상 범례</div>
|
||||
<div className="flex flex-col gap-1.5" style={{ fontSize: 8 }}>
|
||||
{/* 바람 */}
|
||||
<div>
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>바람 (m/s)</div>
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
바람 (m/s)
|
||||
</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: '#6271b7' }} />
|
||||
<div className="flex-1 h-full" style={{ background: '#39a0f6' }} />
|
||||
@ -447,12 +442,20 @@ export function WeatherView() {
|
||||
<div className="flex-1 h-full" style={{ background: '#b41e46' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||
<span>3</span><span>5</span><span>7</span><span>10</span><span>13</span><span>16</span><span>20+</span>
|
||||
<span>3</span>
|
||||
<span>5</span>
|
||||
<span>7</span>
|
||||
<span>10</span>
|
||||
<span>13</span>
|
||||
<span>16</span>
|
||||
<span>20+</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 해류 */}
|
||||
<div className="pt-1 border-t border-stroke">
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>해류 (m/s)</div>
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
해류 (m/s)
|
||||
</div>
|
||||
<div className="flex items-center gap-px h-[6px] rounded-sm overflow-hidden mb-0.5">
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(59, 130, 246)' }} />
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(6, 182, 212)' }} />
|
||||
@ -460,12 +463,17 @@ export function WeatherView() {
|
||||
<div className="flex-1 h-full" style={{ background: 'rgb(249, 115, 22)' }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-fg-disabled" style={{ fontSize: 7 }}>
|
||||
<span>0.2</span><span>0.4</span><span>0.6</span><span>0.6+</span>
|
||||
<span>0.2</span>
|
||||
<span>0.4</span>
|
||||
<span>0.6</span>
|
||||
<span>0.6+</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 파고 */}
|
||||
<div className="pt-1 border-t border-stroke">
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>파고 (m)</div>
|
||||
<div className="font-semibold text-fg-sub mb-0.5" style={{ fontSize: 8 }}>
|
||||
파고 (m)
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<span className="text-fg-disabled"><1.5 낮음</span>
|
||||
@ -476,7 +484,10 @@ export function WeatherView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean" style={{ fontSize: 7 }}>
|
||||
<div
|
||||
className="mt-1 pt-1 border-t border-stroke text-fg-disabled font-korean"
|
||||
style={{ fontSize: 7 }}
|
||||
>
|
||||
💡 지도 클릭 → 기상 예보 확인
|
||||
</div>
|
||||
</div>
|
||||
@ -486,5 +497,5 @@ export function WeatherView() {
|
||||
{/* Right Panel */}
|
||||
<WeatherRightPanel weatherData={weatherData} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user