release: 2026-04-09 (247건 커밋) #163
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 사용)
|
||||
|
||||
@ -219,9 +219,9 @@ export async function createAnalysis(input: {
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
ANALYST_NM, EXEC_STTS_CD
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
||||
$1, $2, $3, $4::numeric, $5::numeric,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::double precision, $5::double precision), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END,
|
||||
$6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16,
|
||||
$17, 'PENDING'
|
||||
|
||||
@ -54,6 +54,28 @@ router.get('/vworld/:z/:y/:x', async (req, res) => {
|
||||
await proxyUpstream(tileUrl, res, 'image/jpeg');
|
||||
});
|
||||
|
||||
// ─── SR 민감자원 벡터타일 ───
|
||||
|
||||
// GET /api/tiles/sr/tilejson — SR TileJSON 프록시 (source-layer 메타데이터)
|
||||
router.get('/sr/tilejson', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sr`, res, 'application/json');
|
||||
});
|
||||
|
||||
// GET /api/tiles/sr/style — SR 스타일 JSON 프록시 (레이어별 type/paint/layout 정의)
|
||||
router.get('/sr/style', async (_req, res) => {
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/style/sr`, res, 'application/json');
|
||||
});
|
||||
|
||||
// GET /api/tiles/sr/:z/:x/:y — SR(민감자원) 벡터타일 프록시
|
||||
router.get('/sr/:z/:x/:y', async (req, res) => {
|
||||
const { z, x, y } = req.params;
|
||||
if (!/^\d+$/.test(z) || !/^\d+$/.test(x) || !/^\d+$/.test(y)) {
|
||||
res.status(400).json({ error: '잘못된 타일 좌표' });
|
||||
return;
|
||||
}
|
||||
await proxyUpstream(`${ENC_UPSTREAM}/sr/${z}/${x}/${y}`, res, 'application/x-protobuf');
|
||||
});
|
||||
|
||||
// ─── S-57 전자해도 (ENC) ───
|
||||
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||
|
||||
|
||||
@ -4,6 +4,20 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-09]
|
||||
|
||||
### 추가
|
||||
- 디자인 시스템 Float 카탈로그 추가 (Modal / Dropdown / Overlay / Toast)
|
||||
- 디자인 시스템 폰트/색상 토큰을 전 탭 컴포넌트에 전면 적용 (admin, aerial, assets, board, hns, incidents, prediction, reports, rescue, scat, weather)
|
||||
- SR 민감자원 벡터타일 오버레이 컴포넌트 및 백엔드 프록시 엔드포인트 추가
|
||||
- 해양 오염물질 배출규정 구역 판별 기능 추가
|
||||
|
||||
### 변경
|
||||
- 지도: 항상 라이트 모드로 고정 (앱 다크 모드와 무관)
|
||||
- 지도: lightMode prop 제거, useThemeStore 기반 테마 전환 통합
|
||||
- 레이어 색상 상태를 OilSpillView로 끌어올림
|
||||
- 대한민국 해리 GeoJSON 데이터 갱신
|
||||
|
||||
## [2026-04-02]
|
||||
|
||||
### 변경
|
||||
|
||||
@ -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 />
|
||||
|
||||
2040
frontend/public/data/대한민국.geojson
Normal file
2040
frontend/public/data/대한민국.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
2360
frontend/public/data/대한민국_12해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
2135
frontend/public/data/대한민국_25해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
2522
frontend/public/data/대한민국_3해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
1965
frontend/public/data/대한민국_50해리.geojson
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
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>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||
import { useThemeStore } from '@common/store/themeStore';
|
||||
|
||||
interface HydrParticleOverlayProps {
|
||||
hydrStep: HydrDataStep | null;
|
||||
lightMode?: boolean;
|
||||
}
|
||||
|
||||
const PARTICLE_COUNT = 3000;
|
||||
@ -25,10 +25,8 @@ interface Particle {
|
||||
age: number;
|
||||
}
|
||||
|
||||
export default function HydrParticleOverlay({
|
||||
hydrStep,
|
||||
lightMode = false,
|
||||
}: HydrParticleOverlayProps) {
|
||||
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
||||
const lightMode = useThemeStore((s) => s.theme) === 'light';
|
||||
const { current: map } = useMap();
|
||||
const animRef = useRef<number>();
|
||||
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
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>
|
||||
|
||||
333
frontend/src/common/components/map/SrOverlay.tsx
Normal file
333
frontend/src/common/components/map/SrOverlay.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useMap } from '@vis.gl/react-maplibre';
|
||||
import { API_BASE_URL } from '../../services/api';
|
||||
import { useLayerTree } from '../../hooks/useLayers';
|
||||
import type { Layer } from '../../services/layerService';
|
||||
import { getOpacityProp, getColorProp } from './srStyles';
|
||||
|
||||
const SR_SOURCE_ID = 'sr';
|
||||
const PROXY_PREFIX = `${API_BASE_URL}/tiles`;
|
||||
|
||||
// MapLibre 내부 요청은 절대 URL이 필요
|
||||
const ABSOLUTE_PREFIX = API_BASE_URL.startsWith('http')
|
||||
? PROXY_PREFIX
|
||||
: `${window.location.origin}${PROXY_PREFIX}`;
|
||||
|
||||
// ─── SR 스타일 JSON (Martin style/sr) ───
|
||||
|
||||
interface SrStyleLayer {
|
||||
id: string;
|
||||
type: string;
|
||||
source: string;
|
||||
'source-layer': string;
|
||||
paint?: Record<string, unknown>;
|
||||
layout?: Record<string, unknown>;
|
||||
filter?: unknown;
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
}
|
||||
|
||||
interface SrStyle {
|
||||
sources: Record<string, { type: string; tiles?: string[]; url?: string }>;
|
||||
layers: SrStyleLayer[];
|
||||
}
|
||||
|
||||
let cachedStyle: SrStyle | null = null;
|
||||
|
||||
async function loadSrStyle(): Promise<SrStyle> {
|
||||
if (cachedStyle) return cachedStyle;
|
||||
const res = await fetch(`${PROXY_PREFIX}/sr/style`);
|
||||
if (!res.ok) throw new Error(`SR style fetch failed: ${res.status}`);
|
||||
cachedStyle = await res.json();
|
||||
return cachedStyle!;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: wmsLayer(mpc:XXX)에서 코드 추출 ───
|
||||
|
||||
function extractCode(wmsLayer: string): string | null {
|
||||
// mpc:468 → '468', mpc:386_spr → '386', mpc:kcg → 'kcg', mpc:kcg_ofi → 'kcg_ofi'
|
||||
const match = wmsLayer.match(/^mpc:(.+?)(?:_(spr|sum|fal|win|apr))?$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// ─── layerTree → SR 매핑 구축 ───
|
||||
|
||||
interface SrMapping {
|
||||
layerCd: string; // DB LAYER_CD (예: 'LYR001002001004005')
|
||||
code: string; // mpc: 뒤 코드 (예: '468', 'kcg', '3')
|
||||
name: string; // DB 레이어명 (예: '갯벌', '경찰청', '군산')
|
||||
}
|
||||
|
||||
// ─── source-layer → DB layerCd 매칭 ───
|
||||
|
||||
function matchSourceLayer(sourceLayer: string, mappings: SrMapping[]): string[] {
|
||||
// 1차: 숫자 접두사 매칭 (468_갯벌 → code '468')
|
||||
const numMatch = sourceLayer.match(/^(\d+)/);
|
||||
if (numMatch) {
|
||||
const code = numMatch[1];
|
||||
const matched = mappings.filter((m) => m.code === code);
|
||||
if (matched.length > 0) return matched.map((m) => m.layerCd);
|
||||
}
|
||||
|
||||
// 2차: 이름 정확 일치 (경찰청 = 경찰청)
|
||||
const exactMatch = mappings.filter((m) => sourceLayer === m.name);
|
||||
if (exactMatch.length > 0) return exactMatch.map((m) => m.layerCd);
|
||||
|
||||
// 3차: 접미사 일치 (해경관할구역-군산 → name '군산')
|
||||
const suffixMatch = mappings.filter(
|
||||
(m) => sourceLayer.endsWith(`-${m.name}`) || sourceLayer.endsWith(`_${m.name}`),
|
||||
);
|
||||
if (suffixMatch.length > 0) return suffixMatch.map((m) => m.layerCd);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildSrMappings(layers: Layer[]): SrMapping[] {
|
||||
const result: SrMapping[] = [];
|
||||
function traverse(nodes: Layer[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.wmsLayer) {
|
||||
const code = extractCode(node.wmsLayer);
|
||||
if (code) {
|
||||
result.push({ layerCd: node.id, code, name: node.name });
|
||||
}
|
||||
}
|
||||
if (node.children) traverse(node.children);
|
||||
}
|
||||
}
|
||||
traverse(layers);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ───
|
||||
|
||||
interface SrOverlayProps {
|
||||
enabledLayers: Set<string>;
|
||||
opacity?: number;
|
||||
layerColors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function SrOverlay({ enabledLayers, opacity = 100, layerColors }: SrOverlayProps) {
|
||||
const { current: mapRef } = useMap();
|
||||
const { data: layerTree } = useLayerTree();
|
||||
const addedLayersRef = useRef<Set<string>>(new Set());
|
||||
const sourceAddedRef = useRef(false);
|
||||
const [style, setStyle] = useState<SrStyle | null>(cachedStyle);
|
||||
|
||||
// 스타일 JSON 로드 (최초 1회)
|
||||
useEffect(() => {
|
||||
if (style) return;
|
||||
loadSrStyle()
|
||||
.then(setStyle)
|
||||
.catch((err) => console.error('[SrOverlay] SR 스타일 로드 실패:', err));
|
||||
}, [style]);
|
||||
|
||||
const ensureSource = useCallback((map: maplibregl.Map) => {
|
||||
if (sourceAddedRef.current) return;
|
||||
if (map.getSource(SR_SOURCE_ID)) {
|
||||
sourceAddedRef.current = true;
|
||||
return;
|
||||
}
|
||||
map.addSource(SR_SOURCE_ID, {
|
||||
type: 'vector',
|
||||
tiles: [`${ABSOLUTE_PREFIX}/sr/{z}/{x}/{y}`],
|
||||
maxzoom: 14,
|
||||
});
|
||||
sourceAddedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const removeAll = useCallback((map: maplibregl.Map) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(map as any).style) {
|
||||
addedLayersRef.current.clear();
|
||||
sourceAddedRef.current = false;
|
||||
return;
|
||||
}
|
||||
for (const id of addedLayersRef.current) {
|
||||
if (map.getLayer(id)) map.removeLayer(id);
|
||||
}
|
||||
addedLayersRef.current.clear();
|
||||
if (map.getSource(SR_SOURCE_ID)) map.removeSource(SR_SOURCE_ID);
|
||||
sourceAddedRef.current = false;
|
||||
}, []);
|
||||
|
||||
// enabledLayers 변경 시 레이어 동기화
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map || !layerTree || !style) return;
|
||||
|
||||
const mappings = buildSrMappings(layerTree);
|
||||
|
||||
// source-layer → DB layerCd[] 매핑
|
||||
const sourceLayerToIds = new Map<string, string[]>();
|
||||
for (const sl of style.layers) {
|
||||
const ids = matchSourceLayer(sl['source-layer'], mappings);
|
||||
if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids);
|
||||
}
|
||||
|
||||
// 커스텀 색상 조회 (source-layer name 기반)
|
||||
const getCustomColor = (sourceLayer: string): string | undefined => {
|
||||
const ids = sourceLayerToIds.get(sourceLayer);
|
||||
if (!ids) return undefined;
|
||||
for (const id of ids) {
|
||||
const c = layerColors?.[id];
|
||||
if (c) return c;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// style JSON 레이어 중 활성화된 DB 레이어에 해당하는 스타일 레이어 필터
|
||||
const enabledStyleLayers = style.layers.filter((sl) => {
|
||||
const ids = sourceLayerToIds.get(sl['source-layer']);
|
||||
return ids && ids.some((id) => enabledLayers.has(id));
|
||||
});
|
||||
|
||||
const syncLayers = () => {
|
||||
ensureSource(map);
|
||||
|
||||
const activeLayerIds = new Set<string>();
|
||||
|
||||
// 활성화된 레이어 추가 또는 visible 설정
|
||||
for (const sl of enabledStyleLayers) {
|
||||
const layerId = `sr-${sl.id}`;
|
||||
activeLayerIds.add(layerId);
|
||||
|
||||
const customColor = getCustomColor(sl['source-layer']);
|
||||
const layerType = sl.type as 'fill' | 'line' | 'circle';
|
||||
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
||||
// 기존 레이어에 커스텀 색상 적용
|
||||
const colorProp = getColorProp(layerType);
|
||||
if (customColor) {
|
||||
map.setPaintProperty(layerId, colorProp, customColor);
|
||||
} else {
|
||||
const orig = sl.paint?.[colorProp];
|
||||
if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig);
|
||||
}
|
||||
} else {
|
||||
const opacityValue = opacity / 100;
|
||||
const opacityProp = getOpacityProp(layerType);
|
||||
const paint = { ...sl.paint, [opacityProp]: opacityValue };
|
||||
|
||||
// 커스텀 색상 적용
|
||||
if (customColor) {
|
||||
const colorProp = getColorProp(layerType);
|
||||
paint[colorProp] = customColor;
|
||||
if (sl.type === 'fill') {
|
||||
paint['fill-outline-color'] = customColor;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: sl.type,
|
||||
source: SR_SOURCE_ID,
|
||||
'source-layer': sl['source-layer'],
|
||||
paint,
|
||||
layout: { visibility: 'visible', ...sl.layout },
|
||||
...(sl.filter ? { filter: sl.filter } : {}),
|
||||
...(sl.minzoom !== undefined && { minzoom: sl.minzoom }),
|
||||
...(sl.maxzoom !== undefined && { maxzoom: sl.maxzoom }),
|
||||
} as maplibregl.AddLayerObject);
|
||||
addedLayersRef.current.add(layerId);
|
||||
} catch (err) {
|
||||
console.warn(`[SrOverlay] 레이어 추가 실패 (${sl.id}):`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 비활성화된 레이어 숨김
|
||||
for (const layerId of addedLayersRef.current) {
|
||||
if (!activeLayerIds.has(layerId)) {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', 'none');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (map.isStyleLoaded()) {
|
||||
syncLayers();
|
||||
} else {
|
||||
map.once('style.load', syncLayers);
|
||||
return () => {
|
||||
map.off('style.load', syncLayers);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabledLayers, layerTree, style, mapRef, layerColors]);
|
||||
|
||||
// opacity 변경 시 paint 업데이트
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map || !style) return;
|
||||
|
||||
const opacityValue = opacity / 100;
|
||||
for (const layerId of addedLayersRef.current) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
const originalId = layerId.replace(/^sr-/, '');
|
||||
const sl = style.layers.find((l) => l.id === originalId);
|
||||
if (sl) {
|
||||
const prop = getOpacityProp(sl.type as 'fill' | 'line' | 'circle');
|
||||
map.setPaintProperty(layerId, prop, opacityValue);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opacity, mapRef]);
|
||||
|
||||
// layerColors 변경 시 paint 업데이트
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map || !style || !layerTree) return;
|
||||
|
||||
const mappings = buildSrMappings(layerTree);
|
||||
const sourceLayerToIds = new Map<string, string[]>();
|
||||
for (const sl of style.layers) {
|
||||
const ids = matchSourceLayer(sl['source-layer'], mappings);
|
||||
if (ids.length > 0) sourceLayerToIds.set(sl['source-layer'], ids);
|
||||
}
|
||||
|
||||
const getCustomColor = (sourceLayer: string): string | undefined => {
|
||||
const ids = sourceLayerToIds.get(sourceLayer);
|
||||
if (!ids) return undefined;
|
||||
for (const id of ids) {
|
||||
const c = layerColors?.[id];
|
||||
if (c) return c;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
for (const layerId of addedLayersRef.current) {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
const originalId = layerId.replace(/^sr-/, '');
|
||||
const sl = style.layers.find((l) => l.id === originalId);
|
||||
if (!sl) continue;
|
||||
|
||||
const customColor = getCustomColor(sl['source-layer']);
|
||||
const layerType = sl.type as 'fill' | 'line' | 'circle';
|
||||
const colorProp = getColorProp(layerType);
|
||||
|
||||
if (customColor) {
|
||||
map.setPaintProperty(layerId, colorProp, customColor);
|
||||
} else {
|
||||
const orig = sl.paint?.[colorProp];
|
||||
if (orig !== undefined) map.setPaintProperty(layerId, colorProp, orig as string);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layerColors, mapRef, style, layerTree]);
|
||||
|
||||
// cleanup on unmount
|
||||
useEffect(() => {
|
||||
const map = mapRef?.getMap();
|
||||
if (!map) return;
|
||||
return () => {
|
||||
removeAll(map);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mapRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
26
frontend/src/common/components/map/srStyles.ts
Normal file
26
frontend/src/common/components/map/srStyles.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// SR(민감자원) 벡터타일 헬퍼
|
||||
// 스타일은 Martin style/sr JSON에서 동적 로드 (SrOverlay에서 사용)
|
||||
|
||||
/** opacity 속성 키를 레이어 타입에 따라 반환 */
|
||||
export function getOpacityProp(type: 'fill' | 'line' | 'circle'): string {
|
||||
switch (type) {
|
||||
case 'fill':
|
||||
return 'fill-opacity';
|
||||
case 'line':
|
||||
return 'line-opacity';
|
||||
case 'circle':
|
||||
return 'circle-opacity';
|
||||
}
|
||||
}
|
||||
|
||||
/** color 속성 키를 레이어 타입에 따라 반환 */
|
||||
export function getColorProp(type: 'fill' | 'line' | 'circle'): string {
|
||||
switch (type) {
|
||||
case 'fill':
|
||||
return 'fill-color';
|
||||
case 'line':
|
||||
return 'line-color';
|
||||
case 'circle':
|
||||
return 'circle-color';
|
||||
}
|
||||
}
|
||||
@ -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,17 +1,11 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { useMapStore } from '@common/store/mapStore';
|
||||
import {
|
||||
BASE_STYLE,
|
||||
LIGHT_STYLE,
|
||||
SATELLITE_3D_STYLE,
|
||||
ENC_EMPTY_STYLE,
|
||||
} from '@common/components/map/mapStyles';
|
||||
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
|
||||
|
||||
export function useBaseMapStyle(lightMode = false): StyleSpecification {
|
||||
export function useBaseMapStyle(): StyleSpecification {
|
||||
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||
|
||||
if (mapToggles.s57) return ENC_EMPTY_STYLE;
|
||||
if (mapToggles.threeD) return SATELLITE_3D_STYLE;
|
||||
if (lightMode) return LIGHT_STYLE;
|
||||
return BASE_STYLE;
|
||||
return LIGHT_STYLE;
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -14,106 +14,99 @@ type Status = 'forbidden' | 'allowed' | 'conditional';
|
||||
interface DischargeRule {
|
||||
category: string;
|
||||
item: string;
|
||||
zones: [Status, Status, Status, Status]; // [~3NM, 3~12NM, 12~25NM, 25NM+]
|
||||
zones: [Status, Status, Status, Status, Status]; // [~3NM, 3~12NM, 12~25NM, 25~50NM, 50NM+]
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
const RULES: DischargeRule[] = [
|
||||
// 폐기물
|
||||
// 분뇨
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '플라스틱 제품',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨마쇄소독장치',
|
||||
zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
||||
},
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '포장유해물질·용기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨저장탱크',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출',
|
||||
},
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '중금속 포함 쓰레기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
category: '분뇨',
|
||||
item: '분뇨처리장치',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가',
|
||||
},
|
||||
// 음식물찌꺼기
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '미분쇄 음식물',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '분쇄·연마 음식물 (25mm 이하)',
|
||||
zones: ['forbidden', 'conditional', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '25mm 이하 개구 스크린 통과 가능시',
|
||||
},
|
||||
// 화물잔류물
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '부유성 화물잔류물',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'],
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '침강성 화물잔류물',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
||||
},
|
||||
{
|
||||
category: '화물잔류물',
|
||||
item: '화물창 세정수',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '해양환경에 해롭지 않은 일반세제 사용시',
|
||||
},
|
||||
// 음식물 찌꺼기
|
||||
// 화물유
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '미분쇄',
|
||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||
category: '화물유',
|
||||
item: '화물유 섞인 평형수·세정수·선저폐수',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'conditional'],
|
||||
condition: '항해 중, 순간배출률 1해리당 30L 이하, 기름오염방지설비 작동 중',
|
||||
},
|
||||
// 유해액체물질
|
||||
{
|
||||
category: '유해액체물질',
|
||||
item: '유해액체물질 섞인 세정수',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '자항선 7노트/비자항선 4노트 이상, 수심 25m 이상, 수면하 배출구 사용',
|
||||
},
|
||||
// 폐기물
|
||||
{
|
||||
category: '폐기물',
|
||||
item: '플라스틱 제품',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
{
|
||||
category: '음식물찌꺼기',
|
||||
item: '분쇄·연마',
|
||||
zones: ['forbidden', 'conditional', 'allowed', 'allowed'],
|
||||
condition: '크기 25mm 이하시',
|
||||
},
|
||||
// 분뇨
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨저장장치',
|
||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 서서히 배출',
|
||||
category: '폐기물',
|
||||
item: '포장유해물질·용기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨마쇄소독장치',
|
||||
zones: ['forbidden', 'conditional', 'conditional', 'conditional'],
|
||||
condition: '항속 4노트 이상시 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
||||
},
|
||||
{
|
||||
category: '분뇨',
|
||||
item: '분뇨처리장치',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면 및 육성수면은 불가',
|
||||
},
|
||||
// 중수
|
||||
{
|
||||
category: '중수',
|
||||
item: '거주구역 중수',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '수산자원보호구역, 보호수면, 수산자원관리수면, 지정해역 등은 불가',
|
||||
},
|
||||
// 수산동식물
|
||||
{
|
||||
category: '수산동식물',
|
||||
item: '자연기원물질',
|
||||
zones: ['allowed', 'allowed', 'allowed', 'allowed'],
|
||||
condition: '면허 또는 허가를 득한 자에 한하여 어업활동 수면',
|
||||
category: '폐기물',
|
||||
item: '중금속 포함 쓰레기',
|
||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||
},
|
||||
];
|
||||
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+'];
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e'];
|
||||
|
||||
function getZoneIndex(distanceNm: number): number {
|
||||
if (distanceNm < 3) return 0;
|
||||
if (distanceNm < 12) return 1;
|
||||
if (distanceNm < 25) return 2;
|
||||
return 3;
|
||||
}
|
||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b'];
|
||||
|
||||
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 +115,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 +123,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)' }}
|
||||
>
|
||||
조건부
|
||||
@ -142,11 +135,18 @@ interface DischargeZonePanelProps {
|
||||
lat: number;
|
||||
lon: number;
|
||||
distanceNm: number;
|
||||
zoneIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
||||
const zoneIdx = getZoneIndex(distanceNm);
|
||||
export function DischargeZonePanel({
|
||||
lat,
|
||||
lon,
|
||||
distanceNm,
|
||||
zoneIndex,
|
||||
onClose,
|
||||
}: DischargeZonePanelProps) {
|
||||
const zoneIdx = zoneIndex;
|
||||
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
||||
|
||||
const categories = [...new Set(RULES.map((r) => r.category))];
|
||||
@ -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,8 +298,8 @@ 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>
|
||||
</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,
|
||||
|
||||
@ -4,195 +4,102 @@
|
||||
* 법률 근거:
|
||||
* https://lbox.kr/v2/statute/%ED%95%B4%EC%96%91%ED%99%98%EA%B2%BD%EA%B4%80%EB%A6%AC%EB%B2%95/%EB%B3%B8%EB%AC%B8%20%3E%20%EC%A0%9C3%EC%9E%A5%20%3E%20%EC%A0%9C1%EC%A0%88%20%3E%20%EC%A0%9C22%EC%A1%B0
|
||||
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||
*
|
||||
* 구역 경계선: 국립해양조사원 영해기선(TB_ZN_TRTSEA) 버퍼 GeoJSON
|
||||
* 영해기선 데이터: 국립해양조사원 TB_ZN_TRTSEA (EPSG:5179 → WGS84 변환)
|
||||
*/
|
||||
|
||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
||||
const COASTLINE_POINTS: [number, number][] = [
|
||||
// 동해안 (북→남)
|
||||
[38.6177, 128.656],
|
||||
[38.5504, 128.4092],
|
||||
[38.4032, 128.7767],
|
||||
[38.1904, 128.8902],
|
||||
[38.0681, 128.9977],
|
||||
[37.9726, 129.0715],
|
||||
[37.8794, 129.1721],
|
||||
[37.8179, 129.2397],
|
||||
[37.6258, 129.3669],
|
||||
[37.5053, 129.4577],
|
||||
[37.3617, 129.57],
|
||||
[37.1579, 129.6538],
|
||||
[37.0087, 129.6706],
|
||||
[36.6618, 129.721],
|
||||
[36.3944, 129.6827],
|
||||
[36.2052, 129.7641],
|
||||
[35.9397, 129.8124],
|
||||
[35.6272, 129.7121],
|
||||
[35.4732, 129.6908],
|
||||
[35.2843, 129.5924],
|
||||
[35.141, 129.4656],
|
||||
[35.0829, 129.2125],
|
||||
// 남해안 (부산→여수→목포)
|
||||
[34.895, 129.0658],
|
||||
[34.205, 128.3063],
|
||||
[35.022, 128.0362],
|
||||
[34.9663, 127.8732],
|
||||
[34.9547, 127.7148],
|
||||
[34.8434, 127.6625],
|
||||
[34.7826, 127.7422],
|
||||
[34.6902, 127.6324],
|
||||
[34.8401, 127.5236],
|
||||
[34.823, 127.4043],
|
||||
[34.6882, 127.4234],
|
||||
[34.6252, 127.4791],
|
||||
[34.5525, 127.4012],
|
||||
[34.4633, 127.3246],
|
||||
[34.5461, 127.1734],
|
||||
[34.6617, 127.2605],
|
||||
[34.7551, 127.2471],
|
||||
[34.6069, 127.0308],
|
||||
[34.4389, 126.8975],
|
||||
[34.4511, 126.8263],
|
||||
[34.4949, 126.7965],
|
||||
[34.5119, 126.7548],
|
||||
[34.4035, 126.6108],
|
||||
[34.3175, 126.5844],
|
||||
[34.3143, 126.5314],
|
||||
[34.3506, 126.5083],
|
||||
[34.4284, 126.5064],
|
||||
[34.4939, 126.4817],
|
||||
[34.5896, 126.3326],
|
||||
[34.6732, 126.2645],
|
||||
// 서해안 (목포→인천)
|
||||
[34.72, 126.3011],
|
||||
[34.6946, 126.4256],
|
||||
[34.6979, 126.5245],
|
||||
[34.7787, 126.5386],
|
||||
[34.8244, 126.5934],
|
||||
[34.8104, 126.4785],
|
||||
[34.8234, 126.4207],
|
||||
[34.9328, 126.3979],
|
||||
[35.0451, 126.3274],
|
||||
[35.1542, 126.2911],
|
||||
[35.2169, 126.3605],
|
||||
[35.3144, 126.3959],
|
||||
[35.4556, 126.4604],
|
||||
[35.5013, 126.4928],
|
||||
[35.5345, 126.5822],
|
||||
[35.571, 126.6141],
|
||||
[35.5897, 126.5649],
|
||||
[35.6063, 126.4865],
|
||||
[35.6471, 126.4885],
|
||||
[35.6693, 126.5419],
|
||||
[35.7142, 126.6016],
|
||||
[35.7688, 126.7174],
|
||||
[35.872, 126.753],
|
||||
[35.8979, 126.7196],
|
||||
[35.9225, 126.6475],
|
||||
[35.9745, 126.6637],
|
||||
[36.0142, 126.6935],
|
||||
[36.0379, 126.6823],
|
||||
[36.105, 126.5971],
|
||||
[36.1662, 126.5404],
|
||||
[36.2358, 126.5572],
|
||||
[36.3412, 126.5442],
|
||||
[36.4297, 126.552],
|
||||
[36.4776, 126.5482],
|
||||
[36.5856, 126.5066],
|
||||
[36.6938, 126.4877],
|
||||
[36.678, 126.433],
|
||||
[36.6512, 126.3888],
|
||||
[36.6893, 126.2307],
|
||||
[36.6916, 126.1809],
|
||||
[36.7719, 126.1605],
|
||||
[36.8709, 126.2172],
|
||||
[36.9582, 126.3516],
|
||||
[36.969, 126.4287],
|
||||
[37.0075, 126.487],
|
||||
[37.0196, 126.5777],
|
||||
[36.9604, 126.6867],
|
||||
[36.9484, 126.7845],
|
||||
[36.8461, 126.8388],
|
||||
[36.8245, 126.8721],
|
||||
[36.8621, 126.8791],
|
||||
[36.9062, 126.958],
|
||||
[36.9394, 126.9769],
|
||||
[36.9576, 126.9598],
|
||||
[36.9757, 126.8689],
|
||||
[37.1027, 126.7874],
|
||||
[37.1582, 126.7761],
|
||||
[37.1936, 126.7464],
|
||||
[37.2949, 126.7905],
|
||||
[37.4107, 126.6962],
|
||||
[37.4471, 126.6503],
|
||||
[37.5512, 126.6568],
|
||||
[37.6174, 126.6076],
|
||||
[37.6538, 126.5802],
|
||||
[37.7165, 126.5634],
|
||||
[37.7447, 126.5777],
|
||||
[37.7555, 126.6207],
|
||||
[37.7818, 126.6339],
|
||||
[37.8007, 126.6646],
|
||||
[37.8279, 126.6665],
|
||||
[37.9172, 126.6668],
|
||||
[37.979, 126.7543],
|
||||
// DMZ (간소화)
|
||||
[38.1066, 126.8789],
|
||||
[38.1756, 126.94],
|
||||
[38.2405, 127.0097],
|
||||
[38.2839, 127.0903],
|
||||
[38.3045, 127.1695],
|
||||
[38.3133, 127.294],
|
||||
[38.3244, 127.5469],
|
||||
[38.3353, 127.7299],
|
||||
[38.3469, 127.7858],
|
||||
[38.3066, 127.8207],
|
||||
[38.325, 127.9001],
|
||||
[38.315, 128.0083],
|
||||
[38.3107, 128.0314],
|
||||
[38.3189, 128.0887],
|
||||
[38.3317, 128.1269],
|
||||
[38.3481, 128.1606],
|
||||
[38.3748, 128.2054],
|
||||
[38.4032, 128.2347],
|
||||
[38.4797, 128.3064],
|
||||
[38.5339, 128.6952],
|
||||
[38.6177, 128.656],
|
||||
// ── GeoJSON 타입 ──
|
||||
|
||||
interface GeoJSONFeature {
|
||||
geometry: {
|
||||
type: string;
|
||||
coordinates: number[][][][] | number[][][];
|
||||
};
|
||||
}
|
||||
|
||||
// ── 영해기선 폴리곤 (거리 계산용) ──
|
||||
|
||||
let cachedBaselineRings: [number, number][][] | null = null;
|
||||
let baselineLoadingPromise: Promise<[number, number][][]> | null = null;
|
||||
|
||||
function extractOuterRings(geojson: { features: GeoJSONFeature[] }): [number, number][][] {
|
||||
const rings: [number, number][][] = [];
|
||||
for (const feature of geojson.features) {
|
||||
const geom = feature.geometry;
|
||||
if (geom.type === 'MultiPolygon') {
|
||||
const polygons = geom.coordinates as [number, number][][][];
|
||||
for (const polygon of polygons) {
|
||||
rings.push(polygon[0]);
|
||||
}
|
||||
} else if (geom.type === 'Polygon') {
|
||||
const polygon = geom.coordinates as [number, number][][];
|
||||
rings.push(polygon[0]);
|
||||
}
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
export async function loadTerritorialBaseline(): Promise<[number, number][][]> {
|
||||
if (cachedBaselineRings) return cachedBaselineRings;
|
||||
if (baselineLoadingPromise) return baselineLoadingPromise;
|
||||
|
||||
baselineLoadingPromise = fetch('/data/대한민국.geojson')
|
||||
.then((res) => res.json())
|
||||
.then((data: { features: GeoJSONFeature[] }) => {
|
||||
cachedBaselineRings = extractOuterRings(data);
|
||||
return cachedBaselineRings;
|
||||
});
|
||||
|
||||
return baselineLoadingPromise;
|
||||
}
|
||||
|
||||
export function getCachedBaseline(): [number, number][][] | null {
|
||||
return cachedBaselineRings;
|
||||
}
|
||||
|
||||
// ── 구역 경계선 GeoJSON (런타임 로드) ──
|
||||
|
||||
interface ZoneGeoJSON {
|
||||
nm: number;
|
||||
rings: [number, number][][];
|
||||
}
|
||||
|
||||
let cachedZones: ZoneGeoJSON[] | null = null;
|
||||
let zoneLoadingPromise: Promise<ZoneGeoJSON[]> | null = null;
|
||||
|
||||
const ZONE_FILES = [
|
||||
{ nm: 3, file: '/data/대한민국_3해리.geojson' },
|
||||
{ nm: 12, file: '/data/대한민국_12해리.geojson' },
|
||||
{ nm: 25, file: '/data/대한민국_25해리.geojson' },
|
||||
{ nm: 50, file: '/data/대한민국_50해리.geojson' },
|
||||
];
|
||||
|
||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
||||
const JEJU_POINTS: [number, number][] = [
|
||||
[33.5168, 126.0128],
|
||||
[33.5067, 126.0073],
|
||||
[33.119, 126.0102],
|
||||
[33.0938, 126.0176],
|
||||
[33.0748, 126.0305],
|
||||
[33.0556, 126.0355],
|
||||
[33.028, 126.0492],
|
||||
[33.0159, 126.4783],
|
||||
[33.0115, 126.5186],
|
||||
[33.0143, 126.5572],
|
||||
[33.0231, 126.597],
|
||||
[33.0182, 126.6432],
|
||||
[33.0201, 126.7129],
|
||||
[33.0458, 126.7847],
|
||||
[33.0662, 126.8169],
|
||||
[33.0979, 126.8512],
|
||||
[33.1192, 126.9292],
|
||||
[33.1445, 126.9783],
|
||||
[33.1683, 127.0129],
|
||||
[33.1974, 127.043],
|
||||
[33.2226, 127.0634],
|
||||
[33.2436, 127.0723],
|
||||
[33.4646, 127.2106],
|
||||
[33.544, 126.0355],
|
||||
[33.5808, 126.0814],
|
||||
[33.5168, 126.0128],
|
||||
];
|
||||
export async function loadZoneGeoJSON(): Promise<ZoneGeoJSON[]> {
|
||||
if (cachedZones) return cachedZones;
|
||||
if (zoneLoadingPromise) return zoneLoadingPromise;
|
||||
|
||||
const ALL_COASTLINE = [...COASTLINE_POINTS, ...JEJU_POINTS];
|
||||
zoneLoadingPromise = Promise.all(
|
||||
ZONE_FILES.map(async ({ nm, file }) => {
|
||||
const res = await fetch(file);
|
||||
const geojson = await res.json();
|
||||
return { nm, rings: extractOuterRings(geojson) };
|
||||
}),
|
||||
).then((zones) => {
|
||||
cachedZones = zones;
|
||||
return zones;
|
||||
});
|
||||
|
||||
/** 두 좌표 간 대략적 해리(NM) 계산 (Haversine) */
|
||||
return zoneLoadingPromise;
|
||||
}
|
||||
|
||||
export function getCachedZones(): ZoneGeoJSON[] | null {
|
||||
return cachedZones;
|
||||
}
|
||||
|
||||
// ── 거리 계산 ──
|
||||
|
||||
/** 두 좌표 간 해리(NM) 계산 (Haversine) */
|
||||
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 3440.065;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
@ -203,83 +110,136 @@ function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): nu
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 가장 가까운 해안선까지의 거리 (NM) */
|
||||
/** 점 P에서 선분 AB까지의 최단거리 (NM) — 위도 보정 평면 투영 */
|
||||
function pointToSegmentNm(
|
||||
pLat: number,
|
||||
pLon: number,
|
||||
aLon: number,
|
||||
aLat: number,
|
||||
bLon: number,
|
||||
bLat: number,
|
||||
): number {
|
||||
const cosLat = Math.cos((pLat * Math.PI) / 180);
|
||||
const ax = (aLon - pLon) * cosLat;
|
||||
const ay = aLat - pLat;
|
||||
const bx = (bLon - pLon) * cosLat;
|
||||
const by = bLat - pLat;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
|
||||
let closeLon: number;
|
||||
let closeLat: number;
|
||||
|
||||
if (lenSq < 1e-20) {
|
||||
closeLon = aLon;
|
||||
closeLat = aLat;
|
||||
} else {
|
||||
const t = Math.max(0, Math.min(1, (-ax * dx + -ay * dy) / lenSq));
|
||||
closeLon = aLon + (bLon - aLon) * t;
|
||||
closeLat = aLat + (bLat - aLat) * t;
|
||||
}
|
||||
|
||||
return haversineNm(pLat, pLon, closeLat, closeLon);
|
||||
}
|
||||
|
||||
/** 클릭 지점에서 영해기선 폴리곤까지의 최단거리 (NM) — 폴리곤 내부이면 0 */
|
||||
export function estimateDistanceFromCoast(lat: number, lon: number): number {
|
||||
if (!cachedBaselineRings) return 0;
|
||||
|
||||
// 영해기선 폴리곤 내부이면 거리 0
|
||||
if (cachedBaselineRings.some((ring) => pointInRing(lon, lat, ring))) return 0;
|
||||
|
||||
let minDist = Infinity;
|
||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
||||
const dist = haversineNm(lat, lon, cLat, cLon);
|
||||
if (dist < minDist) minDist = dist;
|
||||
for (const ring of cachedBaselineRings) {
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const dist = pointToSegmentNm(
|
||||
lat,
|
||||
lon,
|
||||
ring[i][0],
|
||||
ring[i][1],
|
||||
ring[i + 1][0],
|
||||
ring[i + 1][1],
|
||||
);
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/**
|
||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
||||
*/
|
||||
function offsetCoastline(
|
||||
points: [number, number][],
|
||||
distanceNm: number,
|
||||
outwardSign: number = 1,
|
||||
): [number, number][] {
|
||||
const degPerNm = 1 / 60;
|
||||
const result: [number, number][] = [];
|
||||
// ── 구역 판별 (Point-in-Polygon) ──
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const prev = points[(i - 1 + points.length) % points.length];
|
||||
const curr = points[i];
|
||||
const next = points[(i + 1) % points.length];
|
||||
/** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
|
||||
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
|
||||
const cosLat = Math.cos((curr[0] * Math.PI) / 180);
|
||||
const dx0 = (curr[1] - prev[1]) * cosLat;
|
||||
const dy0 = curr[0] - prev[0];
|
||||
const dx1 = (next[1] - curr[1]) * cosLat;
|
||||
const dy1 = next[0] - curr[0];
|
||||
|
||||
let nx = -(dy0 + dy1) / 2;
|
||||
let ny = (dx0 + dx1) / 2;
|
||||
const len = Math.sqrt(nx * nx + ny * ny) || 1;
|
||||
nx /= len;
|
||||
ny /= len;
|
||||
|
||||
const latOff = outwardSign * nx * distanceNm * degPerNm;
|
||||
const lonOff = (outwardSign * ny * distanceNm * degPerNm) / cosLat;
|
||||
|
||||
result.push([curr[0] + latOff, curr[1] + lonOff]);
|
||||
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 점이 MultiPolygon의 어느 폴리곤에든 포함되는지 */
|
||||
function pointInZone(lon: number, lat: number, rings: [number, number][][]): boolean {
|
||||
return rings.some((ring) => pointInRing(lon, lat, ring));
|
||||
}
|
||||
|
||||
/**
|
||||
* 클릭 위치가 어느 구역에 포함되는지 판별
|
||||
* @returns 0=~3해리, 1=3~12해리, 2=12~25해리, 3=25~50해리, 4=50해리+
|
||||
*/
|
||||
export function determineZone(lat: number, lon: number): number {
|
||||
if (!cachedZones) return 4;
|
||||
|
||||
// 작은 구역부터 검사 (3 → 12 → 25 → 50)
|
||||
const sortedZones = [...cachedZones].sort((a, b) => a.nm - b.nm);
|
||||
|
||||
for (let i = 0; i < sortedZones.length; i++) {
|
||||
if (pointInZone(lon, lat, sortedZones[i].rings)) {
|
||||
return i; // 0=3nm 내, 1=12nm 내, 2=25nm 내, 3=50nm 내
|
||||
}
|
||||
}
|
||||
return 4; // 50해리+
|
||||
}
|
||||
|
||||
// ── 구역 경계선 생성 ──
|
||||
|
||||
export interface ZoneLine {
|
||||
path: [number, number][];
|
||||
path: [number, number][]; // [lon, lat]
|
||||
color: [number, number, number, number];
|
||||
label: string;
|
||||
distanceNm: number;
|
||||
}
|
||||
|
||||
const ZONE_STYLES: { nm: number; color: [number, number, number, number]; label: string }[] = [
|
||||
{ nm: 3, color: [239, 68, 68, 180], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140], label: '25해리' },
|
||||
{ nm: 50, color: [100, 116, 139, 120], label: '50해리' },
|
||||
];
|
||||
|
||||
export function getDischargeZoneLines(): ZoneLine[] {
|
||||
const zones = [
|
||||
{ nm: 3, color: [239, 68, 68, 180] as [number, number, number, number], label: '3해리' },
|
||||
{ nm: 12, color: [249, 115, 22, 160] as [number, number, number, number], label: '12해리' },
|
||||
{ nm: 25, color: [234, 179, 8, 140] as [number, number, number, number], label: '25해리' },
|
||||
];
|
||||
if (!cachedZones) return [];
|
||||
|
||||
const lines: ZoneLine[] = [];
|
||||
for (const zone of zones) {
|
||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1);
|
||||
lines.push({
|
||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: zone.label,
|
||||
distanceNm: zone.nm,
|
||||
});
|
||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1);
|
||||
lines.push({
|
||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
||||
color: zone.color,
|
||||
label: `${zone.label} (제주)`,
|
||||
distanceNm: zone.nm,
|
||||
});
|
||||
for (const zone of cachedZones) {
|
||||
const style = ZONE_STYLES.find((s) => s.nm === zone.nm);
|
||||
if (!style) continue;
|
||||
|
||||
for (let i = 0; i < zone.rings.length; i++) {
|
||||
lines.push({
|
||||
path: zone.rings[i],
|
||||
color: style.color,
|
||||
label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
|
||||
distanceNm: style.nm,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { LayerTree } from '@common/components/layer/LayerTree';
|
||||
import { useLayerTree } from '@common/hooks/useLayers';
|
||||
import type { Layer } from '@common/services/layerService';
|
||||
@ -12,6 +11,8 @@ interface InfoLayerSectionProps {
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
}
|
||||
|
||||
const InfoLayerSection = ({
|
||||
@ -23,12 +24,12 @@ const InfoLayerSection = ({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
}: InfoLayerSectionProps) => {
|
||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||
const { data: layerTree, isLoading } = useLayerTree();
|
||||
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||
|
||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||
|
||||
@ -134,7 +135,7 @@ const InfoLayerSection = ({
|
||||
enabledLayers={enabledLayers}
|
||||
onToggleLayer={onToggleLayer}
|
||||
layerColors={layerColors}
|
||||
onColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
onColorChange={onLayerColorChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -108,6 +108,8 @@ export function LeftPanel({
|
||||
onLayerOpacityChange,
|
||||
layerBrightness,
|
||||
onLayerBrightnessChange,
|
||||
layerColors,
|
||||
onLayerColorChange,
|
||||
sensitiveResources = [],
|
||||
onImageAnalysisResult,
|
||||
validationErrors,
|
||||
@ -345,6 +347,8 @@ export function LeftPanel({
|
||||
onLayerOpacityChange={onLayerOpacityChange}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={onLayerBrightnessChange}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={onLayerColorChange}
|
||||
/>
|
||||
|
||||
{/* Oil Boom Placement Guide Section */}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -216,9 +216,10 @@ export function OilSpillView() {
|
||||
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]);
|
||||
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null);
|
||||
|
||||
// 레이어 스타일 (투명도 / 밝기)
|
||||
// 레이어 스타일 (투명도 / 밝기 / 색상)
|
||||
const [layerOpacity, setLayerOpacity] = useState(50);
|
||||
const [layerBrightness, setLayerBrightness] = useState(50);
|
||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||
|
||||
// 표시 정보 제어
|
||||
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||
@ -1200,6 +1201,8 @@ export function OilSpillView() {
|
||||
onLayerOpacityChange={setLayerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
onLayerBrightnessChange={setLayerBrightness}
|
||||
layerColors={layerColors}
|
||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||
sensitiveResources={sensitiveResourceCategories}
|
||||
onImageAnalysisResult={handleImageAnalysisResult}
|
||||
validationErrors={validationErrors}
|
||||
@ -1236,11 +1239,11 @@ export function OilSpillView() {
|
||||
drawingPoints={drawingPoints}
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
layerColors={layerColors}
|
||||
sensitiveResources={sensitiveResources}
|
||||
sensitiveResourceGeojson={
|
||||
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
|
||||
}
|
||||
lightMode
|
||||
centerPoints={centerPoints.filter((p) =>
|
||||
visibleModels.has((p.model || 'OpenDrift') as PredictionModel),
|
||||
)}
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -54,6 +54,8 @@ export interface LeftPanelProps {
|
||||
onLayerOpacityChange: (val: number) => void;
|
||||
layerBrightness: number;
|
||||
onLayerBrightnessChange: (val: number) => void;
|
||||
layerColors: Record<string, string>;
|
||||
onLayerColorChange: (layerId: string, color: string) => void;
|
||||
// 영향 민감자원
|
||||
sensitiveResources?: SensitiveResourceCategory[];
|
||||
// 이미지 분석 결과 콜백
|
||||
|
||||
@ -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)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user