Merge pull request 'release: 2026-04-09 (247건 커밋)' (#163) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 37s
This commit is contained in:
커밋
cc3e0c5596
19
CLAUDE.md
19
CLAUDE.md
@ -125,6 +125,25 @@ wing/
|
|||||||
- API 인터페이스 변경 시 `memory/api-types.md` 갱신
|
- 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 사용)
|
- Node.js 20 (`.node-version`, fnm 사용)
|
||||||
|
|||||||
@ -219,9 +219,9 @@ export async function createAnalysis(input: {
|
|||||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||||
ANALYST_NM, EXEC_STTS_CD
|
ANALYST_NM, EXEC_STTS_CD
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4::numeric, $5::numeric,
|
||||||
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 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 || ' + ' || $5 END,
|
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4::text || ' + ' || $5::text END,
|
||||||
$6, $7, $8, $9, $10, $11,
|
$6, $7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15, $16,
|
$12, $13, $14, $15, $16,
|
||||||
$17, 'PENDING'
|
$17, 'PENDING'
|
||||||
|
|||||||
@ -54,6 +54,28 @@ router.get('/vworld/:z/:y/:x', async (req, res) => {
|
|||||||
await proxyUpstream(tileUrl, res, 'image/jpeg');
|
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) ───
|
// ─── S-57 전자해도 (ENC) ───
|
||||||
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
// tiles.gcnautical.com CORS 제한 우회를 위한 프록시 엔드포인트 그룹
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [2026-04-02]
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 */}
|
{/* User ID */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label
|
<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' }}
|
style={{ letterSpacing: '0.3px' }}
|
||||||
>
|
>
|
||||||
아이디
|
아이디
|
||||||
@ -147,7 +147,7 @@ export function LoginPage() {
|
|||||||
placeholder="사용자 아이디 입력"
|
placeholder="사용자 아이디 입력"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
autoFocus
|
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={{
|
style={{
|
||||||
padding: '11px 14px 11px 38px',
|
padding: '11px 14px 11px 38px',
|
||||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||||
@ -167,7 +167,7 @@ export function LoginPage() {
|
|||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<label
|
<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' }}
|
style={{ letterSpacing: '0.3px' }}
|
||||||
>
|
>
|
||||||
비밀번호
|
비밀번호
|
||||||
@ -200,7 +200,7 @@ export function LoginPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder="비밀번호 입력"
|
placeholder="비밀번호 입력"
|
||||||
autoComplete="current-password"
|
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={{
|
style={{
|
||||||
padding: '11px 14px 11px 38px',
|
padding: '11px 14px 11px 38px',
|
||||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||||
@ -219,7 +219,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* Remember + Forgot */}
|
{/* Remember + Forgot */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={remember}
|
checked={remember}
|
||||||
@ -230,7 +230,7 @@ export function LoginPage() {
|
|||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="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')}
|
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
||||||
>
|
>
|
||||||
@ -241,7 +241,7 @@ export function LoginPage() {
|
|||||||
{/* Pending approval */}
|
{/* Pending approval */}
|
||||||
{pendingMessage && (
|
{pendingMessage && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
background: 'rgba(6,182,212,0.08)',
|
background: 'rgba(6,182,212,0.08)',
|
||||||
@ -271,7 +271,7 @@ export function LoginPage() {
|
|||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
background: 'rgba(239,68,68,0.08)',
|
background: 'rgba(239,68,68,0.08)',
|
||||||
@ -279,7 +279,7 @@ export function LoginPage() {
|
|||||||
color: '#f87171',
|
color: '#f87171',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-[13px]">
|
<span className="text-title-4">
|
||||||
<svg
|
<svg
|
||||||
width="13"
|
width="13"
|
||||||
height="13"
|
height="13"
|
||||||
@ -353,7 +353,7 @@ export function LoginPage() {
|
|||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="flex items-center gap-3 my-6">
|
<div className="flex items-center gap-3 my-6">
|
||||||
<div className="flex-1 bg-border h-px" />
|
<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 className="flex-1 bg-border h-px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -375,7 +375,7 @@ export function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="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' }}
|
style={{ transition: 'background 0.15s' }}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-surface-hover)')}
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-surface-hover)')}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--bg-card)')}
|
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)',
|
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]">
|
<div className="flex flex-col gap-[3px]">
|
||||||
{DEMO_ACCOUNTS.map((acc) => (
|
{DEMO_ACCOUNTS.map((acc) => (
|
||||||
<div
|
<div
|
||||||
@ -427,10 +427,10 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
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}
|
{acc.id} / {acc.password}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] text-fg-disabled">{acc.label}</span>
|
<span className="text-caption text-fg-disabled">{acc.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -440,7 +440,7 @@ export function LoginPage() {
|
|||||||
{/* end form card */}
|
{/* end form card */}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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>WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템</div>
|
||||||
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
|
<div className="mt-0.5" style={{ color: 'rgba(134,144,166,0.6)' }}>
|
||||||
© 2026 Korea Coast Guard. All rights reserved.
|
© 2026 Korea Coast Guard. All rights reserved.
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export function LayerTree({
|
|||||||
return (
|
return (
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<div className="flex items-center justify-between px-2 pt-1 pb-2 mb-1 border-b border-stroke">
|
<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
|
<div
|
||||||
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
|
className={`lyr-sw ${allEnabled ? 'on' : ''} cursor-pointer`}
|
||||||
onClick={handleToggleAll}
|
onClick={handleToggleAll}
|
||||||
@ -260,7 +260,7 @@ function LayerNode({
|
|||||||
<div>
|
<div>
|
||||||
<div className="lyr-t gap-1.5">
|
<div className="lyr-t gap-1.5">
|
||||||
<span
|
<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}
|
onClick={handleHeaderClick}
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
|
|||||||
@ -216,7 +216,7 @@ export function BacktrackReplayBar({
|
|||||||
{/* Collision marker */}
|
{/* Collision marker */}
|
||||||
{collisionEvent && (
|
{collisionEvent && (
|
||||||
<div
|
<div
|
||||||
className="absolute text-[10px] cursor-pointer"
|
className="absolute text-caption cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
top: '-14px',
|
top: '-14px',
|
||||||
left: `${collisionEvent.progressPercent}%`,
|
left: `${collisionEvent.progressPercent}%`,
|
||||||
@ -244,7 +244,7 @@ export function BacktrackReplayBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time labels */}
|
{/* 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="text-fg-disabled">{startLabel}</span>
|
||||||
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
<span className="font-semibold text-color-tertiary">{currentTimeLabel}</span>
|
||||||
<span className="text-fg-disabled">{endLabel}</span>
|
<span className="text-fg-disabled">{endLabel}</span>
|
||||||
@ -257,13 +257,13 @@ export function BacktrackReplayBar({
|
|||||||
{replayShips.map((ship) => (
|
{replayShips.map((ship) => (
|
||||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasBackwardParticles && (
|
{hasBackwardParticles && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: '#a855f7', opacity: 0.8 }} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useMap } from '@vis.gl/react-maplibre';
|
import { useMap } from '@vis.gl/react-maplibre';
|
||||||
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi';
|
||||||
|
import { useThemeStore } from '@common/store/themeStore';
|
||||||
|
|
||||||
interface HydrParticleOverlayProps {
|
interface HydrParticleOverlayProps {
|
||||||
hydrStep: HydrDataStep | null;
|
hydrStep: HydrDataStep | null;
|
||||||
lightMode?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PARTICLE_COUNT = 3000;
|
const PARTICLE_COUNT = 3000;
|
||||||
@ -25,10 +25,8 @@ interface Particle {
|
|||||||
age: number;
|
age: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HydrParticleOverlay({
|
export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) {
|
||||||
hydrStep,
|
const lightMode = useThemeStore((s) => s.theme) === 'light';
|
||||||
lightMode = false,
|
|
||||||
}: HydrParticleOverlayProps) {
|
|
||||||
const { current: map } = useMap();
|
const { current: map } = useMap();
|
||||||
const animRef = useRef<number>();
|
const animRef = useRef<number>();
|
||||||
|
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -26,7 +26,7 @@ export function MeasureOverlay() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeMeasurement(mk.id);
|
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>
|
</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>{displayText}</span>
|
||||||
<span
|
<span
|
||||||
className="text-[8px] text-fg-disabled"
|
className="text-caption text-fg-disabled"
|
||||||
style={{
|
style={{
|
||||||
transition: 'transform 0.2s',
|
transition: 'transform 0.2s',
|
||||||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
@ -67,7 +67,7 @@ export function ComboBox({ value, onChange, options, placeholder, className }: C
|
|||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-[11px] cursor-pointer"
|
className="text-label-2 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
color: option.value === String(value) ? 'var(--color-accent)' : 'var(--fg-sub)',
|
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">
|
<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 사용자 매뉴얼
|
Wing 사용자 매뉴얼
|
||||||
</span>
|
</span>
|
||||||
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.12)',
|
background: 'rgba(6,182,212,0.12)',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
@ -1141,7 +1141,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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' }}
|
style={{ color: '#94a3b8', background: 'transparent' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.background = '#1a2540';
|
e.currentTarget.style.background = '#1a2540';
|
||||||
@ -1194,7 +1194,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
|
background: isActive ? 'rgba(6,182,212,0.18)' : 'rgba(255,255,255,0.05)',
|
||||||
color: isActive ? '#06b6d4' : '#64748b',
|
color: isActive ? '#06b6d4' : '#64748b',
|
||||||
@ -1205,13 +1205,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<div
|
||||||
className="text-[12px] font-medium leading-tight truncate"
|
className="text-label-1 font-medium leading-tight truncate"
|
||||||
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
|
style={{ color: isActive ? '#06b6d4' : '#cbd5e1' }}
|
||||||
>
|
>
|
||||||
{chapter.title}
|
{chapter.title}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] leading-tight mt-0.5 truncate"
|
className="text-caption leading-tight mt-0.5 truncate"
|
||||||
style={{ color: '#475569' }}
|
style={{ color: '#475569' }}
|
||||||
>
|
>
|
||||||
{chapter.subtitle}
|
{chapter.subtitle}
|
||||||
@ -1230,7 +1230,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.12)',
|
background: 'rgba(6,182,212,0.12)',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
@ -1239,20 +1239,20 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
>
|
>
|
||||||
CH {selectedChapter.number}
|
CH {selectedChapter.number}
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-[16px] font-semibold" style={{ color: '#e2e8f0' }}>
|
<h2 className="text-title-2 font-semibold" style={{ color: '#e2e8f0' }}>
|
||||||
{selectedChapter.title}
|
{selectedChapter.title}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[12px]" style={{ color: '#475569' }}>
|
<span className="text-label-1" style={{ color: '#475569' }}>
|
||||||
{selectedChapter.subtitle}
|
{selectedChapter.subtitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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}개 화면
|
{selectedChapter.screens.length}개 화면
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={allExpanded ? collapseAll : expandAll}
|
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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.08)',
|
background: 'rgba(6,182,212,0.08)',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
@ -1298,7 +1298,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.1)',
|
background: 'rgba(6,182,212,0.1)',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
@ -1310,13 +1310,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{screen.id}
|
{screen.id}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="flex-1 text-[13px] font-medium"
|
className="flex-1 text-title-4 font-medium"
|
||||||
style={{ color: '#cbd5e1' }}
|
style={{ color: '#cbd5e1' }}
|
||||||
>
|
>
|
||||||
{screen.name}
|
{screen.name}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0 text-[10px] font-mono"
|
className="flex-shrink-0 text-caption font-mono"
|
||||||
style={{
|
style={{
|
||||||
color: '#475569',
|
color: '#475569',
|
||||||
transition: 'transform 0.2s',
|
transition: 'transform 0.2s',
|
||||||
@ -1346,14 +1346,17 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
display: 'block',
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu path breadcrumb */}
|
{/* Menu path breadcrumb */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(71,85,105,0.15)',
|
background: 'rgba(71,85,105,0.15)',
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
@ -1365,7 +1368,10 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
|
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<div className="mt-2">
|
<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}
|
{screen.overview}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -1380,13 +1386,13 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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' }}
|
style={{ color: '#475569' }}
|
||||||
>
|
>
|
||||||
화면 설명
|
화면 설명
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="text-[12px] leading-relaxed"
|
className="text-label-1 leading-relaxed"
|
||||||
style={{ color: '#7f8ea3' }}
|
style={{ color: '#7f8ea3' }}
|
||||||
>
|
>
|
||||||
{screen.description}
|
{screen.description}
|
||||||
@ -1398,7 +1404,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{screen.procedure && screen.procedure.length > 0 && (
|
{screen.procedure && screen.procedure.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div
|
<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' }}
|
style={{ color: '#475569' }}
|
||||||
>
|
>
|
||||||
사용 절차
|
사용 절차
|
||||||
@ -1407,7 +1413,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{screen.procedure.map((step, idx) => (
|
{screen.procedure.map((step, idx) => (
|
||||||
<li key={idx} className="flex items-start gap-2.5">
|
<li key={idx} className="flex items-start gap-2.5">
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.12)',
|
background: 'rgba(6,182,212,0.12)',
|
||||||
color: '#06b6d4',
|
color: '#06b6d4',
|
||||||
@ -1417,7 +1423,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{idx + 1}
|
{idx + 1}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="text-[12px] leading-relaxed"
|
className="text-label-1 leading-relaxed"
|
||||||
style={{ color: '#94a3b8' }}
|
style={{ color: '#94a3b8' }}
|
||||||
>
|
>
|
||||||
{step}
|
{step}
|
||||||
@ -1432,7 +1438,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{screen.inputs && screen.inputs.length > 0 && (
|
{screen.inputs && screen.inputs.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div
|
<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' }}
|
style={{ color: '#475569' }}
|
||||||
>
|
>
|
||||||
입력 항목
|
입력 항목
|
||||||
@ -1441,7 +1447,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
className="rounded overflow-hidden"
|
className="rounded overflow-hidden"
|
||||||
style={{ border: '1px solid #1e2a45' }}
|
style={{ border: '1px solid #1e2a45' }}
|
||||||
>
|
>
|
||||||
<table className="w-full text-[12px]">
|
<table className="w-full text-label-1">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#0f1729' }}>
|
<tr style={{ background: '#0f1729' }}>
|
||||||
<th
|
<th
|
||||||
@ -1494,7 +1500,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{input.required ? (
|
{input.required ? (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: 'rgba(239,68,68,0.1)',
|
background: 'rgba(239,68,68,0.1)',
|
||||||
color: '#f87171',
|
color: '#f87171',
|
||||||
@ -1505,7 +1511,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
className="text-caption px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(100,116,139,0.1)',
|
background: 'rgba(100,116,139,0.1)',
|
||||||
color: '#64748b',
|
color: '#64748b',
|
||||||
@ -1531,7 +1537,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
{screen.notes && screen.notes.length > 0 && (
|
{screen.notes && screen.notes.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div
|
<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' }}
|
style={{ color: '#475569' }}
|
||||||
>
|
>
|
||||||
유의사항
|
유의사항
|
||||||
@ -1544,7 +1550,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
style={{ background: '#f59e0b' }}
|
style={{ background: '#f59e0b' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-[12px] leading-relaxed"
|
className="text-label-1 leading-relaxed"
|
||||||
style={{ color: '#94a3b8' }}
|
style={{ color: '#94a3b8' }}
|
||||||
>
|
>
|
||||||
{note}
|
{note}
|
||||||
@ -1590,7 +1596,7 @@ const UserManualPopup = ({ isOpen, onClose }: UserManualPopupProps) => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setLightboxSrc(null)}
|
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={{
|
style={{
|
||||||
background: 'rgba(15,23,41,0.85)',
|
background: 'rgba(15,23,41,0.85)',
|
||||||
color: '#94a3b8',
|
color: '#94a3b8',
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
import type { StyleSpecification } from 'maplibre-gl';
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
import { useMapStore } from '@common/store/mapStore';
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import {
|
import { LIGHT_STYLE, SATELLITE_3D_STYLE, ENC_EMPTY_STYLE } from '@common/components/map/mapStyles';
|
||||||
BASE_STYLE,
|
|
||||||
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);
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
|
|
||||||
if (mapToggles.s57) return ENC_EMPTY_STYLE;
|
if (mapToggles.s57) return ENC_EMPTY_STYLE;
|
||||||
if (mapToggles.threeD) return SATELLITE_3D_STYLE;
|
if (mapToggles.threeD) return SATELLITE_3D_STYLE;
|
||||||
if (lightMode) return LIGHT_STYLE;
|
return LIGHT_STYLE;
|
||||||
return BASE_STYLE;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand';
|
||||||
import { api } from '../services/api'
|
import { api } from '../services/api';
|
||||||
import { haversineDistance, polygonAreaKm2 } from '../utils/geo'
|
import { haversineDistance, polygonAreaKm2 } from '../utils/geo';
|
||||||
|
|
||||||
export interface MapTypeItem {
|
export interface MapTypeItem {
|
||||||
mapKey: string;
|
mapKey: string;
|
||||||
@ -46,11 +46,11 @@ interface MapState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
const DEFAULT_MAP_TYPES: MapTypeItem[] = [
|
||||||
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
|
{ mapKey: 's57', mapNm: 'S-57 전자해도', mapLevelCd: 'S-57' },
|
||||||
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
{ mapKey: 's101', mapNm: 'S-101 전자해도', mapLevelCd: 'S-101' },
|
||||||
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
{ mapKey: 'threeD', mapNm: '3D 지도', mapLevelCd: '3D' },
|
||||||
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
{ mapKey: 'satellite', mapNm: '위성 영상', mapLevelCd: 'SAT' },
|
||||||
]
|
];
|
||||||
|
|
||||||
let measureIdCounter = 0;
|
let measureIdCounter = 0;
|
||||||
|
|
||||||
@ -67,17 +67,17 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
loadMapTypes: async () => {
|
loadMapTypes: async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<MapTypeItem[]>('/map-base/active')
|
const res = await api.get<MapTypeItem[]>('/map-base/active');
|
||||||
const types = res.data
|
const types = res.data;
|
||||||
const current = get().mapToggles
|
const current = get().mapToggles;
|
||||||
const newToggles: Partial<MapToggles> = {}
|
const newToggles: Partial<MapToggles> = {};
|
||||||
for (const t of types) {
|
for (const t of types) {
|
||||||
if (t.mapKey in current) {
|
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 (기본지도 표시)
|
// 모든 토글 기본 off (기본지도 표시)
|
||||||
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } })
|
set({ mapTypes: types, mapToggles: { ...current, ...newToggles } });
|
||||||
} catch {
|
} catch {
|
||||||
// API 실패 시 fallback 유지
|
// API 실패 시 fallback 유지
|
||||||
}
|
}
|
||||||
@ -88,8 +88,7 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
measureInProgress: [],
|
measureInProgress: [],
|
||||||
measurements: [],
|
measurements: [],
|
||||||
|
|
||||||
setMeasureMode: (mode) =>
|
setMeasureMode: (mode) => set({ measureMode: mode, measureInProgress: [] }),
|
||||||
set({ measureMode: mode, measureInProgress: [] }),
|
|
||||||
|
|
||||||
addMeasurePoint: (pt) => {
|
addMeasurePoint: (pt) => {
|
||||||
const { measureMode, measureInProgress } = get();
|
const { measureMode, measureInProgress } = get();
|
||||||
@ -99,7 +98,10 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
const dist = haversineDistance(next[0], next[1]);
|
const dist = haversineDistance(next[0], next[1]);
|
||||||
const id = `measure-${++measureIdCounter}`;
|
const id = `measure-${++measureIdCounter}`;
|
||||||
set((s) => ({
|
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: [],
|
measureInProgress: [],
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
@ -116,7 +118,10 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
const area = polygonAreaKm2(measureInProgress);
|
const area = polygonAreaKm2(measureInProgress);
|
||||||
const id = `measure-${++measureIdCounter}`;
|
const id = `measure-${++measureIdCounter}`;
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
measurements: [...s.measurements, { id, mode: 'area', points: [...measureInProgress], value: area }],
|
measurements: [
|
||||||
|
...s.measurements,
|
||||||
|
{ id, mode: 'area', points: [...measureInProgress], value: area },
|
||||||
|
],
|
||||||
measureInProgress: [],
|
measureInProgress: [],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@ -124,6 +129,5 @@ export const useMapStore = create<MapState>((set, get) => ({
|
|||||||
removeMeasurement: (id) =>
|
removeMeasurement: (id) =>
|
||||||
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
|
set((s) => ({ measurements: s.measurements.filter((m) => m.id !== id) })),
|
||||||
|
|
||||||
clearAllMeasurements: () =>
|
clearAllMeasurements: () => set({ measurements: [], measureInProgress: [], measureMode: null }),
|
||||||
set({ measurements: [], measureInProgress: [], measureMode: null }),
|
}));
|
||||||
}))
|
|
||||||
|
|||||||
@ -77,6 +77,7 @@
|
|||||||
--font-size-heading-2: 1.5rem;
|
--font-size-heading-2: 1.5rem;
|
||||||
--font-size-heading-3: 1.375rem;
|
--font-size-heading-3: 1.375rem;
|
||||||
--font-size-title-1: 1.125rem;
|
--font-size-title-1: 1.125rem;
|
||||||
|
--font-size-subtitle: 0.9375rem;
|
||||||
--font-size-title-2: 1rem;
|
--font-size-title-2: 1rem;
|
||||||
--font-size-title-3: 0.875rem;
|
--font-size-title-3: 0.875rem;
|
||||||
--font-size-title-4: 0.8125rem;
|
--font-size-title-4: 0.8125rem;
|
||||||
|
|||||||
@ -41,34 +41,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wing-section-header {
|
.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 {
|
.wing-section-desc {
|
||||||
@apply text-[10px] font-korean leading-relaxed;
|
@apply text-caption font-korean leading-relaxed;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Typography ── */
|
/* ── Typography ── */
|
||||||
.wing-title {
|
.wing-title {
|
||||||
@apply text-[15px] font-bold font-korean;
|
@apply text-subtitle font-bold font-korean;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wing-subtitle {
|
.wing-subtitle {
|
||||||
@apply text-[10px] font-korean mt-0.5;
|
@apply text-caption font-korean mt-0.5;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wing-label {
|
.wing-label {
|
||||||
@apply text-[11px] font-semibold font-korean;
|
@apply text-label-2 font-semibold font-korean;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wing-value {
|
.wing-value {
|
||||||
@apply text-[11px] font-mono font-semibold;
|
@apply text-label-2 font-mono font-semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wing-meta {
|
.wing-meta {
|
||||||
@apply text-[9px] font-korean;
|
@apply text-caption font-korean;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,12 +83,12 @@
|
|||||||
|
|
||||||
/* ── Badge ── */
|
/* ── Badge ── */
|
||||||
.wing-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 ── */
|
/* ── Button ── */
|
||||||
.wing-btn {
|
.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;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +134,7 @@
|
|||||||
|
|
||||||
/* ── Input ── */
|
/* ── Input ── */
|
||||||
.wing-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;
|
padding: 6px 10px;
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
border: 1px solid var(--stroke-default);
|
border: 1px solid var(--stroke-default);
|
||||||
@ -151,7 +151,7 @@
|
|||||||
|
|
||||||
/* ── Table ── */
|
/* ── Table ── */
|
||||||
.wing-table {
|
.wing-table {
|
||||||
@apply w-full text-[10px] font-korean;
|
@apply w-full text-caption font-korean;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,11 +232,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wing-kv-label {
|
.wing-kv-label {
|
||||||
@apply text-[10px] font-korean;
|
@apply text-caption font-korean;
|
||||||
color: var(--fg-disabled);
|
color: var(--fg-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wing-kv-value {
|
.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 }}
|
style={{ opacity: 0.4 }}
|
||||||
>
|
>
|
||||||
<span
|
<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' }}
|
style={{ letterSpacing: '1px' }}
|
||||||
>
|
>
|
||||||
© 2024 WING-OPS 해상 시스템
|
© 2024 WING-OPS 해상 시스템
|
||||||
</span>
|
</span>
|
||||||
<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' }}
|
style={{ letterSpacing: '1px' }}
|
||||||
>
|
>
|
||||||
전술 네비게이터 쉘 v2.4
|
전술 네비게이터 쉘 v2.4
|
||||||
|
|||||||
@ -29,13 +29,12 @@ const ButtonsThumbnail = ({ isDark }: { isDark: boolean }) => {
|
|||||||
{buttons.map(({ label, bg, border, color }) => (
|
{buttons.map(({ label, bg, border, color }) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={label}
|
||||||
className="w-full rounded flex items-center justify-center"
|
className="w-full rounded flex items-center justify-center text-label-1"
|
||||||
style={{
|
style={{
|
||||||
height: '32px',
|
height: '32px',
|
||||||
backgroundColor: bg,
|
backgroundColor: bg,
|
||||||
border: `1.5px solid ${border}`,
|
border: `1.5px solid ${border}`,
|
||||||
color,
|
color,
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
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[] = [
|
const OVERVIEW_CARDS: OverviewCard[] = [
|
||||||
@ -125,6 +227,11 @@ const OVERVIEW_CARDS: OverviewCard[] = [
|
|||||||
label: 'Text Field',
|
label: 'Text Field',
|
||||||
thumbnail: (isDark) => <TextInputsThumbnail isDark={isDark} />,
|
thumbnail: (isDark) => <TextInputsThumbnail isDark={isDark} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'float',
|
||||||
|
label: 'Float',
|
||||||
|
thumbnail: (isDark) => <FloatThumbnail isDark={isDark} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------- Props ----------
|
// ---------- Props ----------
|
||||||
|
|||||||
@ -32,7 +32,7 @@ const SectionTitle = ({ num, title, sub, rightNode, theme }: SectionTitleProps)
|
|||||||
</div>
|
</div>
|
||||||
{sub && (
|
{sub && (
|
||||||
<p
|
<p
|
||||||
className="font-mono text-[10px] leading-[15px] uppercase"
|
className="font-mono text-caption leading-[15px] uppercase"
|
||||||
style={{ letterSpacing: theme.sectionSubSpacing, color: theme.sectionSub }}
|
style={{ letterSpacing: theme.sectionSubSpacing, color: theme.sectionSub }}
|
||||||
>
|
>
|
||||||
{sub}
|
{sub}
|
||||||
@ -46,7 +46,7 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
{
|
{
|
||||||
size: '9px / Meta',
|
size: '9px / Meta',
|
||||||
sampleNode: (t) => (
|
sampleNode: (t) => (
|
||||||
<span className="font-korean text-[9px]" style={{ color: t.typoSampleText }}>
|
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
|
||||||
메타정보 Meta info
|
메타정보 Meta info
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -55,7 +55,7 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
{
|
{
|
||||||
size: '10px / Table',
|
size: '10px / Table',
|
||||||
sampleNode: (t) => (
|
sampleNode: (t) => (
|
||||||
<span className="font-korean text-[10px]" style={{ color: t.typoSampleText }}>
|
<span className="font-korean text-caption" style={{ color: t.typoSampleText }}>
|
||||||
테이블 데이터 Table data
|
테이블 데이터 Table data
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -68,7 +68,7 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
className="inline-flex items-center rounded-md border border-solid py-2 px-4"
|
className="inline-flex items-center rounded-md border border-solid py-2 px-4"
|
||||||
style={{ backgroundColor: t.typoActionBg, borderColor: t.typoActionBorder }}
|
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
|
입력/버튼 Input/Button text
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -78,7 +78,7 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
{
|
{
|
||||||
size: '13px / Header',
|
size: '13px / Header',
|
||||||
sampleNode: (t) => (
|
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
|
섹션 헤더 Section Header
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -87,7 +87,7 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
{
|
{
|
||||||
size: '15px / Title',
|
size: '15px / Title',
|
||||||
sampleNode: (t) => (
|
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
|
패널 타이틀 Panel Title
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@ -97,10 +97,10 @@ const TYPO_ROWS: TypoRow[] = [
|
|||||||
size: 'Data / Mono',
|
size: 'Data / Mono',
|
||||||
sampleNode: (t) => (
|
sampleNode: (t) => (
|
||||||
<div className="flex flex-col gap-1">
|
<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²
|
1,234.56 km²
|
||||||
</span>
|
</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
|
35° 06' 12" N
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -202,7 +202,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
{item.hex}
|
{item.hex}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-[11px] leading-[16.5px]"
|
className="font-korean text-label-2 leading-[16.5px]"
|
||||||
style={{ color: t.textSecondary }}
|
style={{ color: t.textSecondary }}
|
||||||
>
|
>
|
||||||
{item.desc}
|
{item.desc}
|
||||||
@ -229,7 +229,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
boxShadow: t.borderCardShadow,
|
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}
|
{item.token}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -259,7 +259,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
>
|
>
|
||||||
{t.textTokens.map((item) => (
|
{t.textTokens.map((item) => (
|
||||||
<div key={item.token} className="flex flex-col gap-[3px]">
|
<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}
|
{item.token}
|
||||||
</span>
|
</span>
|
||||||
<span className={item.sampleClass}>{item.sampleText}</span>
|
<span className={item.sampleClass}>{item.sampleText}</span>
|
||||||
@ -302,7 +302,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</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}
|
{item.token} / {item.color}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -316,7 +316,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-[11px] font-medium"
|
className="font-korean text-label-2 font-medium"
|
||||||
style={{ color: item.badgeText }}
|
style={{ color: item.badgeText }}
|
||||||
>
|
>
|
||||||
{item.badge}
|
{item.badge}
|
||||||
@ -348,12 +348,12 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-[13px] font-bold flex-1"
|
className="font-korean text-title-4 font-bold flex-1"
|
||||||
style={{ color: item.color }}
|
style={{ color: item.color }}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</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}
|
{item.hex}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -370,7 +370,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
rightNode={
|
rightNode={
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<span
|
<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 }}
|
style={{ backgroundColor: t.fontBadgePrimaryBg, color: t.fontBadgePrimaryText }}
|
||||||
>
|
>
|
||||||
PretendardGOV
|
PretendardGOV
|
||||||
@ -391,7 +391,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
style={{ textAlign: i === 2 ? 'right' : 'left', borderColor: t.tableRowBorder }}
|
style={{ textAlign: i === 2 ? 'right' : 'left', borderColor: t.tableRowBorder }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[10px] font-medium uppercase"
|
className="font-mono text-caption font-medium uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
>
|
>
|
||||||
{col}
|
{col}
|
||||||
@ -412,7 +412,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
>
|
>
|
||||||
{/* Size */}
|
{/* Size */}
|
||||||
<div className="flex-1 py-4 px-8">
|
<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}
|
{row.size}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -420,7 +420,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
|
<div className="flex-1 py-4 px-8">{row.sampleNode(t)}</div>
|
||||||
{/* Properties */}
|
{/* Properties */}
|
||||||
<div className="flex-1 py-4 px-8 text-right" style={{ opacity: 0.5 }}>
|
<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}
|
{row.properties}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -447,7 +447,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-[10px] font-bold uppercase"
|
className="font-korean text-caption font-bold uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.textAccent }}
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Small Elements
|
Small Elements
|
||||||
@ -475,7 +475,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-korean text-[10px] font-bold uppercase"
|
className="font-korean text-caption font-bold uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.textAccent }}
|
style={{ letterSpacing: '1px', color: t.textAccent }}
|
||||||
>
|
>
|
||||||
Structural Panels
|
Structural Panels
|
||||||
@ -503,7 +503,7 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
{['Precision Engineering', 'Safety Compliant', 'Optimized v8.42'].map((label) => (
|
{['Precision Engineering', 'Safety Compliant', 'Optimized v8.42'].map((label) => (
|
||||||
<span
|
<span
|
||||||
key={label}
|
key={label}
|
||||||
className="font-mono text-[10px] uppercase"
|
className="font-mono text-caption uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.footerText }}
|
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@ -513,13 +513,13 @@ export const DesignContent = ({ theme }: DesignContentProps) => {
|
|||||||
{/* 우측 */}
|
{/* 우측 */}
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<div className="flex flex-row gap-2 items-center">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[10px] uppercase"
|
className="font-mono text-caption uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.footerText }}
|
style={{ letterSpacing: '1px', color: t.footerText }}
|
||||||
>
|
>
|
||||||
Generated for Terminal:
|
Generated for Terminal:
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[10px] font-medium uppercase"
|
className="font-mono text-caption font-medium uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.footerAccent }}
|
style={{ letterSpacing: '1px', color: t.footerAccent }}
|
||||||
>
|
>
|
||||||
1440x900_PR_MKT
|
1440x900_PR_MKT
|
||||||
|
|||||||
@ -50,10 +50,10 @@ export const DesignHeader = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="font-sans text-[10px] leading-[15px] uppercase"
|
className="font-sans text-caption leading-[15px] uppercase"
|
||||||
style={{ letterSpacing: '2px', color: theme.textMuted }}
|
style={{ letterSpacing: '2px', color: theme.textMuted }}
|
||||||
>
|
>
|
||||||
Design System v1.0
|
Design System v1.1
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import FoundationsOverview from './FoundationsOverview';
|
|||||||
import ComponentsOverview from './ComponentsOverview';
|
import ComponentsOverview from './ComponentsOverview';
|
||||||
import { ButtonContent } from './ButtonContent';
|
import { ButtonContent } from './ButtonContent';
|
||||||
import { TextFieldContent } from './TextFieldContent';
|
import { TextFieldContent } from './TextFieldContent';
|
||||||
|
import { FloatContent } from './FloatContent';
|
||||||
import { getTheme } from './designTheme';
|
import { getTheme } from './designTheme';
|
||||||
import type { ThemeMode } from './designTheme';
|
import type { ThemeMode } from './designTheme';
|
||||||
|
|
||||||
@ -69,6 +70,8 @@ export const DesignPage = () => {
|
|||||||
return <ButtonContent theme={theme} />;
|
return <ButtonContent theme={theme} />;
|
||||||
case 'text-field':
|
case 'text-field':
|
||||||
return <TextFieldContent theme={theme} />;
|
return <TextFieldContent theme={theme} />;
|
||||||
|
case 'float':
|
||||||
|
return <FloatContent theme={theme} />;
|
||||||
default:
|
default:
|
||||||
return <ComponentsContent />;
|
return <ComponentsContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import type { DesignTheme } from './designTheme';
|
|||||||
import type { DesignTab } from './DesignHeader';
|
import type { DesignTab } from './DesignHeader';
|
||||||
|
|
||||||
export type FoundationsMenuItemId = 'overview' | 'color' | 'typography' | 'radius' | 'layout';
|
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;
|
export type MenuItemId = FoundationsMenuItemId | ComponentsMenuItemId;
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@ -22,6 +22,7 @@ const COMPONENTS_MENU: MenuItem[] = [
|
|||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'buttons', label: 'Buttons' },
|
{ id: 'buttons', label: 'Buttons' },
|
||||||
{ id: 'text-field', label: 'Text Field' },
|
{ id: 'text-field', label: 'Text Field' },
|
||||||
|
{ id: 'float', label: 'Float' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SIDEBAR_CONFIG: Record<DesignTab, { title: string; subtitle: string; menu: MenuItem[] }> = {
|
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) => (
|
{(['이름', '값', 'Preview'] as const).map((col) => (
|
||||||
<div key={col} className="py-3 px-4">
|
<div key={col} className="py-3 px-4">
|
||||||
<span
|
<span
|
||||||
className="font-mono text-[10px] font-medium uppercase"
|
className="font-mono text-caption font-medium uppercase"
|
||||||
style={{ letterSpacing: '1px', color: t.textMuted }}
|
style={{ letterSpacing: '1px', color: t.textMuted }}
|
||||||
>
|
>
|
||||||
{col}
|
{col}
|
||||||
@ -153,7 +153,7 @@ export const RadiusContent = ({ theme }: RadiusContentProps) => {
|
|||||||
</span>
|
</span>
|
||||||
{token.isCustom && (
|
{token.isCustom && (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
color: isDark ? '#f97316' : '#c2410c',
|
color: isDark ? '#f97316' : '#c2410c',
|
||||||
backgroundColor: isDark ? 'rgba(249,115,22,0.10)' : 'rgba(249,115,22,0.08)',
|
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">
|
<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}
|
{token.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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-sans text-lg font-bold" style={{ color: t.textPrimary }}>{font.name}</span>
|
||||||
<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={{
|
style={{
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
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>
|
<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-3 pt-2">
|
||||||
<div className="flex flex-col gap-1">
|
<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>
|
<span className="text-xl leading-7" style={{ color: t.textPrimary, fontWeight: 400 }}>{font.sampleText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<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>
|
<span className="text-xl leading-7 font-bold" style={{ color: t.textPrimary }}>{font.sampleText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -484,7 +484,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
{row.letterSpacing}
|
{row.letterSpacing}
|
||||||
</span>
|
</span>
|
||||||
<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={{
|
style={{
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
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}
|
{row.token}
|
||||||
</div>
|
</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}
|
{row.size} · {row.weight} · {row.lineHeight}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -767,7 +767,7 @@ export const TypographyContent = ({ theme }: TypographyContentProps) => {
|
|||||||
{row.value}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
<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={{
|
style={{
|
||||||
color: t.textAccent,
|
color: t.textAccent,
|
||||||
backgroundColor: isDark ? 'rgba(6,182,212,0.05)' : 'rgba(6,182,212,0.08)',
|
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%)',
|
'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>
|
||||||
</div>
|
</div>
|
||||||
@ -33,7 +33,7 @@ const buttonRows: ButtonRow[] = [
|
|||||||
boxShadow: '0px 0px 12px 0px rgba(6, 182, 212, 0.4)',
|
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>
|
||||||
</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"
|
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 }}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@ -53,21 +53,21 @@ const buttonRows: ButtonRow[] = [
|
|||||||
label: '세컨더리 (솔리드)',
|
label: '세컨더리 (솔리드)',
|
||||||
defaultBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
hoverBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
disabledBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
@ -77,21 +77,21 @@ const buttonRows: ButtonRow[] = [
|
|||||||
label: '아웃라인 (고스트)',
|
label: '아웃라인 (고스트)',
|
||||||
defaultBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
hoverBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
disabledBtn: (
|
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="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>
|
||||||
</div>
|
</div>
|
||||||
@ -102,7 +102,7 @@ const buttonRows: ButtonRow[] = [
|
|||||||
defaultBtn: (
|
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">
|
<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 아이콘" />
|
<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 다운로드
|
PDF 다운로드
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -113,7 +113,7 @@ const buttonRows: ButtonRow[] = [
|
|||||||
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
|
style={{ boxShadow: '0px 0px 8px 0px rgba(59, 130, 246, 0.2)' }}
|
||||||
>
|
>
|
||||||
<img className="shrink-0 relative overflow-visible" src={pdfFileIcon} alt="PDF 아이콘" />
|
<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 다운로드
|
PDF 다운로드
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +125,7 @@ const buttonRows: ButtonRow[] = [
|
|||||||
src={pdfFileDisabledIcon}
|
src={pdfFileDisabledIcon}
|
||||||
alt="PDF 아이콘 (비활성)"
|
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 다운로드
|
PDF 다운로드
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -135,7 +135,7 @@ const buttonRows: ButtonRow[] = [
|
|||||||
label: '경고 (삭제)',
|
label: '경고 (삭제)',
|
||||||
defaultBtn: (
|
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="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>
|
||||||
</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"
|
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)' }}
|
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>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
disabledBtn: (
|
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="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>
|
||||||
</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="border-l-2 border-[#06b6d4] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||||
<div
|
<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' }}
|
style={{ letterSpacing: '1px' }}
|
||||||
>
|
>
|
||||||
활성 물류 현황
|
활성 물류 현황
|
||||||
@ -55,12 +55,12 @@ export const CardSection = () => {
|
|||||||
</div>
|
</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 flex-1 min-w-0 relative">
|
||||||
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-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}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
<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}
|
{item.progress}
|
||||||
</div>
|
</div>
|
||||||
</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%)',
|
'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>
|
||||||
</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-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="flex flex-col gap-[4.5px] items-start justify-start shrink-0 relative">
|
||||||
<div
|
<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' }}
|
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="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="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>
|
</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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -85,7 +85,7 @@ export const IconBadgeSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||||
<div
|
<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' }}
|
style={{ letterSpacing: '0.9px' }}
|
||||||
>
|
>
|
||||||
{btn.label}
|
{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="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)
|
Standard dimensions: 36x36px with radius-md (6px)
|
||||||
</div>
|
</div>
|
||||||
</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="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="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||||
<div
|
<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' }}
|
style={{ letterSpacing: '1px' }}
|
||||||
>
|
>
|
||||||
Operational Status
|
Operational Status
|
||||||
@ -141,7 +141,7 @@ export const IconBadgeSection = () => {
|
|||||||
style={{ backgroundColor: badge.bg }}
|
style={{ backgroundColor: badge.bg }}
|
||||||
>
|
>
|
||||||
<div
|
<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 }}
|
style={{ color: badge.color }}
|
||||||
>
|
>
|
||||||
{badge.label}
|
{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="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="border-l-2 border-[#0e7490] pl-3 flex flex-col gap-0 items-start justify-start self-stretch shrink-0 relative">
|
||||||
<div
|
<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' }}
|
style={{ letterSpacing: '1px' }}
|
||||||
>
|
>
|
||||||
Data Classification
|
Data Classification
|
||||||
@ -174,7 +174,7 @@ export const IconBadgeSection = () => {
|
|||||||
></div>
|
></div>
|
||||||
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
<div className="flex flex-col gap-0 items-start justify-start shrink-0 relative">
|
||||||
<div
|
<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 }}
|
style={{ color: tag.color }}
|
||||||
>
|
>
|
||||||
{tag.label}
|
{tag.label}
|
||||||
|
|||||||
@ -321,21 +321,21 @@ export const DARK_THEME: DesignTheme = {
|
|||||||
{
|
{
|
||||||
token: 'text-1',
|
token: 'text-1',
|
||||||
sampleText: '주요 텍스트 Primary Text',
|
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.',
|
desc: 'Headings, active values, and primary labels.',
|
||||||
descColor: 'rgba(237,240,247,0.60)',
|
descColor: 'rgba(237,240,247,0.60)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'text-2',
|
token: 'text-2',
|
||||||
sampleText: '보조 텍스트 Secondary Text',
|
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.',
|
desc: 'Supporting labels and secondary information.',
|
||||||
descColor: 'rgba(192,200,220,0.60)',
|
descColor: 'rgba(192,200,220,0.60)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'text-3',
|
token: 'text-3',
|
||||||
sampleText: '비활성 텍스트 Muted Text',
|
sampleText: '비활성 텍스트 Muted Text',
|
||||||
sampleClass: 'text-[#9ba3b8] font-korean text-[15px]',
|
sampleClass: 'text-[#9ba3b8] font-korean text-subtitle',
|
||||||
desc: 'Disabled states, placeholders, and captions.',
|
desc: 'Disabled states, placeholders, and captions.',
|
||||||
descColor: 'rgba(155,163,184,0.60)',
|
descColor: 'rgba(155,163,184,0.60)',
|
||||||
},
|
},
|
||||||
@ -353,7 +353,7 @@ export const LIGHT_THEME: DesignTheme = {
|
|||||||
headerBg: '#ffffff',
|
headerBg: '#ffffff',
|
||||||
headerBorder: '#e2e8f0',
|
headerBorder: '#e2e8f0',
|
||||||
|
|
||||||
textPrimary: '#0f172a',
|
textPrimary: '#000000',
|
||||||
textSecondary: '#64748b',
|
textSecondary: '#64748b',
|
||||||
textMuted: '#94a3b8',
|
textMuted: '#94a3b8',
|
||||||
textAccent: '#06b6d4',
|
textAccent: '#06b6d4',
|
||||||
@ -498,21 +498,21 @@ export const LIGHT_THEME: DesignTheme = {
|
|||||||
{
|
{
|
||||||
token: 'text-1',
|
token: 'text-1',
|
||||||
sampleText: '주요 텍스트 Primary Text',
|
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.',
|
desc: 'Headings, active values, and primary labels.',
|
||||||
descColor: '#64748b',
|
descColor: '#64748b',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'text-2',
|
token: 'text-2',
|
||||||
sampleText: '보조 텍스트 Secondary Text',
|
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.',
|
desc: 'Supporting labels and secondary information.',
|
||||||
descColor: '#64748b',
|
descColor: '#64748b',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: 'text-3',
|
token: 'text-3',
|
||||||
sampleText: '비활성 텍스트 Muted Text',
|
sampleText: '비활성 텍스트 Muted Text',
|
||||||
sampleClass: 'text-[#94a3b8] font-korean text-[15px]',
|
sampleClass: 'text-[#94a3b8] font-korean text-subtitle',
|
||||||
desc: 'Disabled states, placeholders, and captions.',
|
desc: 'Disabled states, placeholders, and captions.',
|
||||||
descColor: '#94a3b8',
|
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="flex flex-col items-center justify-center h-full gap-3">
|
||||||
<div className="text-4xl opacity-20">🚧</div>
|
<div className="text-4xl opacity-20">🚧</div>
|
||||||
<div className="text-sm font-korean text-fg-sub font-semibold">{label}</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onSelect(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={{
|
style={{
|
||||||
paddingLeft: `${12 + depth * 14}px`,
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||||||
@ -65,7 +65,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
if (firstLeaf) onSelect(firstLeaf.id);
|
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={{
|
style={{
|
||||||
paddingLeft: `${12 + depth * 14}px`,
|
paddingLeft: `${12 + depth * 14}px`,
|
||||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
|
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||||||
@ -74,7 +74,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<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)' }}
|
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
|
||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
@ -123,7 +123,7 @@ const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
|||||||
{/* 섹션 헤더 */}
|
{/* 섹션 헤더 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(section.id)}
|
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={{
|
style={{
|
||||||
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
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="text-sm">{section.icon}</span>
|
||||||
<span className="flex-1 text-left">{section.label}</span>
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
<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)' }}
|
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 ${
|
className={`rounded-lg border-2 border-dashed py-8 text-center cursor-pointer transition-colors ${
|
||||||
dragging
|
dragging
|
||||||
? 'border-color-accent bg-[rgba(6,182,212,0.05)]'
|
? '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>
|
<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 className="text-xs font-semibold text-fg-sub font-korean mb-1">
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
파일을 드래그하거나 클릭하여 업로드
|
||||||
</div>
|
</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
|
엑셀(.xlsx), CSV 파일 지원 · 최대 10MB
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -170,7 +170,7 @@ function AssetUploadPanel() {
|
|||||||
|
|
||||||
{/* 자산 분류 */}
|
{/* 자산 분류 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -189,7 +189,7 @@ function AssetUploadPanel() {
|
|||||||
|
|
||||||
{/* 대상 관할 */}
|
{/* 대상 관할 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -208,7 +208,7 @@ function AssetUploadPanel() {
|
|||||||
|
|
||||||
{/* 업로드 방식 */}
|
{/* 업로드 방식 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@ -271,7 +271,7 @@ function AssetUploadPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</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}
|
{p.desc}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -287,7 +287,7 @@ function AssetUploadPanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{uploadHistory.length === 0 ? (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -298,12 +298,12 @@ function AssetUploadPanel() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-fg font-korean">{h.fileNm}</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()}건
|
{formatDate(h.regDtm)} · {h.uploaderNm} · {h.uploadCnt.toLocaleString()}건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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"
|
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 text-fg-disabled">{post.sn}</td>
|
||||||
<td className="py-2 text-center">
|
<td className="py-2 text-center">
|
||||||
<span
|
<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'
|
post.categoryCd === 'NOTICE'
|
||||||
? 'bg-red-500/15 text-red-400'
|
? 'bg-red-500/15 text-red-400'
|
||||||
: post.categoryCd === 'QNA'
|
: post.categoryCd === 'QNA'
|
||||||
@ -285,7 +285,7 @@ function PostRow({ post, checked, onToggle }: PostRowProps) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pl-3 text-fg truncate max-w-[300px]">
|
<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}
|
{post.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
<td className="py-2 text-center text-fg-sub">{post.authorName}</td>
|
||||||
|
|||||||
@ -167,47 +167,47 @@ function CleanupEquipPanel() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -228,51 +228,51 @@ function CleanupEquipPanel() {
|
|||||||
key={org.id}
|
key={org.id}
|
||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
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}
|
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<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}
|
{org.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)}
|
{regionShort(org.jurisdiction)}
|
||||||
</td>
|
</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}
|
{org.name}
|
||||||
</td>
|
</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}
|
{org.address}
|
||||||
</td>
|
</td>
|
||||||
<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>}
|
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<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>}
|
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<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>}
|
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<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>}
|
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<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>}
|
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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()}
|
{org.totalAssets.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -286,7 +286,7 @@ function CleanupEquipPanel() {
|
|||||||
{/* 합계 */}
|
{/* 합계 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
<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}개 기관)
|
합계 ({filtered.length}개 기관)
|
||||||
</span>
|
</span>
|
||||||
{[
|
{[
|
||||||
@ -301,15 +301,15 @@ function CleanupEquipPanel() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.label}
|
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
|
<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}
|
{t.label}
|
||||||
</span>
|
</span>
|
||||||
<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.value.toLocaleString()}
|
||||||
{t.unit}
|
{t.unit}
|
||||||
@ -323,7 +323,7 @@ function CleanupEquipPanel() {
|
|||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
<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)} /
|
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} /
|
||||||
전체 {filtered.length}개
|
전체 {filtered.length}개
|
||||||
</span>
|
</span>
|
||||||
@ -331,7 +331,7 @@ function CleanupEquipPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={safePage === 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>
|
</button>
|
||||||
@ -339,7 +339,7 @@ function CleanupEquipPanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setCurrentPage(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={
|
style={
|
||||||
p === safePage
|
p === safePage
|
||||||
? {
|
? {
|
||||||
@ -356,7 +356,7 @@ function CleanupEquipPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={safePage === totalPages}
|
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>
|
</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-t2 font-mono">{row.jobName}</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<span
|
<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'
|
row.activeYn === 'Y'
|
||||||
? 'text-emerald-400 bg-emerald-500/10'
|
? 'text-emerald-400 bg-emerald-500/10'
|
||||||
: 'text-t3 bg-bg-elevated'
|
: '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 text-t2 whitespace-nowrap">{row.clctDate ?? '-'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span
|
<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}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -149,7 +149,7 @@ const DispersingZonePanel = () => {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{/* 펼침 화살표 */}
|
{/* 펼침 화살표 */}
|
||||||
<span className="text-fg-disabled text-[10px] shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
<span className="text-fg-disabled text-caption shrink-0">{isExpanded ? '▲' : '▼'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 펼침 영역 */}
|
{/* 펼침 영역 */}
|
||||||
@ -159,10 +159,10 @@ const DispersingZonePanel = () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{info.rows.map((row) => (
|
{info.rows.map((row) => (
|
||||||
<tr key={row.key} className="border-b border-stroke last:border-0">
|
<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}
|
{row.key}
|
||||||
</td>
|
</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}
|
{row.value}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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="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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-sm bg-blue-500 opacity-80" />
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-3 h-3 rounded-sm bg-red-500 opacity-80" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -210,7 +210,7 @@ const DispersingZonePanel = () => {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
<div className="px-4 py-4 border-b border-stroke shrink-0">
|
||||||
<h1 className="text-sm font-bold text-fg font-korean">유처리제 제한구역</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 구역 카드 목록 */}
|
{/* 구역 카드 목록 */}
|
||||||
|
|||||||
@ -187,7 +187,7 @@ const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalP
|
|||||||
|
|
||||||
const inputCls =
|
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';
|
'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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<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 && (
|
{formError && (
|
||||||
<div className="px-6 pb-2">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 버튼 */}
|
{/* 버튼 */}
|
||||||
@ -502,34 +502,34 @@ const LayerPanel = () => {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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레이어명
|
WMS레이어명
|
||||||
</th>
|
</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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -555,7 +555,7 @@ const LayerPanel = () => {
|
|||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</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 font-korean">{item.layerNm}</td>
|
||||||
{/* 레이어전체명 */}
|
{/* 레이어전체명 */}
|
||||||
@ -566,12 +566,12 @@ const LayerPanel = () => {
|
|||||||
</td>
|
</td>
|
||||||
{/* 레벨 */}
|
{/* 레벨 */}
|
||||||
<td className="px-4 py-3 text-center">
|
<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}
|
{item.layerLevel}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{/* WMS레이어명 */}
|
{/* 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>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
{/* 정렬순서 */}
|
{/* 정렬순서 */}
|
||||||
@ -579,7 +579,7 @@ const LayerPanel = () => {
|
|||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</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 ?? '-'}
|
{item.regDtm ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
{/* 사용여부 토글 */}
|
{/* 사용여부 토글 */}
|
||||||
@ -598,7 +598,7 @@ const LayerPanel = () => {
|
|||||||
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
item.useYn === 'Y' && item.parentUseYn !== 'N'
|
||||||
? 'bg-color-accent'
|
? 'bg-color-accent'
|
||||||
: item.useYn === 'Y' && item.parentUseYn === 'N'
|
: 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'
|
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -637,27 +637,27 @@ const LayerPanel = () => {
|
|||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && totalPages > 1 && (
|
{!loading && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
<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}개
|
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page === 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>
|
</button>
|
||||||
{buildPageButtons().map((btn, i) =>
|
{buildPageButtons().map((btn, i) =>
|
||||||
btn === 'ellipsis' ? (
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={btn}
|
key={btn}
|
||||||
onClick={() => setPage(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
|
page === btn
|
||||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
? '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)]'
|
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||||
@ -670,7 +670,7 @@ const LayerPanel = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page === totalPages}
|
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>
|
</button>
|
||||||
|
|||||||
@ -100,7 +100,7 @@ function MapBaseModal({
|
|||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{/* 지도 이름 */}
|
{/* 지도 이름 */}
|
||||||
<div>
|
<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>
|
지도 이름 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -114,7 +114,7 @@ function MapBaseModal({
|
|||||||
|
|
||||||
{/* 지도 키 */}
|
{/* 지도 키 */}
|
||||||
<div>
|
<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>
|
지도 키 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -129,7 +129,7 @@ function MapBaseModal({
|
|||||||
|
|
||||||
{/* 지도 레벨 */}
|
{/* 지도 레벨 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -148,7 +148,7 @@ function MapBaseModal({
|
|||||||
|
|
||||||
{/* 파일 소스 */}
|
{/* 파일 소스 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -162,7 +162,7 @@ function MapBaseModal({
|
|||||||
|
|
||||||
{/* 상세 설명 */}
|
{/* 상세 설명 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -176,7 +176,7 @@ function MapBaseModal({
|
|||||||
|
|
||||||
{/* 사용여부 */}
|
{/* 사용여부 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -200,7 +200,7 @@ function MapBaseModal({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 모달 푸터 */}
|
{/* 모달 푸터 */}
|
||||||
@ -363,7 +363,7 @@ function MapBasePanel() {
|
|||||||
{/* 메시지 */}
|
{/* 메시지 */}
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<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'
|
message.type === 'success'
|
||||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
? '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)]'
|
: '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 text-center text-fg-disabled">{(page - 1) * 10 + idx + 1}</td>
|
||||||
<td className="py-3 pl-4">
|
<td className="py-3 pl-4">
|
||||||
<span className="text-fg font-korean">{item.mapNm}</span>
|
<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}
|
{item.mapKey}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -189,8 +189,8 @@ function MenusPanel() {
|
|||||||
{activeMenu ? (
|
{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]">
|
<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-fg-disabled text-xs">⠿</span>
|
||||||
<span className="text-[16px]">{activeMenu.icon}</span>
|
<span className="text-title-2">{activeMenu.icon}</span>
|
||||||
<span className="text-[13px] font-semibold text-fg font-korean">
|
<span className="text-title-4 font-semibold text-fg font-korean">
|
||||||
{activeMenu.label}
|
{activeMenu.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -342,19 +342,19 @@ function ConnectionBadge({
|
|||||||
if (isNormal) {
|
if (isNormal) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
<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
|
ON
|
||||||
</span>
|
</span>
|
||||||
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
|
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start gap-0.5">
|
<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
|
OFF
|
||||||
</span>
|
</span>
|
||||||
{lastMessageTime && <span className="text-[10px] text-t3">{lastMessageTime}</span>}
|
{lastMessageTime && <span className="text-caption text-t3">{lastMessageTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,7 +133,7 @@ function PermCell({ state, onToggle, label, readOnly = false }: PermCellProps) {
|
|||||||
const isDisabled = state === 'forced-denied' || readOnly;
|
const isDisabled = state === 'forced-denied' || readOnly;
|
||||||
|
|
||||||
const baseClasses =
|
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 classes: string;
|
||||||
let icon: string;
|
let icon: string;
|
||||||
@ -240,14 +240,14 @@ function TreeRow({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 ? '├' : ''}
|
{node.level > 0 ? '├' : ''}
|
||||||
</span>
|
</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="min-w-0">
|
||||||
<div
|
<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}
|
{node.name}
|
||||||
</div>
|
</div>
|
||||||
@ -295,29 +295,29 @@ function TreeRow({
|
|||||||
function PermLegend() {
|
function PermLegend() {
|
||||||
return (
|
return (
|
||||||
<div
|
<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 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
</span>
|
||||||
비활성
|
비활성
|
||||||
@ -418,14 +418,14 @@ function RolePermTab({
|
|||||||
setShowCreateForm(true);
|
setShowCreateForm(true);
|
||||||
setCreateError('');
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!dirty || saving}
|
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
|
dirty
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? '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'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -434,7 +434,7 @@ function RolePermTab({
|
|||||||
{saving ? '저장 중...' : '변경사항 저장'}
|
{saving ? '저장 중...' : '변경사항 저장'}
|
||||||
</button>
|
</button>
|
||||||
{saveError && (
|
{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>
|
</div>
|
||||||
|
|
||||||
@ -450,7 +450,7 @@ function RolePermTab({
|
|||||||
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
|
<div key={role.sn} className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedRoleSn(role.sn)}
|
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
|
isSelected
|
||||||
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
? 'border-2 shadow-[0_0_8px_rgba(6,182,212,0.2)]'
|
||||||
: 'border border-stroke text-fg-disabled hover:border-stroke'
|
: 'border border-stroke text-fg-disabled hover:border-stroke'
|
||||||
@ -469,19 +469,21 @@ function RolePermTab({
|
|||||||
onBlur={() => handleSaveRoleName(role.sn)}
|
onBlur={() => handleSaveRoleName(role.sn)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
autoFocus
|
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 onDoubleClick={() => handleStartEditName(role)}>{role.name}</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-1 text-[9px] font-mono opacity-50">{role.code}</span>
|
<span className="ml-1 text-caption font-mono opacity-50">{role.code}</span>
|
||||||
{role.isDefault && <span className="ml-1 text-[9px] text-color-accent">기본</span>}
|
{role.isDefault && (
|
||||||
|
<span className="ml-1 text-caption text-color-accent">기본</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleDefault(role.sn)}
|
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
|
role.isDefault
|
||||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent'
|
||||||
: 'text-fg-disabled hover:text-fg-sub'
|
: 'text-fg-disabled hover:text-fg-sub'
|
||||||
@ -525,13 +527,15 @@ function RolePermTab({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
<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>
|
</th>
|
||||||
{OPER_CODES.map((oper) => (
|
{OPER_CODES.map((oper) => (
|
||||||
<th key={oper} className="px-1 py-1.5 text-center w-12">
|
<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-caption font-semibold text-fg-sub">
|
||||||
<div className="text-[8px] text-fg-disabled font-korean">
|
{OPER_LABELS[oper]}
|
||||||
|
</div>
|
||||||
|
<div className="text-caption text-fg-disabled font-korean">
|
||||||
{OPER_FULL_LABELS[oper]}
|
{OPER_FULL_LABELS[oper]}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@ -567,7 +571,7 @@ function RolePermTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4 flex flex-col gap-3">
|
<div className="px-5 py-4 flex flex-col gap-3">
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -579,12 +583,12 @@ function RolePermTab({
|
|||||||
placeholder="CUSTOM_ROLE"
|
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"
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -596,7 +600,7 @@ function RolePermTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -608,7 +612,7 @@ function RolePermTab({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{createError && (
|
{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}
|
{createError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -792,7 +796,9 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* 사용자 검색/선택 */}
|
{/* 사용자 검색/선택 */}
|
||||||
<div className="px-4 py-2.5 border-b border-stroke" style={{ flexShrink: 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}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -823,17 +829,17 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
<div className="text-xs font-semibold text-fg font-korean truncate">
|
<div className="text-xs font-semibold text-fg font-korean truncate">
|
||||||
{user.name}
|
{user.name}
|
||||||
{user.rank && (
|
{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}
|
{user.rank}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-fg-disabled font-mono truncate">
|
<div className="text-caption text-fg-disabled font-mono truncate">
|
||||||
{user.account}
|
{user.account}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user.orgName && (
|
{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}
|
{user.orgName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -857,11 +863,11 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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
|
<button
|
||||||
onClick={handleSaveRoles}
|
onClick={handleSaveRoles}
|
||||||
disabled={!rolesDirty || savingRoles}
|
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
|
rolesDirty
|
||||||
? 'bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
? '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'
|
: 'bg-bg-card text-fg-disabled cursor-not-allowed'
|
||||||
@ -878,7 +884,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
<label
|
<label
|
||||||
key={role.sn}
|
key={role.sn}
|
||||||
className={[
|
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',
|
isChecked ? '' : 'border-stroke text-fg-disabled hover:border-text-2',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={
|
style={
|
||||||
@ -894,7 +900,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
className="w-3 h-3 accent-primary-cyan"
|
className="w-3 h-3 accent-primary-cyan"
|
||||||
/>
|
/>
|
||||||
<span>{role.name}</span>
|
<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>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -903,7 +909,7 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
|
|
||||||
{/* 유효 권한 매트릭스 (읽기 전용) */}
|
{/* 유효 권한 매트릭스 (읽기 전용) */}
|
||||||
<div
|
<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 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-fg-sub">유효 권한 (읽기 전용)</span>
|
<span className="font-semibold text-fg-sub">유효 권한 (읽기 전용)</span>
|
||||||
@ -917,15 +923,15 @@ function UserPermTab({ roles, permTree, rolePerms }: UserPermTabProps) {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
<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>
|
</th>
|
||||||
{OPER_CODES.map((oper) => (
|
{OPER_CODES.map((oper) => (
|
||||||
<th key={oper} className="px-2 py-3 text-center w-16">
|
<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]}
|
{OPER_LABELS[oper]}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-fg-disabled font-korean">
|
<div className="text-caption text-fg-disabled font-korean">
|
||||||
{OPER_FULL_LABELS[oper]}
|
{OPER_FULL_LABELS[oper]}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@ -1189,7 +1195,7 @@ function PermissionsPanel() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-bold text-fg font-korean">권한 관리</h1>
|
<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 권한 설정
|
역할별 리소스 × CRUD 권한 설정
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -183,31 +183,31 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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레이어명
|
WMS레이어명
|
||||||
</th>
|
</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>
|
||||||
<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>
|
||||||
<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>
|
||||||
</tr>
|
</tr>
|
||||||
@ -231,7 +231,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
<td className="px-4 py-3 text-xs text-fg-disabled font-mono">
|
||||||
{(page - 1) * PAGE_SIZE + idx + 1}
|
{(page - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</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 font-korean">{item.layerNm}</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
<td className="px-4 py-3 text-xs text-fg-sub font-korean max-w-[200px]">
|
||||||
<span className="block truncate" title={item.layerFullNm}>
|
<span className="block truncate" title={item.layerFullNm}>
|
||||||
@ -239,17 +239,17 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<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}
|
{item.layerLevel}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>}
|
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
<td className="px-4 py-3 text-xs text-fg-disabled text-center font-mono">
|
||||||
{item.sortOrd}
|
{item.sortOrd}
|
||||||
</td>
|
</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 ?? '-'}
|
{item.regDtm ?? '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
@ -285,27 +285,27 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && totalPages > 1 && (
|
{!loading && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
|
<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}개
|
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page === 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>
|
</button>
|
||||||
{buildPageButtons().map((btn, i) =>
|
{buildPageButtons().map((btn, i) =>
|
||||||
btn === 'ellipsis' ? (
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={btn}
|
key={btn}
|
||||||
onClick={() => setPage(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
|
page === btn
|
||||||
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
|
? '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)]'
|
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
|
||||||
@ -318,7 +318,7 @@ const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps)
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page === totalPages}
|
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>
|
</button>
|
||||||
|
|||||||
@ -75,7 +75,7 @@ function SettingsPanel() {
|
|||||||
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
<div className="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 등록 설정</h2>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -84,8 +84,8 @@ function SettingsPanel() {
|
|||||||
{/* 자동 승인 */}
|
{/* 자동 승인 */}
|
||||||
<div className="px-5 py-4 flex items-center justify-between">
|
<div className="px-5 py-4 flex items-center justify-between">
|
||||||
<div className="flex-1 mr-4">
|
<div className="flex-1 mr-4">
|
||||||
<div className="text-[13px] font-semibold text-fg font-korean">자동 승인</div>
|
<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-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
<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="px-5 py-4 flex items-center justify-between">
|
||||||
<div className="flex-1 mr-4">
|
<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>
|
</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>이 자동으로
|
<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="rounded-lg border border-stroke bg-bg-surface overflow-hidden">
|
||||||
<div className="px-5 py-3 border-b border-stroke">
|
<div className="px-5 py-3 border-b border-stroke">
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">Google OAuth 설정</h2>
|
<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 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<div className="flex-1 mr-4 mb-3">
|
<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>
|
</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 계정은 가입 즉시{' '}
|
지정된 도메인의 Google 계정은 가입 즉시{' '}
|
||||||
<span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다. 미지정
|
<span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다. 미지정
|
||||||
도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로
|
도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로
|
||||||
@ -202,7 +202,7 @@ function SettingsPanel() {
|
|||||||
.map((domain) => (
|
.map((domain) => (
|
||||||
<span
|
<span
|
||||||
key={domain}
|
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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.1)',
|
background: 'rgba(6,182,212,0.1)',
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
@ -223,7 +223,7 @@ function SettingsPanel() {
|
|||||||
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
<h2 className="text-sm font-bold text-fg font-korean">설정 상태 요약</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 py-4">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`}
|
className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`}
|
||||||
|
|||||||
@ -103,31 +103,31 @@ function SortableMenuItem({
|
|||||||
type="text"
|
type="text"
|
||||||
value={menu.label}
|
value={menu.label}
|
||||||
onChange={(e) => onLabelChange(menu.id, e.target.value)}
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onEditEnd}
|
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>
|
</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="flex-1 min-w-0">
|
||||||
<div
|
<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}
|
{menu.label}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEditStart(menu.id)}
|
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="라벨/아이콘 편집"
|
title="라벨/아이콘 편집"
|
||||||
>
|
>
|
||||||
✏️
|
✏️
|
||||||
|
|||||||
@ -107,7 +107,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
<div className="px-6 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{/* 계정 */}
|
{/* 계정 */}
|
||||||
<div>
|
<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>
|
계정 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -121,7 +121,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 비밀번호 */}
|
{/* 비밀번호 */}
|
||||||
<div>
|
<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>
|
비밀번호 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -135,7 +135,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 사용자명 */}
|
{/* 사용자명 */}
|
||||||
<div>
|
<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>
|
사용자명 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -149,7 +149,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 직급 */}
|
{/* 직급 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -163,7 +163,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 소속 */}
|
{/* 소속 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -183,7 +183,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 이메일 */}
|
{/* 이메일 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -197,12 +197,12 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
|
|
||||||
{/* 역할 */}
|
{/* 역할 */}
|
||||||
<div>
|
<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>
|
</label>
|
||||||
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
|
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
|
||||||
{allRoles.length === 0 ? (
|
{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) => {
|
allRoles.map((role, idx) => {
|
||||||
const color = getRoleColor(role.code, 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 }}>
|
<span className="text-xs font-korean" style={{ color }}>
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</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>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@ -229,7 +229,7 @@ function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalP
|
|||||||
</div>
|
</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>
|
</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 className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-bold text-fg font-korean">사용자 정보</h2>
|
<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>
|
</div>
|
||||||
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
|
||||||
<svg
|
<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 className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
|
||||||
{/* 기본 정보 수정 */}
|
{/* 기본 정보 수정 */}
|
||||||
<div>
|
<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>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -369,7 +369,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -381,7 +381,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -402,7 +402,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveInfo}
|
onClick={handleSaveInfo}
|
||||||
disabled={saving || !name.trim()}
|
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 ? '저장 중...' : '정보 저장'}
|
{saving ? '저장 중...' : '정보 저장'}
|
||||||
</button>
|
</button>
|
||||||
@ -414,12 +414,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
|
|
||||||
{/* 비밀번호 초기화 */}
|
{/* 비밀번호 초기화 */}
|
||||||
<div>
|
<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>
|
</h3>
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -433,20 +433,20 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
<button
|
<button
|
||||||
onClick={handleResetPassword}
|
onClick={handleResetPassword}
|
||||||
disabled={resetPwLoading || !newPassword.trim()}
|
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 ? '초기화 완료' : '비밀번호 초기화'}
|
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleUnlock}
|
onClick={handleUnlock}
|
||||||
disabled={unlockLoading || user.status !== 'LOCKED'}
|
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' ? '잠금 상태가 아닙니다' : ''}
|
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
|
||||||
>
|
>
|
||||||
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
|
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -456,12 +456,12 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
|
|
||||||
{/* 계정 잠금 해제 */}
|
{/* 계정 잠금 해제 */}
|
||||||
<div>
|
<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 className="flex items-center justify-between bg-bg-elevated border border-stroke rounded-md px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<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
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`}
|
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}
|
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
|
||||||
</span>
|
</span>
|
||||||
{user.failCount > 0 && (
|
{user.failCount > 0 && (
|
||||||
<span className="text-[10px] text-red-400 font-korean">
|
<span className="text-caption text-red-400 font-korean">
|
||||||
(로그인 실패 {user.failCount}회)
|
(로그인 실패 {user.failCount}회)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user.status === 'LOCKED' && (
|
{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회 이상 오류로 잠금 처리됨
|
비밀번호 5회 이상 오류로 잠금 처리됨
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -484,7 +484,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
<button
|
<button
|
||||||
onClick={handleUnlock}
|
onClick={handleUnlock}
|
||||||
disabled={unlockLoading}
|
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 ? '해제 중...' : '잠금 해제'}
|
{unlockLoading ? '해제 중...' : '잠금 해제'}
|
||||||
</button>
|
</button>
|
||||||
@ -494,8 +494,8 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
|
|
||||||
{/* 기타 정보 (읽기 전용) */}
|
{/* 기타 정보 (읽기 전용) */}
|
||||||
<div>
|
<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="grid grid-cols-2 gap-2 text-[10px] font-korean">
|
<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">
|
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
|
||||||
<span className="text-fg-disabled">이메일: </span>
|
<span className="text-fg-disabled">이메일: </span>
|
||||||
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
|
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
|
||||||
@ -520,7 +520,7 @@ function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalP
|
|||||||
{/* 메시지 */}
|
{/* 메시지 */}
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<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'
|
message.type === 'success'
|
||||||
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
|
? '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)]'
|
: '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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{pendingCount > 0 && (
|
{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}명
|
승인대기 {pendingCount}명
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -747,31 +747,31 @@ function UsersPanel() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface">
|
<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>
|
||||||
<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
|
ID
|
||||||
</th>
|
</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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -796,12 +796,12 @@ function UsersPanel() {
|
|||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
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}
|
{rowNum}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* ID(account) */}
|
{/* 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}
|
{user.account}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -809,24 +809,24 @@ function UsersPanel() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailUser(user)}
|
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}
|
{user.name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</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 || '-'}
|
{user.rank || '-'}
|
||||||
</td>
|
</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 || '-'}
|
{user.orgAbbr || user.orgName || '-'}
|
||||||
</td>
|
</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 || '-'}
|
{user.email || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -846,7 +846,7 @@ function UsersPanel() {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={roleCode}
|
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={{
|
style={{
|
||||||
background: `${color}20`,
|
background: `${color}20`,
|
||||||
color: color,
|
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>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-fg-disabled ml-0.5">
|
<span className="text-caption text-fg-disabled ml-0.5">
|
||||||
<svg
|
<svg
|
||||||
width="10"
|
width="10"
|
||||||
height="10"
|
height="10"
|
||||||
@ -881,7 +881,7 @@ function UsersPanel() {
|
|||||||
ref={roleDropdownRef}
|
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]"
|
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>
|
</div>
|
||||||
{allRoles.map((role, roleIdx) => {
|
{allRoles.map((role, roleIdx) => {
|
||||||
@ -900,7 +900,7 @@ function UsersPanel() {
|
|||||||
<span className="text-xs font-korean" style={{ color }}>
|
<span className="text-xs font-korean" style={{ color }}>
|
||||||
{role.name}
|
{role.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-fg-disabled font-mono">
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
{role.code}
|
{role.code}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@ -909,14 +909,14 @@ function UsersPanel() {
|
|||||||
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
|
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
|
||||||
<button
|
<button
|
||||||
onClick={() => setRoleEditUserId(null)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSaveRoles(user.id)}
|
onClick={() => handleSaveRoles(user.id)}
|
||||||
disabled={selectedRoleSns.length === 0}
|
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>
|
</button>
|
||||||
@ -929,7 +929,7 @@ function UsersPanel() {
|
|||||||
{/* 승인상태 */}
|
{/* 승인상태 */}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<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}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
@ -943,13 +943,13 @@ function UsersPanel() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(user.id)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReject(user.id)}
|
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>
|
</button>
|
||||||
@ -958,7 +958,7 @@ function UsersPanel() {
|
|||||||
{user.status === 'LOCKED' && (
|
{user.status === 'LOCKED' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUnlock(user.id)}
|
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>
|
</button>
|
||||||
@ -966,7 +966,7 @@ function UsersPanel() {
|
|||||||
{user.status === 'ACTIVE' && (
|
{user.status === 'ACTIVE' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeactivate(user.id)}
|
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>
|
</button>
|
||||||
@ -974,7 +974,7 @@ function UsersPanel() {
|
|||||||
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivate(user.id)}
|
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>
|
</button>
|
||||||
@ -993,7 +993,7 @@ function UsersPanel() {
|
|||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && totalPages > 1 && (
|
{!loading && totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
|
<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)} /{' '}
|
{(currentPage - 1) * PAGE_SIZE + 1}–{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
|
||||||
{totalCount}명
|
{totalCount}명
|
||||||
</span>
|
</span>
|
||||||
@ -1001,7 +1001,7 @@ function UsersPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 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>
|
</button>
|
||||||
@ -1020,14 +1020,14 @@ function UsersPanel() {
|
|||||||
}, [])
|
}, [])
|
||||||
.map((item, i) =>
|
.map((item, i) =>
|
||||||
item === '...' ? (
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
onClick={() => setCurrentPage(item as number)}
|
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={
|
style={
|
||||||
currentPage === item
|
currentPage === item
|
||||||
? {
|
? {
|
||||||
@ -1045,7 +1045,7 @@ function UsersPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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>
|
</button>
|
||||||
|
|||||||
@ -148,37 +148,37 @@ function VesselMaterialsPanel() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-surface">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -199,41 +199,41 @@ function VesselMaterialsPanel() {
|
|||||||
key={org.id}
|
key={org.id}
|
||||||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
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}
|
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<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}
|
{org.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)}
|
{regionShort(org.jurisdiction)}
|
||||||
</td>
|
</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}
|
{org.name}
|
||||||
</td>
|
</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}
|
{org.address}
|
||||||
</td>
|
</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>}
|
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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>}
|
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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>}
|
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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>}
|
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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>}
|
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||||||
</td>
|
</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()}
|
{org.totalAssets.toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -247,7 +247,7 @@ function VesselMaterialsPanel() {
|
|||||||
{/* 합계 */}
|
{/* 합계 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
<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}개 기관)
|
합계 ({filtered.length}개 기관)
|
||||||
</span>
|
</span>
|
||||||
{[
|
{[
|
||||||
@ -290,15 +290,15 @@ function VesselMaterialsPanel() {
|
|||||||
].map((t) => (
|
].map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.label}
|
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
|
<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}
|
{t.label}
|
||||||
</span>
|
</span>
|
||||||
<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.value.toLocaleString()}
|
||||||
{t.unit}
|
{t.unit}
|
||||||
@ -311,7 +311,7 @@ function VesselMaterialsPanel() {
|
|||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
<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)} /
|
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} /
|
||||||
전체 {filtered.length}개
|
전체 {filtered.length}개
|
||||||
</span>
|
</span>
|
||||||
@ -319,7 +319,7 @@ function VesselMaterialsPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={safePage === 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>
|
</button>
|
||||||
@ -327,7 +327,7 @@ function VesselMaterialsPanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setCurrentPage(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={
|
style={
|
||||||
p === safePage
|
p === safePage
|
||||||
? {
|
? {
|
||||||
@ -344,7 +344,7 @@ function VesselMaterialsPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={safePage === totalPages}
|
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>
|
</button>
|
||||||
|
|||||||
@ -180,10 +180,10 @@ export default function VesselSignalPanel() {
|
|||||||
className="flex flex-col justify-center mb-4"
|
className="flex flex-col justify-center mb-4"
|
||||||
style={{ height: 20 }}
|
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}
|
{src}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -196,14 +196,14 @@ export default function VesselSignalPanel() {
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<span
|
<span
|
||||||
key={h}
|
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%)' }}
|
style={{ left: `${(h / 24) * 100}%`, transform: 'translateX(-50%)' }}
|
||||||
>
|
>
|
||||||
{String(h).padStart(2, '0')}시
|
{String(h).padStart(2, '0')}시
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<span
|
<span
|
||||||
className="absolute text-[10px] text-fg-disabled font-mono"
|
className="absolute text-caption text-fg-disabled font-mono"
|
||||||
style={{ right: 0 }}
|
style={{ right: 0 }}
|
||||||
>
|
>
|
||||||
24시
|
24시
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -19,6 +19,7 @@ interface CCTVPlayerProps {
|
|||||||
coordDc?: string | null;
|
coordDc?: string | null;
|
||||||
sourceNm?: string | null;
|
sourceNm?: string | null;
|
||||||
cellIndex?: number;
|
cellIndex?: number;
|
||||||
|
onClose?: () => void;
|
||||||
oilDetectionEnabled?: boolean;
|
oilDetectionEnabled?: boolean;
|
||||||
vesselDetectionEnabled?: boolean;
|
vesselDetectionEnabled?: boolean;
|
||||||
intrusionDetectionEnabled?: boolean;
|
intrusionDetectionEnabled?: boolean;
|
||||||
@ -44,9 +45,8 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
cameraNm,
|
cameraNm,
|
||||||
streamUrl,
|
streamUrl,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
coordDc,
|
|
||||||
sourceNm,
|
|
||||||
cellIndex = 0,
|
cellIndex = 0,
|
||||||
|
onClose,
|
||||||
oilDetectionEnabled = false,
|
oilDetectionEnabled = false,
|
||||||
vesselDetectionEnabled = false,
|
vesselDetectionEnabled = false,
|
||||||
intrusionDetectionEnabled = false,
|
intrusionDetectionEnabled = false,
|
||||||
@ -251,10 +251,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
<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-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' ? '점검중' : '오프라인'}
|
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -264,10 +266,12 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
<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-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 미설정
|
스트림 URL 미설정
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -277,11 +281,13 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base">
|
<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-2xl opacity-30 mb-2">⚠️</div>
|
||||||
<div className="text-[10px] font-korean text-color-danger opacity-70">연결 실패</div>
|
<div className="text-caption 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-fg-disabled opacity-40 mt-1">
|
||||||
|
{cameraNm}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setRetryKey((k) => k + 1)}
|
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>
|
</button>
|
||||||
@ -295,7 +301,7 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
{playerState === 'loading' && (
|
{playerState === 'loading' && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-bg-base z-10">
|
<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-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>
|
</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">
|
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
|
||||||
{vesselDetectionEnabled && (
|
{vesselDetectionEnabled && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
|
||||||
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-info) 30%, transparent)',
|
||||||
|
color: 'var(--color-info)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
🚢 선박 출입 감지 중
|
🚢 선박 출입 감지 중
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{intrusionDetectionEnabled && (
|
{intrusionDetectionEnabled && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
|
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-caption font-bold"
|
||||||
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}
|
style={{
|
||||||
|
background: 'color-mix(in srgb, var(--color-warning) 30%, transparent)',
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
🚨 침입 감지 중
|
🚨 침입 감지 중
|
||||||
</div>
|
</div>
|
||||||
@ -362,22 +374,45 @@ export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(
|
|||||||
|
|
||||||
{/* OSD 오버레이 */}
|
{/* OSD 오버레이 */}
|
||||||
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
|
<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}
|
{cameraNm}
|
||||||
</span>
|
</span>
|
||||||
{sttsCd === 'LIVE' && (
|
{sttsCd === 'LIVE' && (
|
||||||
<span
|
<span
|
||||||
className="text-[8px] font-bold px-1 py-0.5 rounded text-color-danger"
|
className="text-caption font-bold px-1 py-0.5 rounded text-color-danger"
|
||||||
style={{ background: 'rgba(239,68,68,.3)' }}
|
style={{ background: 'color-mix(in srgb, var(--color-danger) 30%, transparent)' }}
|
||||||
>
|
>
|
||||||
● REC
|
● REC
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ?? ''}
|
{coordDc ?? ''}
|
||||||
{sourceNm ? ` · ${sourceNm}` : ''}
|
{sourceNm ? ` · ${sourceNm}` : ''}
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -13,17 +13,9 @@ function formatDtm(dtm: string | null): string {
|
|||||||
|
|
||||||
const equipIcon = (t: string) => (t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰');
|
const equipIcon = (t: string) => (t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰');
|
||||||
|
|
||||||
const equipTagCls = (t: string) =>
|
const equipTagCls = () => 'text-fg';
|
||||||
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 mediaTagCls = (t: string) =>
|
const mediaTagCls = () => 'text-fg';
|
||||||
t === '영상'
|
|
||||||
? 'bg-[rgba(239,68,68,0.12)] text-color-danger'
|
|
||||||
: 'bg-[rgba(234,179,8,0.12)] text-color-caution';
|
|
||||||
|
|
||||||
const FilterBtn = ({
|
const FilterBtn = ({
|
||||||
label,
|
label,
|
||||||
@ -36,10 +28,10 @@ const FilterBtn = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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
|
active
|
||||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-[rgba(6,182,212,0.3)]'
|
||||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@ -181,7 +173,7 @@ export function MediaManagement() {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex gap-1.5 items-center">
|
<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
|
<FilterBtn
|
||||||
label="전체"
|
label="전체"
|
||||||
active={equipFilter === 'all'}
|
active={equipFilter === 'all'}
|
||||||
@ -203,7 +195,7 @@ export function MediaManagement() {
|
|||||||
onClick={() => setEquipFilter('satellite')}
|
onClick={() => setEquipFilter('satellite')}
|
||||||
/>
|
/>
|
||||||
<span className="w-px h-4 bg-border mx-1" />
|
<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
|
<FilterBtn
|
||||||
label="📷 사진"
|
label="📷 사진"
|
||||||
active={typeFilter.has('photo')}
|
active={typeFilter.has('photo')}
|
||||||
@ -221,7 +213,7 @@ export function MediaManagement() {
|
|||||||
placeholder="파일명 검색..."
|
placeholder="파일명 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
@ -242,7 +234,7 @@ export function MediaManagement() {
|
|||||||
icon: '📸',
|
icon: '📸',
|
||||||
value: loading ? '…' : String(mediaItems.length),
|
value: loading ? '…' : String(mediaItems.length),
|
||||||
label: '총 파일',
|
label: '총 파일',
|
||||||
color: 'text-color-accent',
|
color: 'text-fg',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🛸',
|
icon: '🛸',
|
||||||
@ -261,19 +253,19 @@ export function MediaManagement() {
|
|||||||
].map((s, i) => (
|
].map((s, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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>
|
<span className="text-xl">{s.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-base font-bold font-mono ${s.color}`}>{s.value}</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Table */}
|
{/* 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">
|
<div className="overflow-auto flex-1">
|
||||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@ -287,7 +279,7 @@ export function MediaManagement() {
|
|||||||
<col style={{ width: 145 }} />
|
<col style={{ width: 145 }} />
|
||||||
<col style={{ width: 85 }} />
|
<col style={{ width: 85 }} />
|
||||||
<col style={{ width: 95 }} />
|
<col style={{ width: 95 }} />
|
||||||
<col style={{ width: 50 }} />
|
<col style={{ width: 60 }} />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-stroke bg-bg-elevated">
|
<tr className="border-b border-stroke bg-bg-elevated">
|
||||||
@ -300,32 +292,32 @@ export function MediaManagement() {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-1 py-2.5" />
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -334,7 +326,7 @@ export function MediaManagement() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={11}
|
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>
|
</td>
|
||||||
@ -344,7 +336,7 @@ export function MediaManagement() {
|
|||||||
<tr
|
<tr
|
||||||
key={f.aerialMediaSn}
|
key={f.aerialMediaSn}
|
||||||
onClick={() => toggleId(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)]' : ''
|
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -357,37 +349,33 @@ export function MediaManagement() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</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) : '—'}
|
{f.acdntSn != null ? String(f.acdntSn) : '—'}
|
||||||
</td>
|
</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 ?? '—'}
|
{f.locDc ?? '—'}
|
||||||
</td>
|
</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}
|
{f.fileNm}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<span
|
<span className={`text-caption font-semibold font-korean ${equipTagCls()}`}>
|
||||||
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}
|
|
||||||
>
|
|
||||||
{f.equipNm}
|
{f.equipNm}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2">
|
<td className="px-2 py-2">
|
||||||
<span
|
<span className={`text-caption font-semibold font-korean ${mediaTagCls()}`}>
|
||||||
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}
|
|
||||||
>
|
|
||||||
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
|
<td className="px-2 py-2 text-label-2 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-label-2 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">{f.resolution ?? '—'}</td>
|
||||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDownload(e, f)}
|
onClick={(e) => handleDownload(e, f)}
|
||||||
disabled={downloadingId === f.aerialMediaSn}
|
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 ? '⏳' : '📥'}
|
{downloadingId === f.aerialMediaSn ? '⏳' : '📥'}
|
||||||
</button>
|
</button>
|
||||||
@ -402,26 +390,26 @@ export function MediaManagement() {
|
|||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="flex justify-between items-center mt-4 pt-3.5 border-t border-stroke">
|
<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>건
|
선택된 파일: <span className="text-color-accent font-semibold">{selectedIds.size}</span>건
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={toggleAll}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleBulkDownload}
|
onClick={handleBulkDownload}
|
||||||
disabled={bulkDownloading || selectedIds.size === 0}
|
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 ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
{bulkDownloading ? '⏳ 다운로드 중...' : '📥 선택 다운로드'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateToTab('prediction', 'analysis')}
|
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>
|
</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="bg-bg-surface border border-stroke rounded-md p-6 w-72 text-center">
|
||||||
<div className="text-2xl mb-3">📥</div>
|
<div className="text-2xl mb-3">📥</div>
|
||||||
<div className="text-sm font-bold font-korean 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>건 선택
|
총 <span className="text-color-accent font-bold">{downloadResult.total}</span>건 선택
|
||||||
</div>
|
</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>건
|
<span className="text-color-success font-bold">{downloadResult.success}</span>건
|
||||||
다운로드 성공
|
다운로드 성공
|
||||||
{downloadResult.total - downloadResult.success > 0 && (
|
{downloadResult.total - downloadResult.success > 0 && (
|
||||||
@ -453,7 +441,7 @@ export function MediaManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDownloadResult(null)}
|
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>
|
</button>
|
||||||
@ -477,12 +465,12 @@ export function MediaManagement() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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-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>
|
||||||
<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
|
JPG, TIFF, GeoTIFF, MP4, MOV 지원 · 최대 2GB
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -520,9 +508,11 @@ export function MediaManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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={{
|
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 ── */}
|
{/* ── Left Panel ── */}
|
||||||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
<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-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>
|
</div>
|
||||||
|
|
||||||
@ -266,12 +266,12 @@ export function OilAreaAnalysis() {
|
|||||||
{/* 선택된 이미지 목록 */}
|
{/* 선택된 이미지 목록 */}
|
||||||
{selectedFiles.length > 0 && (
|
{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">
|
<div className="flex flex-col gap-1 mb-3">
|
||||||
{selectedFiles.map((file, i) => (
|
{selectedFiles.map((file, i) => (
|
||||||
<div key={`${file.name}-${i}`}>
|
<div key={`${file.name}-${i}`}>
|
||||||
<div
|
<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'}`}
|
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||||||
onClick={() => setSelectedImageIndex(i)}
|
onClick={() => setSelectedImageIndex(i)}
|
||||||
>
|
>
|
||||||
@ -291,7 +291,7 @@ export function OilAreaAnalysis() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedImageIndex === i && imageExifs[i] !== undefined && (
|
{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="파일 크기" value={formatFileSize(file.size)} />
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="해상도"
|
label="해상도"
|
||||||
@ -368,7 +368,7 @@ export function OilAreaAnalysis() {
|
|||||||
|
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -377,7 +377,7 @@ export function OilAreaAnalysis() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleStitch}
|
onClick={handleStitch}
|
||||||
disabled={!canStitch}
|
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)]
|
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"
|
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@ -388,7 +388,7 @@ export function OilAreaAnalysis() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={!canAnalyze}
|
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"
|
disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||||||
style={
|
style={
|
||||||
canAnalyze
|
canAnalyze
|
||||||
@ -403,7 +403,7 @@ export function OilAreaAnalysis() {
|
|||||||
{/* ── Right Panel ── */}
|
{/* ── Right Panel ── */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* 3×2 이미지 그리드 */}
|
{/* 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">
|
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@ -426,21 +426,21 @@ export function OilAreaAnalysis() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="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}
|
{selectedFiles[i]?.name}
|
||||||
</div>
|
</div>
|
||||||
{imageExifs[i] === undefined ? (
|
{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 읽는 중...
|
GPS 읽는 중...
|
||||||
</div>
|
</div>
|
||||||
) : imageExifs[i]?.lat !== null ? (
|
) : 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)}
|
{decimalToDMS(imageExifs[i]!.lat!, true)}
|
||||||
<br />
|
<br />
|
||||||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">
|
<div className="text-caption text-fg-disabled font-korean shrink-0">
|
||||||
GPS 정보 없음
|
GPS 정보 없음
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -456,7 +456,7 @@ export function OilAreaAnalysis() {
|
|||||||
</div>
|
</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
|
<div
|
||||||
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
className="relative bg-bg-base border border-stroke rounded-sm overflow-hidden flex items-center justify-center"
|
||||||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||||||
@ -468,7 +468,7 @@ export function OilAreaAnalysis() {
|
|||||||
className="max-w-full max-h-full object-contain"
|
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
|
{isStitching
|
||||||
? '⏳ 이미지를 합성하고 있습니다...'
|
? '⏳ 이미지를 합성하고 있습니다...'
|
||||||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||||||
|
|||||||
@ -85,11 +85,11 @@ const OilDetectionOverlay = memo(
|
|||||||
{/* 에러 표시 */}
|
{/* 에러 표시 */}
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(239,68,68,0.2)',
|
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||||
border: '1px solid rgba(239,68,68,0.5)',
|
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
|
||||||
color: '#f87171',
|
color: 'var(--color-danger)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
추론 서버 연결 불가
|
추론 서버 연결 불가
|
||||||
@ -101,13 +101,13 @@ const OilDetectionOverlay = memo(
|
|||||||
<>
|
<>
|
||||||
{result.regions.map((region) => {
|
{result.regions.map((region) => {
|
||||||
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
|
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;
|
const label = OIL_CLASS_NAMES[region.classId] || region.className;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={region.classId}
|
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={{
|
style={{
|
||||||
background: `${color}33`,
|
background: `${color}33`,
|
||||||
border: `1px solid ${color}80`,
|
border: `1px solid ${color}80`,
|
||||||
@ -120,11 +120,11 @@ const OilDetectionOverlay = memo(
|
|||||||
})}
|
})}
|
||||||
{/* 합계 */}
|
{/* 합계 */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(239,68,68,0.2)',
|
background: 'color-mix(in srgb, var(--color-danger) 20%, transparent)',
|
||||||
border: '1px solid rgba(239,68,68,0.5)',
|
border: '1px solid color-mix(in srgb, var(--color-danger) 50%, transparent)',
|
||||||
color: '#f87171',
|
color: 'var(--color-danger)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
||||||
@ -135,11 +135,11 @@ const OilDetectionOverlay = memo(
|
|||||||
{/* 감지 없음 */}
|
{/* 감지 없음 */}
|
||||||
{!hasRegions && !isAnalyzing && !error && (
|
{!hasRegions && !isAnalyzing && !error && (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(34,197,94,0.15)',
|
background: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
|
||||||
border: '1px solid rgba(34,197,94,0.35)',
|
border: '1px solid color-mix(in srgb, var(--color-success) 35%, transparent)',
|
||||||
color: '#4ade80',
|
color: 'var(--color-success)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
감지 없음
|
감지 없음
|
||||||
@ -148,7 +148,7 @@ const OilDetectionOverlay = memo(
|
|||||||
|
|
||||||
{/* 분석 중 */}
|
{/* 분석 중 */}
|
||||||
{isAnalyzing && (
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -396,7 +396,7 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
<div
|
<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' }}
|
style={{ width: '64%', animation: 'pulse 2s infinite' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -717,7 +717,7 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
<div className="w-24 h-0.5 bg-bg-card rounded-full mt-2 mx-auto overflow-hidden">
|
||||||
<div
|
<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' }}
|
style={{ width: '52%', animation: 'pulse 2s infinite' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -746,15 +746,15 @@ export function SensorAnalysis() {
|
|||||||
<div className="w-[280px] bg-bg-surface border-r border-stroke flex flex-col overflow-auto">
|
<div className="w-[280px] bg-bg-surface border-r border-stroke flex flex-col overflow-auto">
|
||||||
{/* 3D Reconstruction List */}
|
{/* 3D Reconstruction List */}
|
||||||
<div className="p-2.5 px-3 border-b border-stroke">
|
<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 재구성 완료 목록
|
📋 3D 재구성 완료 목록
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="flex gap-1 mb-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubTab('vessel')}
|
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'
|
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'
|
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -762,9 +762,9 @@ export function SensorAnalysis() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSubTab('pollution')}
|
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'
|
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'
|
: 'text-fg-disabled bg-bg-base border-stroke'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -778,20 +778,20 @@ export function SensorAnalysis() {
|
|||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
|
className={`flex items-center gap-2 px-2 py-2 rounded-sm cursor-pointer transition-colors border ${
|
||||||
selectedItem.id === item.id
|
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]'
|
: 'border-transparent hover:bg-white/[0.02]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[10px] font-bold text-fg font-korean">{item.name}</div>
|
<div className="text-caption font-bold text-fg font-korean">{item.name}</div>
|
||||||
<div className="text-[8px] text-fg-disabled font-mono">
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
{item.id} · {item.points} pts
|
{item.id} · {item.points} pts
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -800,15 +800,15 @@ export function SensorAnalysis() {
|
|||||||
|
|
||||||
{/* Source Images */}
|
{/* Source Images */}
|
||||||
<div className="p-2.5 px-3 flex-1 min-h-0 flex flex-col">
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
{[
|
{[
|
||||||
{ label: 'D-01 정면', sensor: '광학', color: 'text-color-info' },
|
{ label: 'D-01 정면', sensor: '광학', color: 'text-fg-sub' },
|
||||||
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-color-danger' },
|
{ label: 'D-02 좌현', sensor: 'IR', color: 'text-fg-sub' },
|
||||||
{ label: 'D-03 우현', sensor: '광학', color: 'text-color-tertiary' },
|
{ label: 'D-03 우현', sensor: '광학', color: 'text-fg-sub' },
|
||||||
{ label: 'D-02 상부', sensor: 'IR', color: 'text-color-danger' },
|
{ label: 'D-02 상부', sensor: 'IR', color: 'text-fg-sub' },
|
||||||
].map((src, i) => (
|
].map((src, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@ -816,13 +816,13 @@ export function SensorAnalysis() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
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">
|
<div className="text-fg-disabled/10 text-xs font-mono">
|
||||||
{src.label.split(' ')[0]}
|
{src.label.split(' ')[0]}
|
||||||
</div>
|
</div>
|
||||||
</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>{src.label}</span>
|
||||||
<span className={src.color}>{src.sensor}</span>
|
<span className={src.color}>{src.sensor}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -863,7 +863,7 @@ export function SensorAnalysis() {
|
|||||||
className="absolute bottom-16 left-4"
|
className="absolute bottom-16 left-4"
|
||||||
style={{ fontSize: '9px', fontFamily: 'var(--font-mono)' }}
|
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-green-500">Y ↑</div>
|
||||||
<div className="text-blue-500">Z ⊙</div>
|
<div className="text-blue-500">Z ⊙</div>
|
||||||
</div>
|
</div>
|
||||||
@ -871,13 +871,13 @@ export function SensorAnalysis() {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="absolute top-3 left-3 z-[2]">
|
<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
|
3D Vessel Analysis
|
||||||
</div>
|
</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} 정밀분석
|
{selectedItem.name} 정밀분석
|
||||||
</div>
|
</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' ? '재구성 완료' : '처리중'}
|
34.58°N, 129.30°E · {selectedItem.status === 'complete' ? '재구성 완료' : '처리중'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -892,10 +892,10 @@ export function SensorAnalysis() {
|
|||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => setViewMode(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
|
viewMode === m.id
|
||||||
? 'bg-[rgba(6,182,212,0.2)] border-color-accent/50 text-color-accent'
|
? 'bg-[rgba(6,182,212,0.2)] border-[rgba(6,182,212,0.5)] text-color-accent'
|
||||||
: 'bg-black/40 border-color-accent/20 text-fg-disabled hover:bg-black/60 hover:border-color-accent/40'
|
: '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}
|
{m.label}
|
||||||
@ -906,7 +906,7 @@ export function SensorAnalysis() {
|
|||||||
{/* Bottom Stats */}
|
{/* Bottom Stats */}
|
||||||
<div
|
<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]"
|
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: '포인트' },
|
{ value: selectedItem.points, label: '포인트' },
|
||||||
@ -917,7 +917,7 @@ export function SensorAnalysis() {
|
|||||||
].map((s, i) => (
|
].map((s, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div className="font-mono font-bold text-sm text-color-accent">{s.value}</div>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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">
|
<div className="w-[270px] bg-bg-surface border-l border-stroke flex flex-col overflow-auto">
|
||||||
{/* Ship/Pollution Info */}
|
{/* Ship/Pollution Info */}
|
||||||
<div className="p-2.5 px-3 border-b border-stroke">
|
<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>
|
||||||
<div className="flex flex-col gap-1.5 text-[10px]">
|
<div className="flex flex-col gap-1.5 text-caption">
|
||||||
{(selectedItem.type === 'vessel'
|
{(selectedItem.type === 'vessel'
|
||||||
? [
|
? [
|
||||||
['대상', selectedItem.name],
|
['대상', selectedItem.name],
|
||||||
@ -962,26 +962,26 @@ export function SensorAnalysis() {
|
|||||||
|
|
||||||
{/* AI Detection Results */}
|
{/* AI Detection Results */}
|
||||||
<div className="p-2.5 px-3 border-b border-stroke">
|
<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 탐지 결과
|
🤖 AI 탐지 결과
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{(selectedItem.type === 'vessel'
|
{(selectedItem.type === 'vessel'
|
||||||
? [
|
? [
|
||||||
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
|
{ label: '선박 식별', confidence: 94, color: 'bg-color-success' },
|
||||||
{ label: '선종 분류', confidence: 78, color: 'bg-color-caution' },
|
{ label: '선종 분류', confidence: 78, color: 'bg-color-success' },
|
||||||
{ label: '손상 감지', confidence: 45, color: 'bg-color-warning' },
|
{ label: '손상 감지', confidence: 45, color: 'bg-color-success' },
|
||||||
{ label: '화물 분석', confidence: 62, color: 'bg-color-caution' },
|
{ label: '화물 분석', confidence: 62, color: 'bg-color-success' },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
|
{ label: '유막 탐지', confidence: 97, color: 'bg-color-success' },
|
||||||
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
|
{ label: '유종 분류', confidence: 85, color: 'bg-color-success' },
|
||||||
{ label: '두께 추정', confidence: 72, color: 'bg-color-caution' },
|
{ label: '두께 추정', confidence: 72, color: 'bg-color-success' },
|
||||||
{ label: '확산 예측', confidence: 68, color: 'bg-color-warning' },
|
{ label: '확산 예측', confidence: 68, color: 'bg-color-success' },
|
||||||
]
|
]
|
||||||
).map((r, i) => (
|
).map((r, i) => (
|
||||||
<div key={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="text-fg-disabled font-korean">{r.label}</span>
|
||||||
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
|
<span className="font-mono font-semibold text-fg">{r.confidence}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -998,10 +998,10 @@ export function SensorAnalysis() {
|
|||||||
|
|
||||||
{/* Comparison / Measurements */}
|
{/* Comparison / Measurements */}
|
||||||
<div className="p-2.5 px-3 border-b border-stroke">
|
<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 측정값
|
📐 3D 측정값
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 text-[10px]">
|
<div className="flex flex-col gap-1 text-caption">
|
||||||
{(selectedItem.type === 'vessel'
|
{(selectedItem.type === 'vessel'
|
||||||
? [
|
? [
|
||||||
['전장 (LOA)', '84.7 m'],
|
['전장 (LOA)', '84.7 m'],
|
||||||
@ -1020,7 +1020,7 @@ export function SensorAnalysis() {
|
|||||||
).map(([k, v], i) => (
|
).map(([k, v], i) => (
|
||||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded">
|
<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="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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1029,14 +1029,15 @@ export function SensorAnalysis() {
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="p-2.5 px-3">
|
<div className="p-2.5 px-3">
|
||||||
<button
|
<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={{
|
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>
|
||||||
<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 모델 다운로드
|
📥 3D 모델 다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { typeTagCls } from './assetTypes';
|
|
||||||
import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi';
|
import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi';
|
||||||
import type { AssetOrgCompat } from '../services/assetsApi';
|
import type { AssetOrgCompat } from '../services/assetsApi';
|
||||||
import AssetMap from './AssetMap';
|
import AssetMap from './AssetMap';
|
||||||
@ -96,20 +95,20 @@ function AssetManagement() {
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('list')}
|
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'
|
viewMode === 'list'
|
||||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
|
||||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
📋 방제자산리스트
|
📋 방제자산리스트
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('map')}
|
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'
|
viewMode === 'map'
|
||||||
? 'bg-[rgba(6,182,212,0.15)] text-color-accent border border-color-accent/30'
|
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent border border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)]'
|
||||||
: 'bg-bg-card border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
: 'border border-stroke text-fg-sub hover:bg-bg-surface-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🗺 지도 보기
|
🗺 지도 보기
|
||||||
@ -170,7 +169,7 @@ function AssetManagement() {
|
|||||||
|
|
||||||
{viewMode === 'list' ? (
|
{viewMode === 'list' ? (
|
||||||
/* ── LIST VIEW ── */
|
/* ── 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">
|
<div className="flex-1">
|
||||||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@ -212,7 +211,7 @@ function AssetManagement() {
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={i}
|
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}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
@ -224,59 +223,53 @@ function AssetManagement() {
|
|||||||
{paged.map((org, idx) => (
|
{paged.map((org, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={org.id}
|
key={org.id}
|
||||||
className={`border-b border-stroke/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
className={`border-b border-stroke hover:bg-white/[0.02] cursor-pointer transition-colors`}
|
||||||
selectedOrg?.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleSelectOrg(org);
|
handleSelectOrg(org);
|
||||||
setViewMode('map');
|
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}
|
{(safePage - 1) * pageSize + idx + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2.5 py-2">
|
<td className="px-2.5 py-2">
|
||||||
<span
|
<span className="text-caption text-color-accent font-korean">{org.type}</span>
|
||||||
className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
|
||||||
>
|
|
||||||
{org.type}
|
|
||||||
</span>
|
|
||||||
</td>
|
</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)}
|
{regionShort(org.jurisdiction)}
|
||||||
</td>
|
</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}
|
{org.name}
|
||||||
</td>
|
</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}
|
{org.address}
|
||||||
</td>
|
</td>
|
||||||
<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}척
|
{org.vessel}척
|
||||||
</td>
|
</td>
|
||||||
<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}대
|
{org.skimmer}대
|
||||||
</td>
|
</td>
|
||||||
<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}대
|
{org.pump}대
|
||||||
</td>
|
</td>
|
||||||
<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}대
|
{org.vehicle}대
|
||||||
</td>
|
</td>
|
||||||
<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}대
|
{org.sprayer}대
|
||||||
</td>
|
</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}
|
{org.totalAssets}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -287,7 +280,7 @@ function AssetManagement() {
|
|||||||
|
|
||||||
{/* Totals Summary */}
|
{/* Totals Summary */}
|
||||||
<div className="flex items-center justify-end gap-4 px-4 py-2 border-t border-stroke bg-bg-base/80">
|
<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}개 기관)
|
합계 ({filtered.length}개 기관)
|
||||||
</span>
|
</span>
|
||||||
{[
|
{[
|
||||||
@ -332,15 +325,15 @@ function AssetManagement() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.key}
|
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
|
<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}
|
{t.label}
|
||||||
</span>
|
</span>
|
||||||
<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.value.toLocaleString()}
|
||||||
{t.unit}
|
{t.unit}
|
||||||
@ -352,7 +345,7 @@ function AssetManagement() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-stroke bg-bg-base">
|
<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">{filtered.length}</span>건 중{' '}
|
||||||
<span className="font-semibold text-fg-sub">
|
<span className="font-semibold text-fg-sub">
|
||||||
{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}
|
{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}
|
||||||
@ -362,14 +355,14 @@ function AssetManagement() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(1)}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={safePage <= 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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={safePage <= 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>
|
||||||
@ -377,9 +370,9 @@ function AssetManagement() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setCurrentPage(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
|
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'
|
: 'border border-stroke bg-bg-card text-fg-disabled hover:bg-bg-surface-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -389,14 +382,14 @@ function AssetManagement() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={safePage >= 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>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
disabled={safePage >= 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>
|
</button>
|
||||||
@ -423,10 +416,10 @@ function AssetManagement() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b border-stroke">
|
<div className="p-4 border-b border-stroke">
|
||||||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
<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}
|
{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-fg-disabled font-korean">
|
<div className="text-label-2 text-fg-disabled font-korean">
|
||||||
{selectedOrg.address}
|
{selectedOrg.address}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -437,7 +430,7 @@ function AssetManagement() {
|
|||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setDetailTab(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
|
detailTab === t
|
||||||
? 'text-color-accent border-color-accent'
|
? 'text-color-accent border-color-accent'
|
||||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||||
@ -449,7 +442,10 @@ function AssetManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 */}
|
{/* Summary */}
|
||||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
<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"
|
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-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}
|
{s.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -498,10 +494,10 @@ function AssetManagement() {
|
|||||||
key={ci}
|
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"
|
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}
|
{cat.icon} {cat.category}
|
||||||
</span>
|
</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-color-accent">{cat.count}</span>
|
||||||
<span className="text-fg-disabled font-normal ml-0.5">{unit}</span>
|
<span className="text-fg-disabled font-normal ml-0.5">{unit}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -528,7 +524,7 @@ function AssetManagement() {
|
|||||||
].map(([k, v], i) => (
|
].map(([k, v], i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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="text-fg-disabled font-korean">{k}</span>
|
||||||
<span className="font-mono font-semibold text-fg">{v}</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="flex flex-col gap-2">
|
||||||
{/* 기관 기본 정보 */}
|
{/* 기관 기본 정보 */}
|
||||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
<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>
|
</div>
|
||||||
{[
|
{[
|
||||||
@ -553,7 +549,7 @@ function AssetManagement() {
|
|||||||
].map(([k, v], j) => (
|
].map(([k, v], j) => (
|
||||||
<div
|
<div
|
||||||
key={j}
|
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 className="text-fg-disabled font-korean shrink-0 mr-2">{k}</span>
|
||||||
<span
|
<span
|
||||||
@ -568,7 +564,7 @@ function AssetManagement() {
|
|||||||
{/* 담당자 목록 */}
|
{/* 담당자 목록 */}
|
||||||
{selectedOrg.contacts.length > 0 && (
|
{selectedOrg.contacts.length > 0 && (
|
||||||
<div className="bg-bg-card border border-stroke rounded-sm p-3">
|
<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>
|
</div>
|
||||||
{selectedOrg.contacts.map((c, i) => (
|
{selectedOrg.contacts.map((c, i) => (
|
||||||
@ -582,7 +578,7 @@ function AssetManagement() {
|
|||||||
.map(([k, v], j) => (
|
.map(([k, v], j) => (
|
||||||
<div
|
<div
|
||||||
key={j}
|
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 className="text-fg-disabled font-korean">{k}</span>
|
||||||
<span
|
<span
|
||||||
@ -604,19 +600,14 @@ function AssetManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
<div className="p-3.5 border-t border-stroke flex gap-2">
|
{/* <div className="p-3.5 border-t border-stroke flex gap-2">
|
||||||
<button
|
<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">
|
||||||
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))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📥 다운로드
|
📥 다운로드
|
||||||
</button>
|
</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 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>
|
</button>
|
||||||
</div>
|
</div> */}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
import { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, useControl, useMap } from '@vis.gl/react-maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import { ScatterplotLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer } from '@deck.gl/layers';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
|
||||||
import { S57EncOverlay } from '@common/components/map/S57EncOverlay'
|
import { S57EncOverlay } from '@common/components/map/S57EncOverlay';
|
||||||
import { useMapStore } from '@common/store/mapStore'
|
import { useMapStore } from '@common/store/mapStore';
|
||||||
import type { AssetOrgCompat } from '../services/assetsApi'
|
import type { AssetOrgCompat } from '../services/assetsApi';
|
||||||
import { typeColor } from './assetTypes'
|
import { typeColor } from './assetTypes';
|
||||||
import { hexToRgba } from '@common/components/map/mapUtils'
|
import { hexToRgba } from '@common/components/map/mapUtils';
|
||||||
|
|
||||||
// ── DeckGLOverlay ──────────────────────────────────────
|
// ── DeckGLOverlay ──────────────────────────────────────
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||||||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }));
|
||||||
overlay.setProps({ layers })
|
overlay.setProps({ layers });
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FlyTo Controller ────────────────────────────────────
|
// ── FlyTo Controller ────────────────────────────────────
|
||||||
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) {
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap();
|
||||||
const prevIdRef = useRef<number | undefined>(undefined)
|
const prevIdRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return
|
if (!map) return;
|
||||||
if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) {
|
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
|
prevIdRef.current = selectedOrg.id;
|
||||||
}, [map, selectedOrg])
|
}, [map, selectedOrg]);
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssetMapProps {
|
interface AssetMapProps {
|
||||||
organizations: AssetOrgCompat[]
|
organizations: AssetOrgCompat[];
|
||||||
selectedOrg: AssetOrgCompat
|
selectedOrg: AssetOrgCompat;
|
||||||
onSelectOrg: (o: AssetOrgCompat) => void
|
onSelectOrg: (o: AssetOrgCompat) => void;
|
||||||
regionFilter: string
|
regionFilter: string;
|
||||||
onRegionFilterChange: (v: string) => void
|
onRegionFilterChange: (v: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetMap({
|
function AssetMap({
|
||||||
@ -49,15 +49,15 @@ function AssetMap({
|
|||||||
regionFilter,
|
regionFilter,
|
||||||
onRegionFilterChange,
|
onRegionFilterChange,
|
||||||
}: AssetMapProps) {
|
}: AssetMapProps) {
|
||||||
const currentMapStyle = useBaseMapStyle()
|
const currentMapStyle = useBaseMapStyle();
|
||||||
const mapToggles = useMapStore((s) => s.mapToggles)
|
const mapToggles = useMapStore((s) => s.mapToggles);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(org: AssetOrgCompat) => {
|
(org: AssetOrgCompat) => {
|
||||||
onSelectOrg(org)
|
onSelectOrg(org);
|
||||||
},
|
},
|
||||||
[onSelectOrg],
|
[onSelectOrg],
|
||||||
)
|
);
|
||||||
|
|
||||||
const markerLayer = useMemo(() => {
|
const markerLayer = useMemo(() => {
|
||||||
return new ScatterplotLayer({
|
return new ScatterplotLayer({
|
||||||
@ -65,19 +65,19 @@ function AssetMap({
|
|||||||
data: orgs,
|
data: orgs,
|
||||||
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
getPosition: (d: AssetOrgCompat) => [d.lng, d.lat],
|
||||||
getRadius: (d: AssetOrgCompat) => {
|
getRadius: (d: AssetOrgCompat) => {
|
||||||
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7
|
const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7;
|
||||||
const isSelected = selectedOrg.id === d.id
|
const isSelected = selectedOrg.id === d.id;
|
||||||
return isSelected ? baseRadius + 4 : baseRadius
|
return isSelected ? baseRadius + 4 : baseRadius;
|
||||||
},
|
},
|
||||||
getFillColor: (d: AssetOrgCompat) => {
|
getFillColor: (d: AssetOrgCompat) => {
|
||||||
const tc = typeColor(d.type)
|
const tc = typeColor(d.type);
|
||||||
const isSelected = selectedOrg.id === d.id
|
const isSelected = selectedOrg.id === d.id;
|
||||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178)
|
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178);
|
||||||
},
|
},
|
||||||
getLineColor: (d: AssetOrgCompat) => {
|
getLineColor: (d: AssetOrgCompat) => {
|
||||||
const tc = typeColor(d.type)
|
const tc = typeColor(d.type);
|
||||||
const isSelected = selectedOrg.id === d.id
|
const isSelected = selectedOrg.id === d.id;
|
||||||
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200)
|
return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200);
|
||||||
},
|
},
|
||||||
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2),
|
||||||
stroked: true,
|
stroked: true,
|
||||||
@ -86,7 +86,7 @@ function AssetMap({
|
|||||||
radiusUnits: 'pixels',
|
radiusUnits: 'pixels',
|
||||||
pickable: true,
|
pickable: true,
|
||||||
onClick: (info: { object?: AssetOrgCompat }) => {
|
onClick: (info: { object?: AssetOrgCompat }) => {
|
||||||
if (info.object) handleClick(info.object)
|
if (info.object) handleClick(info.object);
|
||||||
},
|
},
|
||||||
updateTriggers: {
|
updateTriggers: {
|
||||||
getRadius: [selectedOrg.id],
|
getRadius: [selectedOrg.id],
|
||||||
@ -94,8 +94,8 @@ function AssetMap({
|
|||||||
getLineColor: [selectedOrg.id],
|
getLineColor: [selectedOrg.id],
|
||||||
getLineWidth: [selectedOrg.id],
|
getLineWidth: [selectedOrg.id],
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}, [orgs, selectedOrg, handleClick])
|
}, [orgs, selectedOrg, handleClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
@ -119,13 +119,13 @@ function AssetMap({
|
|||||||
{ value: '중부', label: '중부청' },
|
{ value: '중부', label: '중부청' },
|
||||||
{ value: '동해', label: '동해청' },
|
{ value: '동해', label: '동해청' },
|
||||||
{ value: '제주', label: '제주청' },
|
{ value: '제주', label: '제주청' },
|
||||||
].map(r => (
|
].map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.value}
|
key={r.value}
|
||||||
onClick={() => onRegionFilterChange(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
|
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'
|
: 'bg-bg-base/80 text-fg-sub border border-stroke hover:bg-bg-surface-hover/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -136,28 +136,31 @@ function AssetMap({
|
|||||||
|
|
||||||
{/* Legend overlay */}
|
{/* 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="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">
|
<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
|
||||||
<span className="text-[10px] text-fg-sub font-korean">{item.label}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AssetMap
|
export default AssetMap;
|
||||||
|
|||||||
@ -2,15 +2,11 @@ interface TheoryItem {
|
|||||||
title: string;
|
title: string;
|
||||||
source: string;
|
source: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
tags?: { label: string; color: string }[];
|
tags?: string[];
|
||||||
highlight?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TheorySection {
|
interface TheorySection {
|
||||||
icon: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
color: string;
|
|
||||||
bgTint: string;
|
|
||||||
items: TheoryItem[];
|
items: TheoryItem[];
|
||||||
dividerAfter?: number;
|
dividerAfter?: number;
|
||||||
dividerLabel?: string;
|
dividerLabel?: string;
|
||||||
@ -18,10 +14,7 @@ interface TheorySection {
|
|||||||
|
|
||||||
const THEORY_SECTIONS: TheorySection[] = [
|
const THEORY_SECTIONS: TheorySection[] = [
|
||||||
{
|
{
|
||||||
icon: '🚢',
|
|
||||||
title: '방제선 성능 기준',
|
title: '방제선 성능 기준',
|
||||||
color: 'var(--color-info)',
|
|
||||||
bgTint: 'rgba(59,130,246,.08)',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: '해양경찰청 방제선 성능기준 고시',
|
title: '해양경찰청 방제선 성능기준 고시',
|
||||||
@ -43,10 +36,7 @@ const THEORY_SECTIONS: TheorySection[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🪢',
|
|
||||||
title: '오일펜스·흡착재 규격',
|
title: '오일펜스·흡착재 규격',
|
||||||
color: 'var(--boom, #f59e0b)',
|
|
||||||
bgTint: 'rgba(245,158,11,.08)',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
|
title: 'ASTM F625 — Standard Guide for Selecting Mechanical Oil Spill Equipment',
|
||||||
@ -61,12 +51,9 @@ const THEORY_SECTIONS: TheorySection[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '⚙️',
|
|
||||||
title: '방제자원 배치·동원 이론',
|
title: '방제자원 배치·동원 이론',
|
||||||
color: 'var(--color-tertiary)',
|
|
||||||
bgTint: 'rgba(168,85,247,.08)',
|
|
||||||
dividerAfter: 2,
|
dividerAfter: 2,
|
||||||
dividerLabel: '📐 최적화 수리모델 참고문헌',
|
dividerLabel: '최적화 수리모델 참고문헌',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title:
|
title:
|
||||||
@ -74,7 +61,6 @@ const THEORY_SECTIONS: TheorySection[] = [
|
|||||||
source:
|
source:
|
||||||
'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
|
'Xu, Y. et al. | Ningbo Univ. | Systems 2025, 13, 716 · DOI: 10.3390/systems13080716',
|
||||||
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
|
desc: 'IMOGWO 다목적 최적화 · 스케줄링 시간+경제·생태손실 동시 최소화 · 동적 오일필름 기반 방제정 라우팅',
|
||||||
highlight: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
|
title: 'Dynamic Resource Allocation to Support Oil Spill Response Planning',
|
||||||
@ -92,54 +78,32 @@ const THEORY_SECTIONS: TheorySection[] = [
|
|||||||
source:
|
source:
|
||||||
'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
|
'Das, T., Goerlandt, F. & Pelot, R. | Multimodal Transportation Vol.3 No.1, 100110, 2023',
|
||||||
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
desc: '혼합정수계획법으로 응급 방제자원 거점 위치 선택 + 자원 할당 동시 최적화. 비용·응답시간 트레이드오프 파레토 분석.',
|
||||||
highlight: true,
|
tags: ['MIP 수리모델', '자원 위치 선택', '북극해 적용'],
|
||||||
tags: [
|
|
||||||
{ label: 'MIP 수리모델', color: 'var(--color-tertiary)' },
|
|
||||||
{ label: '자원 위치 선택', color: 'var(--color-info)' },
|
|
||||||
{ label: '북극해 적용', color: 'var(--color-accent)' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
|
title: '유전알고리즘을 이용하여 최적화된 방제자원 배치안의 분포도 분석',
|
||||||
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.11–16, 2020',
|
source: '김혜진, 김용혁 | 한국융합학회논문지 Vol.11 No.4, pp.11–16, 2020',
|
||||||
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
desc: 'GA(유전알고리즘)로 방제자원 배치 최적화 및 시뮬레이션 분포도 분석. 국내 해역 실정에 맞는 자원 배치 패턴 도출.',
|
||||||
highlight: true,
|
tags: ['GA 메타휴리스틱', '국내 연구', '배치 분포도 분석'],
|
||||||
tags: [
|
|
||||||
{ label: 'GA 메타휴리스틱', color: 'var(--color-tertiary)' },
|
|
||||||
{ label: '국내 연구', color: 'var(--green, #22c55e)' },
|
|
||||||
{ label: '배치 분포도 분석', color: 'var(--boom, #f59e0b)' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title:
|
title:
|
||||||
'A Two-Stage Stochastic Optimization Framework for Environmentally Sensitive Oil Spill Response Resource Allocation',
|
'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',
|
source: 'Rahman, M.A., Kuhel, M.T. & Novoa, C. | arXiv preprint arXiv:2511.22218, 2025',
|
||||||
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
|
desc: '확률적 MILP 2단계 프레임워크로 불확실성 포함 최적 자원 배치. 환경민감구역 가중치 반영.',
|
||||||
highlight: true,
|
tags: ['확률적 MILP', '2단계 최적화', '환경민감구역'],
|
||||||
tags: [
|
|
||||||
{ label: '확률적 MILP', color: 'var(--color-tertiary)' },
|
|
||||||
{ label: '2단계 최적화', color: 'var(--color-info)' },
|
|
||||||
{ label: '환경민감구역', color: 'var(--green, #22c55e)' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title:
|
title:
|
||||||
'Mixed-Integer Dynamic Optimization for Oil-Spill Response Planning with Integration of Dynamic Oil Weathering Model',
|
'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',
|
source: 'You, F. & Leyffer, S. | Argonne National Laboratory Technical Note, 2008',
|
||||||
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
|
desc: '동적 최적화(MINLP/MILP) 프레임워크로 오일스필 대응 스케줄링 + 오일 풍화·거동 물리모델 통합.',
|
||||||
highlight: true,
|
tags: ['MINLP 동적 최적화', '오일 풍화 모델 통합'],
|
||||||
tags: [
|
|
||||||
{ label: 'MINLP 동적 최적화', color: 'var(--color-tertiary)' },
|
|
||||||
{ label: '오일 풍화 모델 통합', color: 'var(--boom, #f59e0b)' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🗄',
|
|
||||||
title: '자산 현행화·데이터 관리',
|
title: '자산 현행화·데이터 관리',
|
||||||
color: 'var(--green, #22c55e)',
|
|
||||||
bgTint: 'rgba(34,197,94,.08)',
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: '해양오염방제자원 현황관리 지침',
|
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 }) {
|
function TheoryCard({ section }: { section: TheorySection }) {
|
||||||
const badgeBg = section.bgTint.replace(/[\d.]+\)$/, '0.15)');
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden">
|
<div className="bg-bg-card border border-stroke rounded-md overflow-hidden">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div
|
<div className="px-4 py-3 border-b border-stroke">
|
||||||
className="px-4 py-3 border-b border-stroke flex items-center gap-2"
|
<span className="text-label-1 font-semibold text-fg font-korean">{section.title}</span>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Items */}
|
{/* 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) => (
|
{section.items.map((item, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
{section.dividerAfter !== undefined && i === section.dividerAfter + 1 && (
|
||||||
<div
|
<div className="mt-1 mb-3 pt-2 border-t border-dashed border-stroke">
|
||||||
className="mt-1 mb-3 pt-2"
|
<div className="text-caption font-semibold text-fg mb-1.5">
|
||||||
style={{ borderTop: '1px dashed var(--stroke-default)' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-[8px] font-bold mb-1.5 opacity-70"
|
|
||||||
style={{ color: section.color }}
|
|
||||||
>
|
|
||||||
{section.dividerLabel}
|
{section.dividerLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="px-2.5 py-2 bg-bg-base rounded-md">
|
||||||
className="grid gap-2 px-2.5 py-2 bg-bg-base rounded-md"
|
<div className="font-semibold mb-0.5">{item.title}</div>
|
||||||
style={{
|
<div className="text-fg-disabled leading-[1.6]">{item.source}</div>
|
||||||
gridTemplateColumns: '24px 1fr',
|
{item.tags && <div className="mt-0.5 text-fg-disabled">{item.tags.join(' | ')}</div>}
|
||||||
borderLeft: item.highlight ? `2px solid ${section.color}` : undefined,
|
<div className="mt-0.5 text-fg-sub">{item.desc}</div>
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -263,8 +155,8 @@ function TheoryCard({ section }: { section: TheorySection }) {
|
|||||||
function AssetTheory() {
|
function AssetTheory() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="text-[18px] font-bold mb-1">📚 방제자원 이론</div>
|
<div className="text-title-1 font-bold mb-1">📚 방제자원 이론</div>
|
||||||
<div className="text-xs text-fg-disabled mb-6">
|
<div className="text-caption text-fg-disabled mb-6">
|
||||||
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
방제자산 운용 기준·성능 이론 및 관련 법령·고시 근거 문헌
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -22,19 +22,19 @@ function AssetUpload() {
|
|||||||
<div className="flex gap-8 h-full overflow-auto">
|
<div className="flex gap-8 h-full overflow-auto">
|
||||||
{/* Left - Upload */}
|
{/* Left - Upload */}
|
||||||
<div className="flex-1 max-w-[580px]">
|
<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 */}
|
{/* 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-4xl mb-2.5 opacity-50">📁</div>
|
||||||
<div className="text-sm font-semibold mb-1.5 font-korean">
|
<div className="text-sm font-semibold mb-1.5 font-korean">
|
||||||
파일을 드래그하거나 클릭하여 업로드
|
파일을 드래그하거나 클릭하여 업로드
|
||||||
</div>
|
</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
|
엑셀(.xlsx), CSV 파일 지원 · 최대 10MB
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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)' }}
|
style={{ background: 'linear-gradient(135deg, var(--color-info), #2563eb)' }}
|
||||||
>
|
>
|
||||||
파일 선택
|
파일 선택
|
||||||
@ -120,7 +120,7 @@ function AssetUpload() {
|
|||||||
{/* Right - Permission & History */}
|
{/* Right - Permission & History */}
|
||||||
<div className="flex-1 max-w-[480px]">
|
<div className="flex-1 max-w-[480px]">
|
||||||
{/* Permission System */}
|
{/* 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">
|
<div className="flex flex-col gap-2 mb-7">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@ -164,14 +164,14 @@ function AssetUpload() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs font-bold font-korean ${p.color}`}>{p.role}</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload History */}
|
{/* 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">
|
<div className="flex flex-col gap-2">
|
||||||
{uploadHistory.map((h) => (
|
{uploadHistory.map((h) => (
|
||||||
<div
|
<div
|
||||||
@ -180,11 +180,11 @@ function AssetUpload() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold font-korean">{h.fileNm}</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}건
|
{new Date(h.regDtm).toLocaleString('ko-KR')} · {h.uploaderNm} · {h.uploadCnt}건
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</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 items-center justify-between border-b border-stroke bg-bg-surface shrink-0">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[
|
{[
|
||||||
{ id: 'management' as const, icon: '🗂', label: '자산 관리' },
|
{ id: 'management' as const, label: '자산 관리' },
|
||||||
// { id: 'upload' as const, icon: '📤', label: '자산 현행화 (업로드)' },
|
// { id: 'upload' as const, label: '자산 현행화 (업로드)' },
|
||||||
{ id: 'theory' as const, icon: '📚', label: '방제자원 이론' },
|
{ id: 'theory' as const, label: '방제자원 이론' },
|
||||||
{ id: 'insurance' as const, icon: '🛡', label: '선박 보험정보' },
|
{ id: 'insurance' as const, label: '선박 보험정보' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@ -33,16 +33,16 @@ export function AssetsView() {
|
|||||||
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
: 'text-fg-disabled border-transparent hover:text-fg-sub'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.icon} {tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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: 'rgba(59,130,246,0.3)' }}
|
style={{ borderColor: 'color-mix(in srgb, var(--color-info) 30%, transparent)' }}
|
||||||
>
|
>
|
||||||
👤 남해청_방제과 (수정 권한 ✅)
|
👤 남해청_방제과 (수정 권한 ✅)
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@ -60,10 +60,12 @@ function ShipInsurance() {
|
|||||||
const isY = yn === 'Y';
|
const isY = yn === 'Y';
|
||||||
return (
|
return (
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: isY ? 'rgba(34,197,94,.15)' : 'rgba(100,116,139,.1)',
|
background: isY
|
||||||
color: isY ? 'var(--color-success)' : 'var(--text-3)',
|
? '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'}
|
{isY ? 'Y' : 'N'}
|
||||||
@ -131,17 +133,31 @@ function ShipInsurance() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-auto">
|
<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 className="flex items-start justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2.5 mb-1">
|
<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
|
<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={{
|
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)',
|
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
|
<span
|
||||||
@ -158,23 +174,13 @@ function ShipInsurance() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
onClick={() => window.open('https://www.haewoon.or.kr', '_blank', 'noopener')}
|
||||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
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)]"
|
||||||
style={{
|
|
||||||
background: 'rgba(59,130,246,.12)',
|
|
||||||
color: 'var(--color-info)',
|
|
||||||
border: '1px solid rgba(59,130,246,.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
한국해운조합 API
|
한국해운조합 API
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
onClick={() => window.open('https://new.portmis.go.kr', '_blank', 'noopener')}
|
||||||
className="px-4 py-2 text-[11px] font-bold cursor-pointer rounded-sm"
|
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)]"
|
||||||
style={{
|
|
||||||
background: 'rgba(168,85,247,.12)',
|
|
||||||
color: 'var(--color-tertiary)',
|
|
||||||
border: '1px solid rgba(168,85,247,.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
PortMIS
|
PortMIS
|
||||||
</button>
|
</button>
|
||||||
@ -182,10 +188,10 @@ function ShipInsurance() {
|
|||||||
</div>
|
</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 gap-2.5 items-end flex-wrap">
|
||||||
<div className="flex-1 min-w-[200px]">
|
<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/선주)
|
검색 (선박명/호출부호/IMO/선주)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -198,7 +204,7 @@ function ShipInsurance() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -212,7 +218,7 @@ function ShipInsurance() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
@ -237,28 +243,20 @@ function ShipInsurance() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-5 py-2 text-white border-none rounded-sm text-xs font-bold cursor-pointer"
|
className="px-4 py-2 bg-bg-base text-fg border border-stroke rounded-sm text-xs cursor-pointer"
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, var(--color-accent), var(--color-info))',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
조회
|
조회
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={total === 0}
|
disabled={total === 0}
|
||||||
className="px-4 py-2 text-xs font-bold cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default"
|
className="px-4 py-2 text-xs cursor-pointer rounded-sm disabled:opacity-30 disabled:cursor-default bg-bg-base border border-stroke"
|
||||||
style={{
|
|
||||||
background: 'rgba(34,197,94,.12)',
|
|
||||||
color: 'var(--color-success)',
|
|
||||||
border: '1px solid rgba(34,197,94,.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
엑셀 다운로드
|
엑셀 다운로드
|
||||||
</button>
|
</button>
|
||||||
@ -276,7 +274,7 @@ function ShipInsurance() {
|
|||||||
animation: 'spin 0.8s linear infinite',
|
animation: 'spin 0.8s linear infinite',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="text-[13px] text-fg-sub">보험 데이터 조회 중...</div>
|
<div className="text-title-4 text-fg-sub">보험 데이터 조회 중...</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -291,7 +289,7 @@ function ShipInsurance() {
|
|||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
{!isLoading && !error && (
|
{!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="flex items-center justify-between px-4 py-3 border-b border-stroke">
|
||||||
<div className="text-xs font-bold">
|
<div className="text-xs font-bold">
|
||||||
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
조회 결과 <span className="text-color-accent">{total.toLocaleString()}</span>건
|
||||||
@ -303,7 +301,7 @@ function ShipInsurance() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<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>
|
<thead>
|
||||||
<tr className="bg-bg-base">
|
<tr className="bg-bg-base">
|
||||||
{[
|
{[
|
||||||
@ -342,18 +340,22 @@ function ShipInsurance() {
|
|||||||
<tr
|
<tr
|
||||||
key={r.insSn}
|
key={r.insSn}
|
||||||
className="border-b border-stroke"
|
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">
|
<td className="px-3 py-2 text-center text-fg-disabled font-mono">
|
||||||
{rowNum}
|
{rowNum}
|
||||||
</td>
|
</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.callSign || '—'}</td>
|
||||||
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
<td className="px-3 py-2 text-center font-mono">{r.imoNo || '—'}</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
<span className="text-[10px]">{r.shipTp}</span>
|
<span className="text-caption">{r.shipTp}</span>
|
||||||
{r.shipTpDetail && (
|
{r.shipTpDetail && (
|
||||||
<span className="text-fg-disabled text-[9px] ml-1">
|
<span className="text-fg-disabled text-caption ml-1">
|
||||||
({r.shipTpDetail})
|
({r.shipTpDetail})
|
||||||
</span>
|
</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.fuelOilYn)}</td>
|
||||||
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
<td className="px-3 py-2 text-center">{ynBadge(r.wreckRemovalYn)}</td>
|
||||||
<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={{
|
style={{
|
||||||
color: isExp
|
color: isExp
|
||||||
? 'var(--color-danger)'
|
? 'var(--color-danger)'
|
||||||
: isSoon
|
: isSoon
|
||||||
? 'var(--color-caution)'
|
? 'var(--color-caution)'
|
||||||
: undefined,
|
: undefined,
|
||||||
fontWeight: isExp || isSoon ? 700 : undefined,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{r.validStart} ~ {r.validEnd}
|
{r.validStart} ~ {r.validEnd}
|
||||||
</td>
|
</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">
|
<td className="px-3 py-2 text-center">
|
||||||
<span
|
<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={{
|
style={{
|
||||||
background: isExp
|
background: isExp
|
||||||
? 'rgba(239,68,68,.15)'
|
? 'color-mix(in srgb, var(--color-danger) 15%, transparent)'
|
||||||
: isSoon
|
: isSoon
|
||||||
? 'rgba(234,179,8,.15)'
|
? 'color-mix(in srgb, var(--color-caution) 15%, transparent)'
|
||||||
: 'rgba(34,197,94,.15)',
|
: 'color-mix(in srgb, var(--color-success) 15%, transparent)',
|
||||||
color: isExp
|
color: isExp
|
||||||
? 'var(--color-danger)'
|
? 'var(--color-danger)'
|
||||||
: isSoon
|
: isSoon
|
||||||
@ -414,7 +415,7 @@ function ShipInsurance() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => loadData(page - 1)}
|
onClick={() => loadData(page - 1)}
|
||||||
disabled={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>
|
</button>
|
||||||
@ -426,13 +427,7 @@ function ShipInsurance() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => loadData(p)}
|
onClick={() => loadData(p)}
|
||||||
className="w-8 h-8 text-[11px] rounded-sm border cursor-pointer font-mono"
|
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'}`}
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
@ -441,7 +436,7 @@ function ShipInsurance() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => loadData(page + 1)}
|
onClick={() => loadData(page + 1)}
|
||||||
disabled={page >= totalPages}
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,18 +34,19 @@ export interface InsuranceRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const typeTagCls = (type: string) => {
|
export const typeTagCls = (type: string) => {
|
||||||
if (type === '해경관할') return 'bg-[rgba(239,68,68,0.1)] text-color-danger';
|
if (type === '해경관할')
|
||||||
if (type === '해경경찰서') return 'bg-[rgba(59,130,246,0.1)] text-color-info';
|
return 'bg-[color-mix(in_srgb,var(--color-danger)_10%,transparent)] text-color-danger';
|
||||||
if (type === '파출소') return 'bg-[rgba(34,197,94,0.1)] text-color-success';
|
if (type === '해경경찰서')
|
||||||
if (type === '관련기관') return 'bg-[rgba(168,85,247,0.1)] text-color-tertiary';
|
return 'bg-[color-mix(in_srgb,var(--color-info)_10%,transparent)] text-color-info';
|
||||||
if (type === '해양환경공단') return 'bg-[rgba(6,182,212,0.1)] text-color-accent';
|
if (type === '파출소')
|
||||||
if (type === '업체') return 'bg-[rgba(245,158,11,0.1)] text-color-warning';
|
return 'bg-[color-mix(in_srgb,var(--color-success)_10%,transparent)] text-color-success';
|
||||||
if (type === '지자체') return 'bg-[rgba(236,72,153,0.1)] text-[#ec4899]';
|
if (type === '관련기관')
|
||||||
if (type === '기름저장시설') return 'bg-[rgba(139,92,246,0.1)] text-[#8b5cf6]';
|
return 'bg-[color-mix(in_srgb,var(--color-tertiary)_10%,transparent)] text-color-tertiary';
|
||||||
if (type === '정유사') return 'bg-[rgba(20,184,166,0.1)] text-[#14b8a6]';
|
if (type === '해양환경공단')
|
||||||
if (type === '해군') return 'bg-[rgba(100,116,139,0.1)] text-[#64748b]';
|
return 'bg-[color-mix(in_srgb,var(--color-accent)_10%,transparent)] text-color-accent';
|
||||||
if (type === '기타') return 'bg-[rgba(107,114,128,0.1)] text-[#6b7280]';
|
if (type === '업체')
|
||||||
return 'bg-[rgba(156,163,175,0.1)] text-[#9ca3af]';
|
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) => {
|
export const typeColor = (type: string) => {
|
||||||
|
|||||||
@ -10,12 +10,12 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
MANUAL: '해경매뉴얼',
|
MANUAL: '해경매뉴얼',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카테고리별 배지 색상
|
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지는 중립)
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
NOTICE: 'bg-red-500/20 text-red-400',
|
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
|
||||||
DATA: 'bg-green-500/20 text-green-400',
|
DATA: 'bg-bg-elevated text-fg-sub',
|
||||||
QNA: 'bg-purple-500/20 text-purple-400',
|
QNA: 'bg-bg-elevated text-fg-sub',
|
||||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BoardDetailViewProps {
|
interface BoardDetailViewProps {
|
||||||
@ -55,7 +55,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
|||||||
if (isLoading || !post) {
|
if (isLoading || !post) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
<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>
|
</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">
|
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
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>
|
||||||
<span>목록으로</span>
|
<span>목록으로</span>
|
||||||
@ -78,13 +78,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
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>
|
</button>
|
||||||
@ -99,20 +99,20 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
|||||||
<div className="pb-6 border-b border-stroke">
|
<div className="pb-6 border-b border-stroke">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span
|
<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}
|
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||||
</span>
|
</span>
|
||||||
{post.pinnedYn === 'Y' && (
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-fg mb-4">{post.title}</h1>
|
<h1 className="text-title-1 font-bold text-fg mb-4">{post.title}</h1>
|
||||||
<div className="flex items-center gap-4 text-sm text-fg-disabled">
|
<div className="flex items-center gap-4 text-label-1 text-fg-disabled">
|
||||||
<span>
|
<span>
|
||||||
작성자: <span className="text-fg-sub font-semibold">{post.authorName}</span>
|
작성자: <span className="text-fg-sub">{post.authorName}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>|</span>
|
<span>|</span>
|
||||||
<span>작성일: {new Date(post.regDtm).toLocaleDateString('ko-KR')}</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="py-8">
|
||||||
<div className="prose prose-invert max-w-none">
|
<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 || '(내용 없음)'}
|
{post.content || '(내용 없음)'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -139,7 +139,7 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
|||||||
{/* 댓글 섹션 (향후 구현 예정) */}
|
{/* 댓글 섹션 (향후 구현 예정) */}
|
||||||
<div className="py-6 border-t border-stroke">
|
<div className="py-6 border-t border-stroke">
|
||||||
<div className="text-center py-8">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,10 +18,10 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const CATEGORY_STYLE: Record<string, string> = {
|
const CATEGORY_STYLE: Record<string, string> = {
|
||||||
NOTICE: 'bg-red-500/20 text-red-400',
|
NOTICE: 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] text-color-accent',
|
||||||
DATA: 'bg-blue-500/20 text-blue-400',
|
DATA: 'bg-bg-elevated text-fg-sub',
|
||||||
QNA: 'bg-green-500/20 text-green-400',
|
QNA: 'bg-bg-elevated text-fg-sub',
|
||||||
MANUAL: 'bg-yellow-500/20 text-yellow-400',
|
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@ -104,9 +104,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
<button
|
<button
|
||||||
key={cat.label}
|
key={cat.label}
|
||||||
onClick={() => handleCategoryChange(cat.code)}
|
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
|
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'
|
: '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}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyDown}
|
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 && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={onWriteClick}
|
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>
|
||||||
<span>글쓰기</span>
|
<span>글쓰기</span>
|
||||||
@ -142,27 +142,29 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
<div className="flex-1 overflow-auto px-8 py-6">
|
<div className="flex-1 overflow-auto px-8 py-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-fg-disabled text-sm">불러오는 중...</p>
|
<p className="text-fg-disabled text-label-1">불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-stroke">
|
<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>
|
||||||
<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>
|
||||||
<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-label-1 font-semibold text-fg-sub">
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-fg-sub w-32">
|
제목
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-label-1 font-semibold text-fg-sub w-32">
|
||||||
작성자
|
작성자
|
||||||
</th>
|
</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>
|
||||||
<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>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -174,9 +176,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
onClick={() => onPostClick(post.sn)}
|
onClick={() => onPostClick(post.sn)}
|
||||||
className="border-b border-stroke hover:bg-bg-elevated cursor-pointer transition-colors"
|
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' ? (
|
{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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@ -185,27 +187,23 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
className={`inline-flex items-center px-2.5 py-0.5 rounded text-caption font-semibold ${
|
||||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
CATEGORY_STYLE[post.categoryCd] || 'bg-bg-elevated text-fg-sub'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<span
|
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
|
||||||
className={`text-sm ${
|
|
||||||
post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'
|
|
||||||
} hover:text-color-accent transition-colors`}
|
|
||||||
>
|
|
||||||
{post.title}
|
{post.title}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 text-sm text-fg-sub">{post.authorName}</td>
|
<td className="px-4 py-4 text-label-1 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-disabled">
|
||||||
{formatDate(post.regDtm)}
|
{formatDate(post.regDtm)}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -213,7 +211,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
|
|
||||||
{posts.length === 0 && (
|
{posts.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-fg-disabled text-sm">검색 결과가 없습니다.</p>
|
<p className="text-fg-disabled text-label-1">검색 결과가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -226,7 +224,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page <= 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>
|
</button>
|
||||||
@ -234,9 +232,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(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
|
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'
|
: '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
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page >= totalPages}
|
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>
|
</button>
|
||||||
|
|||||||
@ -33,12 +33,12 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
MANUAL: '해경매뉴얼',
|
MANUAL: '해경매뉴얼',
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카테고리별 배지 색상
|
// 카테고리별 배지 색상 (NOTICE는 danger, 나머지 중립)
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
NOTICE: 'bg-red-500/20 text-red-400',
|
NOTICE: 'bg-[color-mix(in_srgb,var(--color-danger)_15%,transparent)] text-color-danger',
|
||||||
DATA: 'bg-green-500/20 text-green-400',
|
DATA: 'bg-bg-elevated text-fg-sub',
|
||||||
QNA: 'bg-purple-500/20 text-purple-400',
|
QNA: 'bg-bg-elevated text-fg-sub',
|
||||||
MANUAL: 'bg-blue-500/20 text-blue-400',
|
MANUAL: 'bg-bg-elevated text-fg-sub',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@ -91,6 +91,13 @@ export function BoardView() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [activeSubTab]);
|
}, [activeSubTab]);
|
||||||
|
|
||||||
|
// 서브탭 변경 시 목록 화면으로 복귀
|
||||||
|
useEffect(() => {
|
||||||
|
setViewMode('list');
|
||||||
|
setSelectedPostSn(null);
|
||||||
|
setEditingPostSn(null);
|
||||||
|
}, [activeSubTab]);
|
||||||
|
|
||||||
// 상세 보기
|
// 상세 보기
|
||||||
const handlePostClick = (sn: number) => {
|
const handlePostClick = (sn: number) => {
|
||||||
setSelectedPostSn(sn);
|
setSelectedPostSn(sn);
|
||||||
@ -196,19 +203,18 @@ export function BoardView() {
|
|||||||
|
|
||||||
const filteredManuals = manualList;
|
const filteredManuals = manualList;
|
||||||
|
|
||||||
|
// 카테고리별 색상 (방제매뉴얼만 accent, 나머지 중립)
|
||||||
const catColor = (cat: string) => {
|
const catColor = (cat: string) => {
|
||||||
switch (cat) {
|
if (cat === '방제매뉴얼') {
|
||||||
case '방제매뉴얼':
|
return {
|
||||||
return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' };
|
bg: 'color-mix(in srgb, var(--color-accent) 15%, transparent)',
|
||||||
case '대응매뉴얼':
|
text: 'var(--color-accent)',
|
||||||
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' };
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
bg: 'var(--bg-elevated)',
|
||||||
|
text: 'var(--fg-sub)',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeSubTab === 'manual') {
|
if (activeSubTab === 'manual') {
|
||||||
@ -217,38 +223,31 @@ export function BoardView() {
|
|||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<div className="flex flex-col h-full bg-bg-base">
|
<div className="flex flex-col h-full bg-bg-base">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div
|
<div className="flex items-center justify-between px-8 py-4 border-b border-stroke bg-bg-surface">
|
||||||
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 gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg">📘</span>
|
<span className="text-subtitle font-bold">해경매뉴얼</span>
|
||||||
<span className="text-[15px] font-bold">해경매뉴얼</span>
|
<span className="text-caption ml-1 text-fg-disabled">
|
||||||
<span className="text-[10px] ml-1 text-fg-disabled">
|
|
||||||
총 {filteredManuals.length}건
|
총 {filteredManuals.length}건
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 ml-4">
|
<div className="flex gap-1 ml-4">
|
||||||
{manualCategories.map((cat) => (
|
{manualCategories.map((cat) => {
|
||||||
<button
|
const isActive = manualCategory === cat;
|
||||||
key={cat}
|
return (
|
||||||
onClick={() => setManualCategory(cat)}
|
<button
|
||||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
|
key={cat}
|
||||||
style={{
|
onClick={() => setManualCategory(cat)}
|
||||||
background:
|
className={`px-3 py-1.5 text-label-2 font-semibold rounded-md transition-all border ${
|
||||||
manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg-card)',
|
isActive
|
||||||
border:
|
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
|
||||||
manualCategory === cat
|
: 'bg-bg-card border-stroke text-fg-disabled'
|
||||||
? '1px solid rgba(6,182,212,.3)'
|
}`}
|
||||||
: '1px solid var(--stroke-default)',
|
>
|
||||||
color:
|
{cat}
|
||||||
manualCategory === cat ? 'var(--color-accent)' : 'var(--fg-disabled)',
|
</button>
|
||||||
}}
|
);
|
||||||
>
|
})}
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -257,24 +256,13 @@ export function BoardView() {
|
|||||||
placeholder="매뉴얼 검색..."
|
placeholder="매뉴얼 검색..."
|
||||||
value={manualSearch}
|
value={manualSearch}
|
||||||
onChange={(e) => setManualSearch(e.target.value)}
|
onChange={(e) => setManualSearch(e.target.value)}
|
||||||
className="px-4 py-2 text-sm rounded w-64"
|
className="px-4 py-2 text-label-1 rounded w-64 bg-bg-elevated border border-stroke outline-none"
|
||||||
style={{
|
|
||||||
background: 'var(--bg-elevated)',
|
|
||||||
border: '1px solid var(--stroke-default)',
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUploadModal(true)}
|
onClick={() => setShowUploadModal(true)}
|
||||||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
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)]"
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,.15)',
|
|
||||||
border: '1px solid rgba(6,182,212,.3)',
|
|
||||||
color: '#22d3ee',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
📤 새로 업로드
|
새로 업로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -283,7 +271,7 @@ export function BoardView() {
|
|||||||
<div className="flex-1 overflow-auto px-8 py-6">
|
<div className="flex-1 overflow-auto px-8 py-6">
|
||||||
{manualLoading ? (
|
{manualLoading ? (
|
||||||
<div className="text-center py-20">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@ -295,52 +283,29 @@ export function BoardView() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.manualSn}
|
key={file.manualSn}
|
||||||
className="rounded-xl p-4 transition-all"
|
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)]"
|
||||||
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)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span
|
<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 }}
|
style={{ background: cc.bg, color: cc.text }}
|
||||||
>
|
>
|
||||||
{file.catgNm}
|
{file.catgNm}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-caption font-semibold px-2 py-0.5 rounded bg-bg-elevated text-fg-sub">
|
||||||
className="text-[10px] font-semibold px-2 py-0.5 rounded"
|
|
||||||
style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}
|
|
||||||
>
|
|
||||||
{file.version}
|
{file.version}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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-2 mb-3">
|
||||||
<div
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-bg-elevated">
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded"
|
<span className="text-caption font-semibold text-fg-sub">
|
||||||
style={{ background: 'rgba(239,68,68,.08)' }}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12 }}>📄</span>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-semibold"
|
|
||||||
style={{ color: '#ef4444' }}
|
|
||||||
>
|
|
||||||
{file.fileTp || 'PDF'}
|
{file.fileTp || 'PDF'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
className="text-[10px]"
|
|
||||||
style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}
|
|
||||||
>
|
|
||||||
{file.fileSz}
|
{file.fileSz}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -358,16 +323,10 @@ export function BoardView() {
|
|||||||
});
|
});
|
||||||
setShowUploadModal(true);
|
setShowUploadModal(true);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
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"
|
||||||
style={{
|
|
||||||
background: 'rgba(59,130,246,.1)',
|
|
||||||
border: '1px solid rgba(59,130,246,.2)',
|
|
||||||
color: '#3b82f6',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="수정"
|
title="수정"
|
||||||
>
|
>
|
||||||
✏️ 수정
|
수정
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
@ -384,34 +343,19 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
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"
|
||||||
style={{
|
|
||||||
background: 'rgba(239,68,68,.1)',
|
|
||||||
border: '1px solid rgba(239,68,68,.2)',
|
|
||||||
color: '#ef4444',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
🗑️ 삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex items-center justify-between pt-3 border-t border-stroke">
|
||||||
className="flex items-center justify-between pt-3"
|
<div className="flex items-center gap-3 text-caption text-fg-disabled">
|
||||||
style={{ borderTop: '1px solid var(--stroke-default)' }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 text-[10px] text-fg-disabled">
|
|
||||||
<span>{file.authorNm}</span>
|
<span>{file.authorNm}</span>
|
||||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
className="text-[10px]"
|
|
||||||
style={{
|
|
||||||
color: 'var(--fg-disabled)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⬇ {file.dwnldCnt}
|
⬇ {file.dwnldCnt}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@ -457,15 +401,9 @@ export function BoardView() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-1 rounded text-[10px] font-semibold transition-all"
|
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"
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,.1)',
|
|
||||||
border: '1px solid rgba(6,182,212,.25)',
|
|
||||||
color: '#22d3ee',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
📥 다운로드
|
다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -477,8 +415,7 @@ export function BoardView() {
|
|||||||
|
|
||||||
{!manualLoading && filteredManuals.length === 0 && (
|
{!manualLoading && filteredManuals.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
|
<p className="text-label-1 text-fg-disabled">검색 결과가 없습니다.</p>
|
||||||
<p className="text-sm text-fg-disabled">검색 결과가 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -487,8 +424,7 @@ export function BoardView() {
|
|||||||
{/* 업로드 모달 */}
|
{/* 업로드 모달 */}
|
||||||
{showUploadModal && (
|
{showUploadModal && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[rgba(0,0,0,.55)]"
|
||||||
style={{ background: 'rgba(0,0,0,.55)' }}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUploadModal(false);
|
setShowUploadModal(false);
|
||||||
setEditingManualId(null);
|
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="px-5 py-4 border-b border-stroke flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-base">{editingManualId ? '✏️' : '📤'}</span>
|
<span className="text-label-1 font-bold">
|
||||||
<span className="text-sm font-bold">
|
|
||||||
{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}
|
{editingManualId ? '매뉴얼 수정' : '매뉴얼 업로드'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -510,32 +445,28 @@ export function BoardView() {
|
|||||||
setShowUploadModal(false);
|
setShowUploadModal(false);
|
||||||
setEditingManualId(null);
|
setEditingManualId(null);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer text-fg-disabled text-base leading-none"
|
className="cursor-pointer text-fg-disabled text-label-1 leading-none"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 flex flex-col gap-4">
|
<div className="p-5 flex flex-col gap-4">
|
||||||
<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>
|
</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map((cat) => {
|
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map((cat) => {
|
||||||
const cc = catColor(cat);
|
|
||||||
const isActive = uploadForm.category === cat;
|
const isActive = uploadForm.category === cat;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setUploadForm((prev) => ({ ...prev, category: cat }))}
|
onClick={() => setUploadForm((prev) => ({ ...prev, category: cat }))}
|
||||||
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
|
className={`flex-1 py-2 px-1 rounded-md text-label-2 font-semibold cursor-pointer border ${
|
||||||
style={{
|
isActive
|
||||||
background: isActive ? cc.bg : 'var(--bg-card)',
|
? 'bg-[color-mix(in_srgb,var(--color-accent)_15%,transparent)] border-[color-mix(in_srgb,var(--color-accent)_30%,transparent)] text-color-accent'
|
||||||
border: isActive
|
: 'bg-bg-card border-stroke text-fg-disabled'
|
||||||
? `1px solid ${cc.text}40`
|
}`}
|
||||||
: '1px solid var(--stroke-default)',
|
|
||||||
color: isActive ? cc.text : 'var(--fg-disabled)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@ -544,7 +475,7 @@ export function BoardView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -554,12 +485,11 @@ export function BoardView() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setUploadForm((prev) => ({ ...prev, title: e.target.value }))
|
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"
|
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
|
||||||
style={{ boxSizing: 'border-box' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -569,12 +499,11 @@ export function BoardView() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setUploadForm((prev) => ({ ...prev, version: e.target.value }))
|
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"
|
className="w-full px-3 py-2.5 rounded-md text-label-1 bg-bg-elevated border border-stroke outline-none box-border"
|
||||||
style={{ boxSizing: 'border-box' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</label>
|
||||||
<div
|
<div
|
||||||
@ -599,10 +528,9 @@ export function BoardView() {
|
|||||||
>
|
>
|
||||||
{uploadForm.fileName ? (
|
{uploadForm.fileName ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<span className="text-xl">📄</span>
|
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-xs font-semibold">{uploadForm.fileName}</div>
|
<div className="text-label-1 font-semibold">{uploadForm.fileName}</div>
|
||||||
<div className="text-[10px] text-fg-disabled font-mono">
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
{uploadForm.fileSize}
|
{uploadForm.fileSize}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -611,18 +539,17 @@ export function BoardView() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setUploadForm((prev) => ({ ...prev, fileName: '', fileSize: '' }));
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-[28px] opacity-30 mb-1.5">📁</div>
|
<div className="text-label-2 text-fg-disabled">
|
||||||
<div className="text-[11px] text-fg-disabled">
|
|
||||||
클릭하여 파일을 선택하세요
|
클릭하여 파일을 선택하세요
|
||||||
</div>
|
</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)
|
PDF, DOC, HWP, XLSX (최대 100MB)
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -636,7 +563,7 @@ export function BoardView() {
|
|||||||
setShowUploadModal(false);
|
setShowUploadModal(false);
|
||||||
setEditingManualId(null);
|
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>
|
</button>
|
||||||
@ -685,14 +612,9 @@ export function BoardView() {
|
|||||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.');
|
alert((err as { message?: string })?.message || '저장에 실패했습니다.');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-6 py-2 rounded-md text-xs font-semibold 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)]"
|
||||||
style={{
|
|
||||||
background: 'rgba(6,182,212,.2)',
|
|
||||||
border: '1px solid rgba(6,182,212,.35)',
|
|
||||||
color: '#22d3ee',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
{editingManualId ? '수정' : '업로드'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -748,7 +670,7 @@ export function BoardView() {
|
|||||||
<div className="flex flex-col h-full bg-bg-base">
|
<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="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>건
|
총 <span className="text-fg font-semibold">{totalCount}</span>건
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -758,14 +680,14 @@ export function BoardView() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyDown}
|
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') && (
|
{hasPermission(getWriteResource(), 'CREATE') && (
|
||||||
<button
|
<button
|
||||||
onClick={handleWriteClick}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -775,29 +697,29 @@ export function BoardView() {
|
|||||||
<div className="flex-1 overflow-auto px-8 py-6">
|
<div className="flex-1 overflow-auto px-8 py-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-fg-disabled text-sm">로딩 중...</p>
|
<p className="text-fg-disabled text-label-1">로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<table className="w-full border-collapse">
|
<table className="w-full border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-stroke">
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
</tr>
|
</tr>
|
||||||
@ -808,10 +730,10 @@ export function BoardView() {
|
|||||||
key={post.sn}
|
key={post.sn}
|
||||||
className="border-b border-stroke hover:bg-bg-elevated transition-colors"
|
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">
|
<td className="px-4 py-4 text-center">
|
||||||
<span
|
<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}
|
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||||
</span>
|
</span>
|
||||||
@ -820,20 +742,18 @@ export function BoardView() {
|
|||||||
className="px-4 py-4 cursor-pointer"
|
className="px-4 py-4 cursor-pointer"
|
||||||
onClick={() => handlePostClick(post.sn)}
|
onClick={() => handlePostClick(post.sn)}
|
||||||
>
|
>
|
||||||
<span
|
<span className="text-label-1 text-fg hover:text-color-accent transition-colors">
|
||||||
className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-fg' : 'text-fg'} hover:text-color-accent transition-colors`}
|
|
||||||
>
|
|
||||||
{post.pinnedYn === 'Y' && '📌 '}
|
{post.pinnedYn === 'Y' && '📌 '}
|
||||||
{post.title}
|
{post.title}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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}
|
{post.authorName}
|
||||||
</td>
|
</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')}
|
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||||
</td>
|
</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}
|
{post.viewCnt}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -843,7 +763,7 @@ export function BoardView() {
|
|||||||
|
|
||||||
{posts.length === 0 && (
|
{posts.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-fg-disabled text-sm">게시글이 없습니다.</p>
|
<p className="text-fg-disabled text-label-1">게시글이 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -857,9 +777,9 @@ export function BoardView() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(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
|
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'
|
: 'bg-bg-elevated text-fg-disabled hover:bg-bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -126,34 +126,50 @@ export function BoardWriteForm({
|
|||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-bg-base items-center justify-center">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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 ? '게시글 수정' : '게시글 작성'}
|
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||||
</h2>
|
</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>
|
</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="flex-1 overflow-auto px-8 py-6">
|
||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* 분류 선택 */}
|
{/* 분류 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||||
분류 <span className="text-red-500">*</span>
|
분류 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={categoryCd}
|
value={categoryCd}
|
||||||
onChange={(e) => setCategoryCd(e.target.value)}
|
onChange={(e) => setCategoryCd(e.target.value)}
|
||||||
disabled={isEditMode}
|
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) => (
|
{CATEGORY_OPTIONS.map((opt) => (
|
||||||
<option key={opt.code} value={opt.code}>
|
<option key={opt.code} value={opt.code}>
|
||||||
@ -165,8 +181,8 @@ export function BoardWriteForm({
|
|||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||||
제목 <span className="text-red-500">*</span>
|
제목 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -174,14 +190,14 @@ export function BoardWriteForm({
|
|||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
placeholder="제목을 입력하세요"
|
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>
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내용 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-semibold text-fg-sub mb-2">
|
<label className="block text-label-1 font-semibold text-fg-sub mb-2">
|
||||||
내용 <span className="text-red-500">*</span>
|
내용 <span className="text-color-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={content}
|
value={content}
|
||||||
@ -189,13 +205,13 @@ export function BoardWriteForm({
|
|||||||
maxLength={10000}
|
maxLength={10000}
|
||||||
placeholder="내용을 입력하세요"
|
placeholder="내용을 입력하세요"
|
||||||
rows={15}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* 파일 첨부 (향후 API 연동 예정) */}
|
{/* 파일 첨부 (향후 API 연동 예정) */}
|
||||||
<div>
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -207,34 +223,16 @@ export function BoardWriteForm({
|
|||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="file-upload"
|
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>
|
</label>
|
||||||
<span className="text-sm text-fg-disabled">선택된 파일 없음</span>
|
<span className="text-label-1 text-fg-disabled">선택된 파일 없음</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* 하단 버튼 */}
|
</form>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -548,7 +548,7 @@ ${styles}
|
|||||||
SEBC 해양 거동 분류 체계
|
SEBC 해양 거동 분류 체계
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
Standard European Behaviour Classification
|
Standard European Behaviour Classification
|
||||||
@ -585,11 +585,11 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">G</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-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>
|
||||||
<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 }}
|
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||||
>
|
>
|
||||||
대기확산 모델 적용
|
대기확산 모델 적용
|
||||||
@ -616,11 +616,11 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">E</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-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>
|
||||||
<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 }}
|
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||||
>
|
>
|
||||||
대기+해양 복합 대응
|
대기+해양 복합 대응
|
||||||
@ -647,11 +647,11 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">F</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-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, 불용성 물질'}
|
{'해수면 위에 부유. 비중 < 1.0, 불용성 물질'}
|
||||||
</div>
|
</div>
|
||||||
<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 }}
|
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||||
>
|
>
|
||||||
오일펜스 유사 봉쇄
|
오일펜스 유사 봉쇄
|
||||||
@ -678,11 +678,11 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">D</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-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>
|
||||||
<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 }}
|
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||||
>
|
>
|
||||||
해양확산 모델 적용
|
해양확산 모델 적용
|
||||||
@ -709,11 +709,11 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-title-4 font-mono font-extrabold text-color-accent">S</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-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, 저층 오염 축적'}
|
{'해저로 침강. 비중 > 1.0, 저층 오염 축적'}
|
||||||
</div>
|
</div>
|
||||||
<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 }}
|
style={{ background: 'rgba(6,182,212,.08)', borderRadius: 3 }}
|
||||||
>
|
>
|
||||||
저층 3D 모니터링 필수
|
저층 3D 모니터링 필수
|
||||||
@ -724,7 +724,7 @@ ${styles}
|
|||||||
<div className="rounded-md p-3 border border-stroke bg-bg-card">
|
<div className="rounded-md p-3 border border-stroke bg-bg-card">
|
||||||
<div className="text-label-2 font-bold mb-2">🔀 복합 거동 유형</div>
|
<div className="text-label-2 font-bold mb-2">🔀 복합 거동 유형</div>
|
||||||
<div
|
<div
|
||||||
className="grid text-center text-[8px]"
|
className="grid text-center text-caption"
|
||||||
style={{ gridTemplateColumns: 'repeat(5,1fr)', gap: 6 }}
|
style={{ gridTemplateColumns: 'repeat(5,1fr)', gap: 6 }}
|
||||||
>
|
>
|
||||||
<div className="rounded p-1.5 bg-bg-base">
|
<div className="rounded p-1.5 bg-bg-base">
|
||||||
@ -799,20 +799,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
G/GD
|
G/GD
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
독성
|
독성
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">7664-41-7</span>
|
<span className="font-mono">7664-41-7</span>
|
||||||
@ -839,7 +839,7 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="grid text-center text-[7px]"
|
className="grid text-center text-caption"
|
||||||
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -879,7 +879,7 @@ ${styles}
|
|||||||
<b>300 ppm</b>
|
<b>300 ppm</b>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
@ -898,20 +898,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
ED
|
ED
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
인화성
|
인화성
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">67-56-1</span>
|
<span className="font-mono">67-56-1</span>
|
||||||
@ -938,7 +938,7 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="grid text-center text-[7px]"
|
className="grid text-center text-caption"
|
||||||
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
style={{ gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -978,7 +978,7 @@ ${styles}
|
|||||||
<b>6,000 ppm</b>
|
<b>6,000 ppm</b>
|
||||||
</div>
|
</div>
|
||||||
</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 유출 사고.
|
발생. 2007 온산항 FODDANGER호 95만L 유출 사고.
|
||||||
</div>
|
</div>
|
||||||
@ -995,20 +995,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
G
|
G
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
폭발
|
폭발
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">1333-74-0</span>
|
<span className="font-mono">1333-74-0</span>
|
||||||
@ -1034,7 +1034,7 @@ ${styles}
|
|||||||
<span className="font-mono text-color-accent">75.0%</span>
|
<span className="font-mono text-color-accent">75.0%</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
폭발 범위 극히 넓음(4~75%). 무색·무취로 감지 불가. 극저온 액화수소 유출 시 BLEVE
|
||||||
위험. 급속 상승 확산.
|
위험. 급속 상승 확산.
|
||||||
</div>
|
</div>
|
||||||
@ -1055,20 +1055,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
G
|
G
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
인화/폭발
|
인화/폭발
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">74-82-8</span>
|
<span className="font-mono">74-82-8</span>
|
||||||
@ -1094,7 +1094,7 @@ ${styles}
|
|||||||
<span className="font-mono text-color-accent">15.0%</span>
|
<span className="font-mono text-color-accent">15.0%</span>
|
||||||
</div>
|
</div>
|
||||||
</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 기화 → 가연성
|
극저온(-162°C) 유출 시 RPT(급속상변환폭발), Pool Fire 위험. Flash 기화 → 가연성
|
||||||
가스운 형성. 인천·평택항 LNG 물동량 상위.
|
가스운 형성. 인천·평택항 LNG 물동량 상위.
|
||||||
</div>
|
</div>
|
||||||
@ -1113,20 +1113,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
S/SD
|
S/SD
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
독성
|
독성
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">108-95-2</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>
|
<span className="font-mono text-color-accent">84 g/L</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
비중 1.07 → <b className="text-color-accent">Sinker 특성</b>으로 저층 축적. ROMS
|
||||||
검증 결과 저층 농도가 표층의 3.5배. 해양산업시설 배출 주요 HNS (31.8kg/일).
|
검증 결과 저층 농도가 표층의 3.5배. 해양산업시설 배출 주요 HNS (31.8kg/일).
|
||||||
</div>
|
</div>
|
||||||
@ -1171,20 +1171,20 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
FE
|
FE
|
||||||
</span>
|
</span>
|
||||||
<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)' }}
|
style={{ borderRadius: 3, background: 'rgba(6,182,212,.1)' }}
|
||||||
>
|
>
|
||||||
인화성
|
인화성
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="rounded bg-bg-base px-1.5 py-1">
|
||||||
<span className="text-fg-disabled">CAS:</span>{' '}
|
<span className="text-fg-disabled">CAS:</span>{' '}
|
||||||
<span className="font-mono">108-88-3</span>
|
<span className="font-mono">108-88-3</span>
|
||||||
@ -1210,7 +1210,7 @@ ${styles}
|
|||||||
<span className="font-mono">0.52 g/L</span>
|
<span className="font-mono">0.52 g/L</span>
|
||||||
</div>
|
</div>
|
||||||
</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) 화재 위험 상시. 석유화학 산업의 대표적
|
해수면 부유 → 증발. 인화점 극히 낮아(4°C) 화재 위험 상시. 석유화학 산업의 대표적
|
||||||
HNS. 울산항 주요 취급물질.
|
HNS. 울산항 주요 취급물질.
|
||||||
</div>
|
</div>
|
||||||
@ -1293,7 +1293,7 @@ ${styles}
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
className="text-[8px] rounded px-2 py-1.5"
|
className="text-caption rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,.03)',
|
background: 'rgba(6,182,212,.03)',
|
||||||
border: '1px solid rgba(6,182,212,.1)',
|
border: '1px solid rgba(6,182,212,.1)',
|
||||||
@ -1302,7 +1302,7 @@ ${styles}
|
|||||||
<b className="text-color-accent">ERPG-1</b> — 일시적 건강 영향, 냄새 감지
|
<b className="text-color-accent">ERPG-1</b> — 일시적 건강 영향, 냄새 감지
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-[8px] rounded px-2 py-1.5"
|
className="text-caption rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,.03)',
|
background: 'rgba(6,182,212,.03)',
|
||||||
border: '1px solid rgba(6,182,212,.1)',
|
border: '1px solid rgba(6,182,212,.1)',
|
||||||
@ -1311,7 +1311,7 @@ ${styles}
|
|||||||
<b className="text-color-accent">ERPG-2</b> — 비가역적 영향, 대피 판단 기준
|
<b className="text-color-accent">ERPG-2</b> — 비가역적 영향, 대피 판단 기준
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-[8px] rounded px-2 py-1.5"
|
className="text-caption rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(6,182,212,.03)',
|
background: 'rgba(6,182,212,.03)',
|
||||||
border: '1px solid rgba(6,182,212,.1)',
|
border: '1px solid rgba(6,182,212,.1)',
|
||||||
@ -1465,7 +1465,7 @@ ${styles}
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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: 폭발하한
|
※ AEGL: 60분 기준 / ERPG: 1시간 노출 / IDLH: 30분 / LFL: 폭발하한
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1556,7 +1556,7 @@ ${styles}
|
|||||||
🔎 검색
|
🔎 검색
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>{' '}
|
||||||
| 약자/제품명 검색 시{' '}
|
| 약자/제품명 검색 시{' '}
|
||||||
<b className="text-color-accent">부호, 띄어쓰기 제외</b> 후 검색 | 총{' '}
|
<b className="text-color-accent">부호, 띄어쓰기 제외</b> 후 검색 | 총{' '}
|
||||||
@ -2080,11 +2080,11 @@ function HmsDetailPanel({
|
|||||||
{nfpa.reactivity}
|
{nfpa.reactivity}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</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
|
NFPA 704
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
@ -2295,7 +2295,7 @@ function HmsDetailPanel({
|
|||||||
>
|
>
|
||||||
<div className="text-base mb-[3px]">🧑🚒</div>
|
<div className="text-base mb-[3px]">🧑🚒</div>
|
||||||
<div className="font-bold text-color-accent">근거리</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>
|
||||||
<div
|
<div
|
||||||
className="text-center rounded"
|
className="text-center rounded"
|
||||||
@ -2307,7 +2307,7 @@ function HmsDetailPanel({
|
|||||||
>
|
>
|
||||||
<div className="text-base mb-[3px]">🦺</div>
|
<div className="text-base mb-[3px]">🦺</div>
|
||||||
<div className="font-bold text-color-accent">원거리</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2328,7 +2328,7 @@ function HmsDetailPanel({
|
|||||||
📄 MSDS 주요 정보
|
📄 MSDS 주요 정보
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-[8px] font-semibold cursor-pointer rounded"
|
className="text-caption font-semibold cursor-pointer rounded"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
background: 'rgba(6,182,212,.1)',
|
background: 'rgba(6,182,212,.1)',
|
||||||
@ -2339,7 +2339,7 @@ function HmsDetailPanel({
|
|||||||
📥 전문 다운로드
|
📥 전문 다운로드
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
<b>§2 유해성·위험성:</b> {s.msds.hazard}
|
||||||
<br />
|
<br />
|
||||||
<b>§4 응급조치:</b> {s.msds.firstAid}
|
<b>§4 응급조치:</b> {s.msds.firstAid}
|
||||||
@ -2607,7 +2607,7 @@ function HmsDetailPanel({
|
|||||||
<div className="text-label-1 font-bold text-color-accent">
|
<div className="text-label-1 font-bold text-color-accent">
|
||||||
📋 화물적부도 화물코드
|
📋 화물적부도 화물코드
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[8px] text-fg-disabled">클릭 시 물질검색창으로 이동</div>
|
<div className="text-caption text-fg-disabled">클릭 시 물질검색창으로 이동</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<table className="w-full border-collapse text-caption">
|
<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-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>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<table className="w-full border-collapse text-caption">
|
<table className="w-full border-collapse text-caption">
|
||||||
|
|||||||
@ -3849,15 +3849,16 @@ function RealtimeComparePanel() {
|
|||||||
<button
|
<button
|
||||||
className="px-4 py-1.5 rounded-md text-caption font-bold cursor-pointer text-white"
|
className="px-4 py-1.5 rounded-md text-caption font-bold cursor-pointer text-white"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-accent)',
|
// background: 'var(--color-accent)',
|
||||||
border: 'none',
|
// border: '1px solid var(--color-accent)',
|
||||||
|
color: 'var(--color-accent)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
▶ 비교 실행
|
▶ 비교 실행
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1.5 rounded-md text-caption font-semibold cursor-pointer text-fg-sub bg-bg-card"
|
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>
|
</button>
|
||||||
|
|||||||
@ -171,7 +171,7 @@ function HNSManualViewer() {
|
|||||||
<div className="text-label-2 font-bold" style={{ color: `${s.color},1)` }}>
|
<div className="text-label-2 font-bold" style={{ color: `${s.color},1)` }}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</div>
|
</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}
|
{s.desc}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +180,7 @@ function HNSManualViewer() {
|
|||||||
</div>
|
</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
|
<b>출처:</b> Marine HNS Response Manual — Bonn Agreement / HELCOM / REMPEC (WestMOPoCo
|
||||||
Project, 2024 한국어판)
|
Project, 2024 한국어판)
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@ -14,106 +14,99 @@ type Status = 'forbidden' | 'allowed' | 'conditional';
|
|||||||
interface DischargeRule {
|
interface DischargeRule {
|
||||||
category: string;
|
category: string;
|
||||||
item: 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;
|
condition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RULES: DischargeRule[] = [
|
const RULES: DischargeRule[] = [
|
||||||
// 폐기물
|
// 분뇨
|
||||||
{
|
{
|
||||||
category: '폐기물',
|
category: '분뇨',
|
||||||
item: '플라스틱 제품',
|
item: '분뇨마쇄소독장치',
|
||||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
zones: ['forbidden', 'conditional', 'conditional', 'conditional', 'conditional'],
|
||||||
|
condition: '항속 4노트 이상시 서서히 배출 / 400톤 미만 국내항해 선박은 3해리 이내 가능',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: '폐기물',
|
category: '분뇨',
|
||||||
item: '포장유해물질·용기',
|
item: '분뇨저장탱크',
|
||||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||||
|
condition: '항속 4노트 이상시 서서히 배출',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: '폐기물',
|
category: '분뇨',
|
||||||
item: '중금속 포함 쓰레기',
|
item: '분뇨처리장치',
|
||||||
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
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: '화물잔류물',
|
category: '화물잔류물',
|
||||||
item: '부유성 화물잔류물',
|
item: '부유성 화물잔류물',
|
||||||
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed'],
|
zones: ['forbidden', 'forbidden', 'forbidden', 'allowed', 'allowed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: '화물잔류물',
|
category: '화물잔류물',
|
||||||
item: '침강성 화물잔류물',
|
item: '침강성 화물잔류물',
|
||||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
zones: ['forbidden', 'forbidden', 'allowed', 'allowed', 'allowed'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: '화물잔류물',
|
category: '화물잔류물',
|
||||||
item: '화물창 세정수',
|
item: '화물창 세정수',
|
||||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
zones: ['forbidden', 'forbidden', 'conditional', 'conditional', 'conditional'],
|
||||||
condition: '해양환경에 해롭지 않은 일반세제 사용시',
|
condition: '해양환경에 해롭지 않은 일반세제 사용시',
|
||||||
},
|
},
|
||||||
// 음식물 찌꺼기
|
// 화물유
|
||||||
{
|
{
|
||||||
category: '음식물찌꺼기',
|
category: '화물유',
|
||||||
item: '미분쇄',
|
item: '화물유 섞인 평형수·세정수·선저폐수',
|
||||||
zones: ['forbidden', 'forbidden', 'allowed', 'allowed'],
|
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: '음식물찌꺼기',
|
category: '폐기물',
|
||||||
item: '분쇄·연마',
|
item: '포장유해물질·용기',
|
||||||
zones: ['forbidden', 'conditional', 'allowed', 'allowed'],
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||||
condition: '크기 25mm 이하시',
|
|
||||||
},
|
|
||||||
// 분뇨
|
|
||||||
{
|
|
||||||
category: '분뇨',
|
|
||||||
item: '분뇨저장장치',
|
|
||||||
zones: ['forbidden', 'forbidden', 'conditional', 'conditional'],
|
|
||||||
condition: '항속 4노트 이상시 서서히 배출',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: '분뇨',
|
category: '폐기물',
|
||||||
item: '분뇨마쇄소독장치',
|
item: '중금속 포함 쓰레기',
|
||||||
zones: ['forbidden', 'conditional', 'conditional', 'conditional'],
|
zones: ['forbidden', 'forbidden', 'forbidden', 'forbidden', 'forbidden'],
|
||||||
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: '면허 또는 허가를 득한 자에 한하여 어업활동 수면',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25해리+'];
|
const ZONE_LABELS = ['~3해리', '3~12해리', '12~25해리', '25~50해리', '50해리+'];
|
||||||
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e'];
|
const ZONE_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#64748b'];
|
||||||
|
|
||||||
function getZoneIndex(distanceNm: number): number {
|
|
||||||
if (distanceNm < 3) return 0;
|
|
||||||
if (distanceNm < 12) return 1;
|
|
||||||
if (distanceNm < 25) return 2;
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: Status }) {
|
function StatusBadge({ status }: { status: Status }) {
|
||||||
if (status === 'forbidden')
|
if (status === 'forbidden')
|
||||||
return (
|
return (
|
||||||
<span
|
<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)' }}
|
style={{ background: 'rgba(239,68,68,0.15)', color: 'var(--color-danger)' }}
|
||||||
>
|
>
|
||||||
배출불가
|
배출불가
|
||||||
@ -122,7 +115,7 @@ function StatusBadge({ status }: { status: Status }) {
|
|||||||
if (status === 'allowed')
|
if (status === 'allowed')
|
||||||
return (
|
return (
|
||||||
<span
|
<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)' }}
|
style={{ background: 'rgba(34,197,94,0.15)', color: 'var(--color-success)' }}
|
||||||
>
|
>
|
||||||
배출가능
|
배출가능
|
||||||
@ -130,7 +123,7 @@ function StatusBadge({ status }: { status: Status }) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<span
|
<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)' }}
|
style={{ background: 'rgba(234,179,8,0.15)', color: 'var(--color-caution)' }}
|
||||||
>
|
>
|
||||||
조건부
|
조건부
|
||||||
@ -142,11 +135,18 @@ interface DischargeZonePanelProps {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
distanceNm: number;
|
distanceNm: number;
|
||||||
|
zoneIndex: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZonePanelProps) {
|
export function DischargeZonePanel({
|
||||||
const zoneIdx = getZoneIndex(distanceNm);
|
lat,
|
||||||
|
lon,
|
||||||
|
distanceNm,
|
||||||
|
zoneIndex,
|
||||||
|
onClose,
|
||||||
|
}: DischargeZonePanelProps) {
|
||||||
|
const zoneIdx = zoneIndex;
|
||||||
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
const [expandedCat, setExpandedCat] = useState<string | null>(null);
|
||||||
|
|
||||||
const categories = [...new Set(RULES.map((r) => r.category))];
|
const categories = [...new Set(RULES.map((r) => r.category))];
|
||||||
@ -173,10 +173,10 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
<div className="text-label-2 font-bold text-fg font-korean">🚢 오염물 배출 규정</div>
|
||||||
<div className="text-[8px] text-fg-sub font-korean">해양환경관리법 제22조</div>
|
<div className="text-caption text-fg-sub font-korean">해양환경관리법 제22조</div>
|
||||||
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -187,14 +187,17 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
style={{ padding: '8px 14px', borderBottom: '1px solid var(--stroke-light)' }}
|
style={{ padding: '8px 14px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-[9px] text-fg-sub font-korean">선택 위치</span>
|
<span className="text-caption text-fg-sub font-korean">선택 위치</span>
|
||||||
<span className="text-[9px] text-fg font-mono">
|
<span className="text-caption text-fg font-mono">
|
||||||
{lat.toFixed(4)}°N, {lon.toFixed(4)}°E
|
{lat.toFixed(4)}°N, {lon.toFixed(4)}°E
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-[9px] text-fg-sub font-korean">영해기선 거리 (추정)</span>
|
<span className="text-caption text-fg-sub font-korean">영해기선 거리</span>
|
||||||
<span className="text-[11px] font-bold font-mono" style={{ color: ZONE_COLORS[zoneIdx] }}>
|
<span
|
||||||
|
className="text-label-2 font-bold font-mono"
|
||||||
|
style={{ color: ZONE_COLORS[zoneIdx] }}
|
||||||
|
>
|
||||||
{distanceNm.toFixed(1)} NM
|
{distanceNm.toFixed(1)} NM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -242,13 +245,13 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
<div
|
<div
|
||||||
style={{ width: 6, height: 6, borderRadius: '50%', background: summaryColor }}
|
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>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? '전체 가능' : '항목별 상이'}
|
{allForbidden ? '전체 불가' : allAllowed ? '전체 가능' : '항목별 상이'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
<span className="text-caption text-fg-sub">{isExpanded ? '▾' : '▸'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -265,7 +268,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
borderRadius: 4,
|
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]} />
|
<StatusBadge status={rule.zones[zoneIdx]} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -276,7 +279,7 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
.map((r, i) => (
|
.map((r, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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}
|
💡 {r.item}: {r.condition}
|
||||||
</div>
|
</div>
|
||||||
@ -295,8 +298,8 @@ export function DischargeZonePanel({ lat, lon, distanceNm, onClose }: DischargeZ
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
style={{ padding: '6px 14px', borderTop: '1px solid var(--stroke-light)' }}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -233,10 +233,10 @@ export function IncidentsLeftPanel({
|
|||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
resetPage();
|
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)' }}
|
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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dateTo}
|
value={dateTo}
|
||||||
@ -245,12 +245,12 @@ export function IncidentsLeftPanel({
|
|||||||
setSelectedPeriod('');
|
setSelectedPeriod('');
|
||||||
resetPage();
|
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)' }}
|
style={{ padding: '5px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={resetPage}
|
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={{
|
style={{
|
||||||
padding: '5px 12px',
|
padding: '5px 12px',
|
||||||
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
|
background: 'linear-gradient(135deg,var(--color-accent),var(--color-info))',
|
||||||
@ -267,7 +267,7 @@ export function IncidentsLeftPanel({
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => handlePeriodClick(p)}
|
onClick={() => handlePeriodClick(p)}
|
||||||
className="text-[10px] font-semibold cursor-pointer"
|
className="text-caption font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 8px',
|
padding: '3px 8px',
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
@ -290,7 +290,7 @@ export function IncidentsLeftPanel({
|
|||||||
style={{ background: 'rgba(6,182,212,0.03)' }}
|
style={{ background: 'rgba(6,182,212,0.03)' }}
|
||||||
>
|
>
|
||||||
<div
|
<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' }}
|
style={{ letterSpacing: '0.8px' }}
|
||||||
>
|
>
|
||||||
📅 오늘 ({todayLabel}) 사고 현황
|
📅 오늘 ({todayLabel}) 사고 현황
|
||||||
@ -306,7 +306,7 @@ export function IncidentsLeftPanel({
|
|||||||
setSelectedRegion(r);
|
setSelectedRegion(r);
|
||||||
resetPage();
|
resetPage();
|
||||||
}}
|
}}
|
||||||
className="text-[11px] cursor-pointer"
|
className="text-label-2 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
@ -349,7 +349,7 @@ export function IncidentsLeftPanel({
|
|||||||
setSelectedStatus(s.id);
|
setSelectedStatus(s.id);
|
||||||
resetPage();
|
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={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
@ -372,7 +372,7 @@ export function IncidentsLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Count */}
|
{/* 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}건
|
총 {filteredIncidents.length}건
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -385,7 +385,7 @@ export function IncidentsLeftPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pagedIncidents.length === 0 ? (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -449,7 +449,7 @@ export function IncidentsLeftPanel({
|
|||||||
{inc.name}
|
{inc.name}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-[10px] font-semibold"
|
className="shrink-0 text-caption font-semibold"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 10px',
|
padding: '2px 10px',
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
@ -461,7 +461,7 @@ export function IncidentsLeftPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: meta */}
|
{/* 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>
|
<span>
|
||||||
📅 {inc.date} {inc.time}
|
📅 {inc.date} {inc.time}
|
||||||
</span>
|
</span>
|
||||||
@ -472,7 +472,7 @@ export function IncidentsLeftPanel({
|
|||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{inc.causeType && (
|
{inc.causeType && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium text-fg-sub"
|
className="text-caption font-medium text-fg-sub"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
@ -485,7 +485,7 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
{inc.oilType && (
|
{inc.oilType && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium text-color-warning"
|
className="text-caption font-medium text-color-warning"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
@ -498,7 +498,7 @@ export function IncidentsLeftPanel({
|
|||||||
)}
|
)}
|
||||||
{inc.prediction && (
|
{inc.prediction && (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] font-medium text-color-success"
|
className="text-caption font-medium text-color-success"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
@ -512,7 +512,7 @@ export function IncidentsLeftPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
className="inc-wx-btn cursor-pointer text-[11px]"
|
className="inc-wx-btn cursor-pointer text-label-2"
|
||||||
onClick={(e) => handleWeatherClick(e, inc.id)}
|
onClick={(e) => handleWeatherClick(e, inc.id)}
|
||||||
title="사고 위치 기상정보"
|
title="사고 위치 기상정보"
|
||||||
style={{
|
style={{
|
||||||
@ -537,7 +537,7 @@ export function IncidentsLeftPanel({
|
|||||||
setMediaModalIncident(inc);
|
setMediaModalIncident(inc);
|
||||||
}}
|
}}
|
||||||
title="현장정보 조회"
|
title="현장정보 조회"
|
||||||
className="cursor-pointer text-[11px]"
|
className="cursor-pointer text-label-2"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 7px',
|
padding: '3px 7px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@ -548,7 +548,7 @@ export function IncidentsLeftPanel({
|
|||||||
transition: '0.15s',
|
transition: '0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📹 <span className="text-[8px]">{inc.mediaCount}</span>
|
📹 <span className="text-caption">{inc.mediaCount}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -576,7 +576,7 @@ export function IncidentsLeftPanel({
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between bg-bg-surface shrink-0 border-t border-stroke px-3 py-2">
|
<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}-
|
총 <b>{filteredIncidents.length}</b>건 중 {(safePage - 1) * pageSize + 1}-
|
||||||
{Math.min(safePage * pageSize, filteredIncidents.length)}
|
{Math.min(safePage * pageSize, filteredIncidents.length)}
|
||||||
</div>
|
</div>
|
||||||
@ -612,7 +612,7 @@ export function IncidentsLeftPanel({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
/* page size change placeholder */ void 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>6건</option>
|
||||||
<option>10건</option>
|
<option>10건</option>
|
||||||
@ -638,7 +638,7 @@ function PgBtn({
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="flex items-center justify-center font-mono text-[9px]"
|
className="flex items-center justify-center font-mono text-caption"
|
||||||
style={{
|
style={{
|
||||||
minWidth: '24px',
|
minWidth: '24px',
|
||||||
height: '24px',
|
height: '24px',
|
||||||
@ -694,8 +694,8 @@ const WeatherPopup = forwardRef<
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm">🌤</span>
|
<span className="text-sm">🌤</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-bold">{data?.locNm || '기상정보 없음'}</div>
|
<div className="text-label-2 font-bold">{data?.locNm || '기상정보 없음'}</div>
|
||||||
<div className="text-fg-disabled font-mono text-[8px]">{data?.obsDtm || '-'}</div>
|
<div className="text-fg-disabled font-mono text-caption">{data?.obsDtm || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span onClick={onClose} className="cursor-pointer text-fg-disabled text-sm p-0.5">
|
<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 className="text-[28px]">{data?.icon || '❓'}</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold font-mono text-[20px]">{data?.temp || '-'}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail grid */}
|
{/* 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?.wind} />
|
||||||
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
<WxCell icon="🌊" label="파고" value={data?.wave} />
|
||||||
<WxCell icon="💧" label="습도" value={data?.humid} />
|
<WxCell icon="💧" label="습도" value={data?.humid} />
|
||||||
@ -735,8 +735,8 @@ const WeatherPopup = forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="text-xs">⬆</span>
|
<span className="text-xs">⬆</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-fg-disabled text-[7px]">고조 (만조)</div>
|
<div className="text-fg-disabled text-caption">고조 (만조)</div>
|
||||||
<div className="font-bold font-mono text-[10px] text-color-info">
|
<div className="font-bold font-mono text-caption text-color-info">
|
||||||
{data?.highTide || '-'}
|
{data?.highTide || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -747,8 +747,8 @@ const WeatherPopup = forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="text-xs">⬇</span>
|
<span className="text-xs">⬇</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-fg-disabled text-[7px]">저조 (간조)</div>
|
<div className="text-fg-disabled text-caption">저조 (간조)</div>
|
||||||
<div className="text-color-accent font-bold font-mono text-[10px]">
|
<div className="text-color-accent font-bold font-mono text-caption">
|
||||||
{data?.lowTide || '-'}
|
{data?.lowTide || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -757,9 +757,9 @@ const WeatherPopup = forwardRef<
|
|||||||
|
|
||||||
{/* 24h Forecast */}
|
{/* 24h Forecast */}
|
||||||
<div className="bg-bg-base mt-2.5 px-2.5 py-2 rounded-md">
|
<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 ? (
|
{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) => (
|
{forecast.map((f, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<div>{f.hour}</div>
|
<div>{f.hour}</div>
|
||||||
@ -769,7 +769,7 @@ const WeatherPopup = forwardRef<
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@ -782,8 +782,8 @@ const WeatherPopup = forwardRef<
|
|||||||
border: '1px solid rgba(249,115,22,0.12)',
|
border: '1px solid rgba(249,115,22,0.12)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="font-bold text-color-warning text-[8px] mb-[3px]">⚠ 방제 작업 영향</div>
|
<div className="font-bold text-color-warning text-caption mb-[3px]">⚠ 방제 작업 영향</div>
|
||||||
<div className="text-fg-sub text-[8px] leading-[1.5]">{data?.impactDc || '-'}</div>
|
<div className="text-fg-sub text-caption leading-[1.5]">{data?.impactDc || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -794,9 +794,9 @@ WeatherPopup.displayName = 'WeatherPopup';
|
|||||||
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
function WxCell({ icon, label, value }: { icon: string; label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center bg-bg-base rounded gap-[6px] py-1.5 px-2">
|
<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>
|
||||||
<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 className="font-semibold font-mono">{value || '-'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -311,7 +311,7 @@ export function IncidentsRightPanel({
|
|||||||
if (!incident) {
|
if (!incident) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-[280px] min-w-[280px]">
|
<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>
|
<div className="text-[32px] mb-2 opacity-30">📊</div>
|
||||||
좌측에서 사고를 선택하면
|
좌측에서 사고를 선택하면
|
||||||
<br />
|
<br />
|
||||||
@ -326,7 +326,7 @@ export function IncidentsRightPanel({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-[14px] py-2.5 border-b border-stroke shrink-0">
|
<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-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>
|
선택: <b className="text-color-accent">{incident.name}</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +350,7 @@ export function IncidentsRightPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-[10px] font-semibold cursor-pointer"
|
className="text-caption font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@ -364,7 +364,7 @@ export function IncidentsRightPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{sec.items.length === 0 ? (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -387,15 +387,15 @@ export function IncidentsRightPanel({
|
|||||||
style={{ accentColor: sec.color }}
|
style={{ accentColor: sec.color }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{item.name}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<span
|
<span
|
||||||
onClick={() => removePredItem(item.id)}
|
onClick={() => removePredItem(item.id)}
|
||||||
title="제거"
|
title="제거"
|
||||||
className="text-[10px] cursor-pointer text-fg-disabled shrink-0"
|
className="text-caption cursor-pointer text-fg-disabled shrink-0"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</span>
|
</span>
|
||||||
@ -403,7 +403,7 @@ export function IncidentsRightPanel({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}
|
선택: <b style={{ color: sec.color }}>{checkedCount}건</b> · {sec.totalLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -421,7 +421,7 @@ export function IncidentsRightPanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="text-[10px] font-semibold cursor-pointer"
|
className="text-caption font-semibold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@ -433,8 +433,8 @@ export function IncidentsRightPanel({
|
|||||||
📋 조회
|
📋 조회
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-fg-disabled text-center py-1.5">준비 중입니다</div>
|
<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-[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 }}>0건</b> · 전체 0건
|
선택: <b style={{ color: sec.color }}>0건</b> · 전체 0건
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -448,7 +448,7 @@ export function IncidentsRightPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[3px]">
|
<div className="flex flex-col gap-[3px]">
|
||||||
{sensCategories.length === 0 ? (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -463,7 +463,7 @@ export function IncidentsRightPanel({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={cat.category}
|
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)` }}
|
style={{ padding: '4px 6px', background: `rgba(${r},${g},${b},0.06)` }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@ -499,23 +499,23 @@ export function IncidentsRightPanel({
|
|||||||
<span className="text-sm">🛡</span>
|
<span className="text-sm">🛡</span>
|
||||||
<span className="text-xs font-bold text-color-boom">근처 방제자원</span>
|
<span className="text-xs font-bold text-color-boom">근처 방제자원</span>
|
||||||
{nearbyOrgs.length > 0 && (
|
{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}개
|
{nearbyOrgs.length}개
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedVessel ? (
|
{!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>
|
<div className="text-xl mb-1 opacity-40">🚢</div>
|
||||||
지도에서 선박을 클릭하면
|
지도에서 선박을 클릭하면
|
||||||
<br />
|
<br />
|
||||||
부근 방제자원이 표시됩니다
|
부근 방제자원이 표시됩니다
|
||||||
</div>
|
</div>
|
||||||
) : nearbyLoading ? (
|
) : 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 ? (
|
) : 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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -532,19 +532,19 @@ export function IncidentsRightPanel({
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1 mb-[2px]">
|
<div className="flex items-center gap-1 mb-[2px]">
|
||||||
<span
|
<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' }}
|
style={{ background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }}
|
||||||
>
|
>
|
||||||
{org.orgTp}
|
{org.orgTp}
|
||||||
</span>
|
</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>
|
||||||
<div className="text-[9px] text-fg-disabled">
|
<div className="text-caption text-fg-disabled">
|
||||||
{org.areaNm}
|
{org.areaNm}
|
||||||
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''}
|
||||||
</div>
|
</div>
|
||||||
</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
|
{org.distanceNm.toFixed(1)} nm
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -555,8 +555,8 @@ export function IncidentsRightPanel({
|
|||||||
{/* Radius slider */}
|
{/* Radius slider */}
|
||||||
<div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(245,158,11,0.1)' }}>
|
<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]">
|
<div className="flex items-center justify-between mb-[5px]">
|
||||||
<span className="text-[9px] text-fg-disabled">탐색 반경</span>
|
<span className="text-caption text-fg-disabled">탐색 반경</span>
|
||||||
<span className="text-[10px] font-bold font-mono text-color-boom">
|
<span className="text-caption font-bold font-mono text-color-boom">
|
||||||
{nearbyRadius} nm
|
{nearbyRadius} nm
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -593,7 +593,7 @@ export function IncidentsRightPanel({
|
|||||||
<button
|
<button
|
||||||
key={v.mode}
|
key={v.mode}
|
||||||
onClick={() => onViewModeChange(v.mode)}
|
onClick={() => onViewModeChange(v.mode)}
|
||||||
className="flex-1 text-[10px] cursor-pointer"
|
className="flex-1 text-caption cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
@ -624,7 +624,7 @@ export function IncidentsRightPanel({
|
|||||||
const sensChecked = checkedSensCategories.size;
|
const sensChecked = checkedSensCategories.size;
|
||||||
onRunAnalysis(checkedSections, sensChecked);
|
onRunAnalysis(checkedSections, sensChecked);
|
||||||
}}
|
}}
|
||||||
className="w-full text-[11px] font-bold cursor-pointer"
|
className="w-full text-label-2 font-bold cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
background: analysisActive
|
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)' }}
|
style={{ background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-center text-[12px] text-fg-disabled"
|
className="text-center text-label-1 text-fg-disabled"
|
||||||
style={{
|
style={{
|
||||||
width: 300,
|
width: 300,
|
||||||
padding: 40,
|
padding: 40,
|
||||||
@ -114,8 +114,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
<div className="flex items-center gap-[10px]">
|
<div className="flex items-center gap-[10px]">
|
||||||
<span className="text-lg">📋</span>
|
<span className="text-lg">📋</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] font-[800] text-fg">현장정보 — {incident.name}</div>
|
<div className="text-title-3 font-[800] text-fg">현장정보 — {incident.name}</div>
|
||||||
<div className="text-[10px] text-fg-disabled font-mono">
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
{incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} /
|
{incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} /
|
||||||
위성 {media.satCnt} / CCTV {media.cctvCnt}
|
위성 {media.satCnt} / CCTV {media.cctvCnt}
|
||||||
</div>
|
</div>
|
||||||
@ -161,7 +161,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
{/* Close */}
|
{/* Close */}
|
||||||
<span
|
<span
|
||||||
onClick={onClose}
|
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' }}
|
style={{ padding: '2px 6px' }}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@ -174,7 +174,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
className="shrink-0 flex items-center gap-[10px]"
|
className="shrink-0 flex items-center gap-[10px]"
|
||||||
style={{ padding: '6px 20px', borderBottom: '1px solid var(--stroke-light)' }}
|
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 className="flex-1 relative" style={{ height: 16 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -204,7 +204,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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: '#ef4444' }}>● 초기</span>
|
||||||
<span style={{ color: '#f59e0b' }}>● 대응</span>
|
<span style={{ color: '#f59e0b' }}>● 대응</span>
|
||||||
<span className="text-fg-disabled">● 종료</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)' }}
|
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<span className="text-[12px]">📷</span>
|
<span className="text-label-1">📷</span>
|
||||||
<span className="text-[12px] font-bold text-fg">
|
<span className="text-label-1 font-bold text-fg">
|
||||||
현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
|
현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -245,10 +245,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
<div className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||||
📷
|
📷
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] text-fg-sub font-semibold">
|
<div className="text-label-1 text-fg-sub font-semibold">
|
||||||
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
|
||||||
</div>
|
</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')}
|
{str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -262,7 +262,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
(_, i) => (
|
(_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-center justify-center text-[14px] cursor-pointer"
|
className="flex items-center justify-center text-title-3 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 36,
|
height: 36,
|
||||||
@ -281,10 +281,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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')}
|
📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')}
|
||||||
</span>
|
</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>
|
</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)' }}
|
style={{ padding: '8px 16px', borderBottom: '1px solid var(--stroke-light)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<span className="text-[12px]">🎬</span>
|
<span className="text-label-1">🎬</span>
|
||||||
<span className="text-[12px] font-bold text-fg">
|
<span className="text-label-1 font-bold text-fg">
|
||||||
드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
|
드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="text-[9px] font-bold text-color-danger rounded"
|
className="text-caption font-bold text-color-danger rounded"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
background: 'rgba(239,68,68,0.15)',
|
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 className="text-[48px]" style={{ color: 'var(--stroke-default)' }}>
|
||||||
🎬
|
🎬
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] text-fg-sub font-semibold">드론 항공 촬영 영상</div>
|
<div className="text-label-1 text-fg-sub font-semibold">드론 항공 촬영 영상</div>
|
||||||
<div className="text-[9px] text-fg-disabled font-mono">
|
<div className="text-caption text-fg-disabled font-mono">
|
||||||
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
|
{str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -328,9 +330,9 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
|
style={{ padding: '10px 16px', borderTop: '1px solid var(--stroke-light)' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<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
|
<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={{
|
style={{
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@ -341,18 +343,18 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
>
|
>
|
||||||
▶
|
▶
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[12px] text-fg-disabled cursor-pointer">⏭</span>
|
<span className="text-label-1 text-fg-disabled cursor-pointer">⏭</span>
|
||||||
<span className="text-[10px] text-fg-disabled font-mono">
|
<span className="text-caption text-fg-disabled font-mono">
|
||||||
02:34 / {str(media.droneMeta, 'duration')}
|
02:34 / {str(media.droneMeta, 'duration')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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')}
|
🎬 영상 {num(media.droneMeta, 'videoCount')}건 · {str(media.droneMeta, 'stage')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex gap-[8px]">
|
||||||
<span className="text-[8px] text-color-info cursor-pointer">📂 전체보기</span>
|
<span className="text-caption text-color-info cursor-pointer">📂 전체보기</span>
|
||||||
<span className="text-[8px] text-color-tertiary cursor-pointer">
|
<span className="text-caption text-color-tertiary cursor-pointer">
|
||||||
🔗 R&D 연계
|
🔗 R&D 연계
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -369,8 +371,8 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<span className="text-[12px]">🛰</span>
|
<span className="text-label-1">🛰</span>
|
||||||
<span className="text-[12px] font-bold text-fg">
|
<span className="text-label-1 font-bold text-fg">
|
||||||
위성영상 — {str(media.satMeta, 'title', '위성영상')}
|
위성영상 — {str(media.satMeta, 'title', '위성영상')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -397,16 +399,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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' }}
|
style={{ top: -10, left: 8, padding: '0 4px' }}
|
||||||
>
|
>
|
||||||
{str(media.satMeta, 'detection')}
|
{str(media.satMeta, 'detection')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[40px] text-fg-disabled">🛰</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', '위성영상')} 위성영상
|
{str(media.satMeta, 'title', '위성영상')} 위성영상
|
||||||
</div>
|
</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')}
|
{str(media.satMeta, 'date')} · 해상도 {str(media.satMeta, 'resolution')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -414,7 +416,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
{str(media.satMeta, 'detection') === '—' && (
|
{str(media.satMeta, 'detection') === '—' && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-[40px] text-fg-disabled">🛰</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -429,7 +431,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
|
{Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 36,
|
height: 36,
|
||||||
@ -444,10 +446,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between items-center">
|
<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')}
|
🛰 위성 {num(media.satMeta, 'thumbCount')}장 · {str(media.satMeta, 'sensor')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] text-color-info cursor-pointer">🔍 편집/측 비교</span>
|
<span className="text-caption text-color-info cursor-pointer">
|
||||||
|
🔍 편집/측 비교
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -461,15 +465,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
style={{ padding: '8px 16px', borderBottom: '1px solid #21262d' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<span className="text-[12px]">📹</span>
|
<span className="text-label-1">📹</span>
|
||||||
<span className="text-[12px] font-bold text-fg">
|
<span className="text-label-1 font-bold text-fg">
|
||||||
CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
|
CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
{bool(media.cctvMeta, 'live') && (
|
{bool(media.cctvMeta, 'live') && (
|
||||||
<span
|
<span
|
||||||
className="text-[9px] font-bold text-color-success rounded"
|
className="text-caption font-bold text-color-success rounded"
|
||||||
style={{
|
style={{
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
background: 'rgba(34,197,94,0.15)',
|
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">
|
<div className="flex-1 flex items-center justify-center flex-col gap-2 relative">
|
||||||
{bool(media.cctvMeta, 'live') && (
|
{bool(media.cctvMeta, 'live') && (
|
||||||
<div
|
<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 }}
|
style={{ top: 10, left: 16 }}
|
||||||
>
|
>
|
||||||
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
|
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[48px] text-fg-disabled">📹</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 #')}
|
{str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
|
||||||
</div>
|
</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')} ·{' '}
|
{str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} ·{' '}
|
||||||
{bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
|
{bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
|
||||||
</div>
|
</div>
|
||||||
@ -529,13 +533,15 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<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')}채널 ·{' '}
|
📹 CCTV {num(media.cctvMeta, 'camCount')}채널 ·{' '}
|
||||||
{str(media.cctvMeta, 'location')}
|
{str(media.cctvMeta, 'location')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-[8px]">
|
<div className="flex gap-[8px]">
|
||||||
<span className="text-[8px] text-color-danger cursor-pointer">🔴 녹화영상</span>
|
<span className="text-caption text-color-danger cursor-pointer">
|
||||||
<span className="text-[8px] text-color-info cursor-pointer">🎥 PTZ</span>
|
🔴 녹화영상
|
||||||
|
</span>
|
||||||
|
<span className="text-caption text-color-info cursor-pointer">🎥 PTZ</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -552,7 +558,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
borderTop: '1px solid #30363d',
|
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>
|
<span>
|
||||||
📷 사진 <b className="text-fg">{media.photoCnt}</b>
|
📷 사진 <b className="text-fg">{media.photoCnt}</b>
|
||||||
</span>
|
</span>
|
||||||
@ -601,7 +607,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
|
|||||||
function NavBtn({ label }: { label: string }) {
|
function NavBtn({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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={{
|
style={{
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
@ -628,7 +634,7 @@ function BottomBtn({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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={{
|
style={{
|
||||||
padding: '6px 14px',
|
padding: '6px 14px',
|
||||||
background: bg,
|
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
|
* 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조
|
* 선박에서의 오염방지에 관한 규칙 제8조[별표 2] 및 제14조
|
||||||
|
*
|
||||||
|
* 구역 경계선: 국립해양조사원 영해기선(TB_ZN_TRTSEA) 버퍼 GeoJSON
|
||||||
|
* 영해기선 데이터: 국립해양조사원 TB_ZN_TRTSEA (EPSG:5179 → WGS84 변환)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 한국 해안선 — OpenStreetMap Nominatim 기반 실측 좌표
|
// ── GeoJSON 타입 ──
|
||||||
// [lat, lon] 형식, 시계방향 (동해→남해→서해→DMZ)
|
|
||||||
const COASTLINE_POINTS: [number, number][] = [
|
interface GeoJSONFeature {
|
||||||
// 동해안 (북→남)
|
geometry: {
|
||||||
[38.6177, 128.656],
|
type: string;
|
||||||
[38.5504, 128.4092],
|
coordinates: number[][][][] | number[][][];
|
||||||
[38.4032, 128.7767],
|
};
|
||||||
[38.1904, 128.8902],
|
}
|
||||||
[38.0681, 128.9977],
|
|
||||||
[37.9726, 129.0715],
|
// ── 영해기선 폴리곤 (거리 계산용) ──
|
||||||
[37.8794, 129.1721],
|
|
||||||
[37.8179, 129.2397],
|
let cachedBaselineRings: [number, number][][] | null = null;
|
||||||
[37.6258, 129.3669],
|
let baselineLoadingPromise: Promise<[number, number][][]> | null = null;
|
||||||
[37.5053, 129.4577],
|
|
||||||
[37.3617, 129.57],
|
function extractOuterRings(geojson: { features: GeoJSONFeature[] }): [number, number][][] {
|
||||||
[37.1579, 129.6538],
|
const rings: [number, number][][] = [];
|
||||||
[37.0087, 129.6706],
|
for (const feature of geojson.features) {
|
||||||
[36.6618, 129.721],
|
const geom = feature.geometry;
|
||||||
[36.3944, 129.6827],
|
if (geom.type === 'MultiPolygon') {
|
||||||
[36.2052, 129.7641],
|
const polygons = geom.coordinates as [number, number][][][];
|
||||||
[35.9397, 129.8124],
|
for (const polygon of polygons) {
|
||||||
[35.6272, 129.7121],
|
rings.push(polygon[0]);
|
||||||
[35.4732, 129.6908],
|
}
|
||||||
[35.2843, 129.5924],
|
} else if (geom.type === 'Polygon') {
|
||||||
[35.141, 129.4656],
|
const polygon = geom.coordinates as [number, number][][];
|
||||||
[35.0829, 129.2125],
|
rings.push(polygon[0]);
|
||||||
// 남해안 (부산→여수→목포)
|
}
|
||||||
[34.895, 129.0658],
|
}
|
||||||
[34.205, 128.3063],
|
return rings;
|
||||||
[35.022, 128.0362],
|
}
|
||||||
[34.9663, 127.8732],
|
|
||||||
[34.9547, 127.7148],
|
export async function loadTerritorialBaseline(): Promise<[number, number][][]> {
|
||||||
[34.8434, 127.6625],
|
if (cachedBaselineRings) return cachedBaselineRings;
|
||||||
[34.7826, 127.7422],
|
if (baselineLoadingPromise) return baselineLoadingPromise;
|
||||||
[34.6902, 127.6324],
|
|
||||||
[34.8401, 127.5236],
|
baselineLoadingPromise = fetch('/data/대한민국.geojson')
|
||||||
[34.823, 127.4043],
|
.then((res) => res.json())
|
||||||
[34.6882, 127.4234],
|
.then((data: { features: GeoJSONFeature[] }) => {
|
||||||
[34.6252, 127.4791],
|
cachedBaselineRings = extractOuterRings(data);
|
||||||
[34.5525, 127.4012],
|
return cachedBaselineRings;
|
||||||
[34.4633, 127.3246],
|
});
|
||||||
[34.5461, 127.1734],
|
|
||||||
[34.6617, 127.2605],
|
return baselineLoadingPromise;
|
||||||
[34.7551, 127.2471],
|
}
|
||||||
[34.6069, 127.0308],
|
|
||||||
[34.4389, 126.8975],
|
export function getCachedBaseline(): [number, number][][] | null {
|
||||||
[34.4511, 126.8263],
|
return cachedBaselineRings;
|
||||||
[34.4949, 126.7965],
|
}
|
||||||
[34.5119, 126.7548],
|
|
||||||
[34.4035, 126.6108],
|
// ── 구역 경계선 GeoJSON (런타임 로드) ──
|
||||||
[34.3175, 126.5844],
|
|
||||||
[34.3143, 126.5314],
|
interface ZoneGeoJSON {
|
||||||
[34.3506, 126.5083],
|
nm: number;
|
||||||
[34.4284, 126.5064],
|
rings: [number, number][][];
|
||||||
[34.4939, 126.4817],
|
}
|
||||||
[34.5896, 126.3326],
|
|
||||||
[34.6732, 126.2645],
|
let cachedZones: ZoneGeoJSON[] | null = null;
|
||||||
// 서해안 (목포→인천)
|
let zoneLoadingPromise: Promise<ZoneGeoJSON[]> | null = null;
|
||||||
[34.72, 126.3011],
|
|
||||||
[34.6946, 126.4256],
|
const ZONE_FILES = [
|
||||||
[34.6979, 126.5245],
|
{ nm: 3, file: '/data/대한민국_3해리.geojson' },
|
||||||
[34.7787, 126.5386],
|
{ nm: 12, file: '/data/대한민국_12해리.geojson' },
|
||||||
[34.8244, 126.5934],
|
{ nm: 25, file: '/data/대한민국_25해리.geojson' },
|
||||||
[34.8104, 126.4785],
|
{ nm: 50, file: '/data/대한민국_50해리.geojson' },
|
||||||
[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],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 제주도 — OpenStreetMap 기반 (26 points)
|
export async function loadZoneGeoJSON(): Promise<ZoneGeoJSON[]> {
|
||||||
const JEJU_POINTS: [number, number][] = [
|
if (cachedZones) return cachedZones;
|
||||||
[33.5168, 126.0128],
|
if (zoneLoadingPromise) return zoneLoadingPromise;
|
||||||
[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],
|
|
||||||
];
|
|
||||||
|
|
||||||
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 {
|
function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
const R = 3440.065;
|
const R = 3440.065;
|
||||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
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));
|
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 {
|
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;
|
let minDist = Infinity;
|
||||||
for (const [cLat, cLon] of ALL_COASTLINE) {
|
for (const ring of cachedBaselineRings) {
|
||||||
const dist = haversineNm(lat, lon, cLat, cLon);
|
for (let i = 0; i < ring.length - 1; i++) {
|
||||||
if (dist < minDist) minDist = dist;
|
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;
|
return minDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── 구역 판별 (Point-in-Polygon) ──
|
||||||
* 해안선을 주어진 해리(NM) 만큼 바깥(바다쪽)으로 오프셋한 경계선 생성
|
|
||||||
*/
|
|
||||||
function offsetCoastline(
|
|
||||||
points: [number, number][],
|
|
||||||
distanceNm: number,
|
|
||||||
outwardSign: number = 1,
|
|
||||||
): [number, number][] {
|
|
||||||
const degPerNm = 1 / 60;
|
|
||||||
const result: [number, number][] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
/** Ray casting 알고리즘으로 점이 폴리곤 내부인지 판별 */
|
||||||
const prev = points[(i - 1 + points.length) % points.length];
|
function pointInRing(lon: number, lat: number, ring: [number, number][]): boolean {
|
||||||
const curr = points[i];
|
let inside = false;
|
||||||
const next = points[(i + 1) % points.length];
|
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);
|
if (yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) {
|
||||||
const dx0 = (curr[1] - prev[1]) * cosLat;
|
inside = !inside;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
return inside;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 점이 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 {
|
export interface ZoneLine {
|
||||||
path: [number, number][];
|
path: [number, number][]; // [lon, lat]
|
||||||
color: [number, number, number, number];
|
color: [number, number, number, number];
|
||||||
label: string;
|
label: string;
|
||||||
distanceNm: number;
|
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[] {
|
export function getDischargeZoneLines(): ZoneLine[] {
|
||||||
const zones = [
|
if (!cachedZones) return [];
|
||||||
{ 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해리' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const lines: ZoneLine[] = [];
|
const lines: ZoneLine[] = [];
|
||||||
for (const zone of zones) {
|
for (const zone of cachedZones) {
|
||||||
const mainOffset = offsetCoastline(COASTLINE_POINTS, zone.nm, -1);
|
const style = ZONE_STYLES.find((s) => s.nm === zone.nm);
|
||||||
lines.push({
|
if (!style) continue;
|
||||||
path: mainOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
|
||||||
color: zone.color,
|
for (let i = 0; i < zone.rings.length; i++) {
|
||||||
label: zone.label,
|
lines.push({
|
||||||
distanceNm: zone.nm,
|
path: zone.rings[i],
|
||||||
});
|
color: style.color,
|
||||||
const jejuOffset = offsetCoastline(JEJU_POINTS, zone.nm, +1);
|
label: zone.rings.length > 1 ? `${style.label} (${i + 1})` : style.label,
|
||||||
lines.push({
|
distanceNm: style.nm,
|
||||||
path: jejuOffset.map(([lat, lon]) => [lon, lat] as [number, number]),
|
});
|
||||||
color: zone.color,
|
}
|
||||||
label: `${zone.label} (제주)`,
|
|
||||||
distanceNm: zone.nm,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,7 +116,7 @@ export function BacktrackModal({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-base font-bold m-0">유출유 역추적 분석</h2>
|
<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 항적 기반 유출 선박 추정
|
AIS 항적 기반 유출 선박 추정
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -144,7 +144,7 @@ export function BacktrackModal({
|
|||||||
>
|
>
|
||||||
{/* Analysis Conditions */}
|
{/* Analysis Conditions */}
|
||||||
<div>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -161,7 +161,7 @@ export function BacktrackModal({
|
|||||||
}}
|
}}
|
||||||
className="border border-stroke"
|
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
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={inputTime}
|
value={inputTime}
|
||||||
@ -180,7 +180,7 @@ export function BacktrackModal({
|
|||||||
}}
|
}}
|
||||||
className="border border-stroke"
|
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
|
<select
|
||||||
value={inputRange}
|
value={inputRange}
|
||||||
onChange={(e) => setInputRange(e.target.value)}
|
onChange={(e) => setInputRange(e.target.value)}
|
||||||
@ -202,7 +202,7 @@ export function BacktrackModal({
|
|||||||
}}
|
}}
|
||||||
className="border border-stroke"
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -214,7 +214,7 @@ export function BacktrackModal({
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -227,8 +227,8 @@ export function BacktrackModal({
|
|||||||
}}
|
}}
|
||||||
className="border border-stroke"
|
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="text-[12px] font-semibold font-mono">
|
<div className="text-label-1 font-semibold font-mono">
|
||||||
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
|
{conditions.spillLocation.lat.toFixed(4)}°N,{' '}
|
||||||
{conditions.spillLocation.lon.toFixed(4)}°E
|
{conditions.spillLocation.lon.toFixed(4)}°E
|
||||||
</div>
|
</div>
|
||||||
@ -244,10 +244,10 @@ export function BacktrackModal({
|
|||||||
gridColumn: '1 / -1',
|
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">
|
<div className="text-sm font-bold text-color-tertiary font-mono">
|
||||||
{conditions.totalVessels}척{' '}
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -257,7 +257,7 @@ export function BacktrackModal({
|
|||||||
{phase === 'results' && vessels.length > 0 && (
|
{phase === 'results' && vessels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 10px',
|
padding: '4px 10px',
|
||||||
@ -265,7 +265,7 @@ export function BacktrackModal({
|
|||||||
background: 'rgba(239,68,68,0.1)',
|
background: 'rgba(239,68,68,0.1)',
|
||||||
border: '1px solid rgba(239,68,68,0.3)',
|
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}척 의심
|
{conditions.totalVessels}척 중 {vessels.length}척 의심
|
||||||
</div>
|
</div>
|
||||||
@ -303,7 +303,7 @@ export function BacktrackModal({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
className="flex-1 text-title-4 font-bold cursor-pointer"
|
||||||
>
|
>
|
||||||
🔍 역추적 분석 실행
|
🔍 역추적 분석 실행
|
||||||
</button>
|
</button>
|
||||||
@ -318,7 +318,7 @@ export function BacktrackModal({
|
|||||||
color: 'var(--color-tertiary)',
|
color: 'var(--color-tertiary)',
|
||||||
cursor: 'wait',
|
cursor: 'wait',
|
||||||
}}
|
}}
|
||||||
className="flex-1 text-[13px] font-bold border border-stroke"
|
className="flex-1 text-title-4 font-bold border border-stroke"
|
||||||
>
|
>
|
||||||
⏳ AIS 항적 분석중...
|
⏳ AIS 항적 분석중...
|
||||||
</button>
|
</button>
|
||||||
@ -333,7 +333,7 @@ export function BacktrackModal({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
}}
|
}}
|
||||||
className="flex-1 text-[13px] font-bold cursor-pointer"
|
className="flex-1 text-title-4 font-bold cursor-pointer"
|
||||||
>
|
>
|
||||||
🗺 지도에서 리플레이 보기
|
🗺 지도에서 리플레이 보기
|
||||||
</button>
|
</button>
|
||||||
@ -380,8 +380,8 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|||||||
{vessel.rank}
|
{vessel.rank}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[13px] font-bold font-mono">{vessel.name}</div>
|
<div className="text-title-4 font-bold font-mono">{vessel.name}</div>
|
||||||
<div className="text-[9px] text-fg-disabled font-mono mt-[2px]">
|
<div className="text-caption text-fg-disabled font-mono mt-[2px]">
|
||||||
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
IMO: {vessel.imo} · {vessel.type} · {vessel.flag}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -392,7 +392,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|||||||
>
|
>
|
||||||
{vessel.probability}%
|
{vessel.probability}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[8px] text-fg-disabled">유출 확률</div>
|
<div className="text-caption text-fg-disabled">유출 확률</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -430,12 +430,12 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|||||||
: '1px solid var(--stroke-default)',
|
: '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
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: s.highlight ? 'var(--color-danger)' : 'var(--fg-default)',
|
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}
|
{s.value}
|
||||||
</div>
|
</div>
|
||||||
@ -453,7 +453,7 @@ function VesselCard({ vessel }: { vessel: BacktrackVessel }) {
|
|||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
}}
|
}}
|
||||||
className="text-[9px] text-fg-sub"
|
className="text-caption text-fg-sub"
|
||||||
>
|
>
|
||||||
{vessel.description}
|
{vessel.description}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { LayerTree } from '@common/components/layer/LayerTree';
|
import { LayerTree } from '@common/components/layer/LayerTree';
|
||||||
import { useLayerTree } from '@common/hooks/useLayers';
|
import { useLayerTree } from '@common/hooks/useLayers';
|
||||||
import type { Layer } from '@common/services/layerService';
|
import type { Layer } from '@common/services/layerService';
|
||||||
@ -12,6 +11,8 @@ interface InfoLayerSectionProps {
|
|||||||
onLayerOpacityChange: (val: number) => void;
|
onLayerOpacityChange: (val: number) => void;
|
||||||
layerBrightness: number;
|
layerBrightness: number;
|
||||||
onLayerBrightnessChange: (val: number) => void;
|
onLayerBrightnessChange: (val: number) => void;
|
||||||
|
layerColors: Record<string, string>;
|
||||||
|
onLayerColorChange: (layerId: string, color: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoLayerSection = ({
|
const InfoLayerSection = ({
|
||||||
@ -23,12 +24,12 @@ const InfoLayerSection = ({
|
|||||||
onLayerOpacityChange,
|
onLayerOpacityChange,
|
||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
|
layerColors,
|
||||||
|
onLayerColorChange,
|
||||||
}: InfoLayerSectionProps) => {
|
}: InfoLayerSectionProps) => {
|
||||||
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
// API에서 레이어 트리 데이터 가져오기 (관리자 설정 USE_YN='Y' 레이어만 반환)
|
||||||
const { data: layerTree, isLoading } = useLayerTree();
|
const { data: layerTree, isLoading } = useLayerTree();
|
||||||
|
|
||||||
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
// 관리자에서 사용여부가 ON인 레이어만 표시 (정적 폴백 없음)
|
||||||
const effectiveLayers: Layer[] = layerTree ?? [];
|
const effectiveLayers: Layer[] = layerTree ?? [];
|
||||||
|
|
||||||
@ -134,7 +135,7 @@ const InfoLayerSection = ({
|
|||||||
enabledLayers={enabledLayers}
|
enabledLayers={enabledLayers}
|
||||||
onToggleLayer={onToggleLayer}
|
onToggleLayer={onToggleLayer}
|
||||||
layerColors={layerColors}
|
layerColors={layerColors}
|
||||||
onColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
onColorChange={onLayerColorChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -108,6 +108,8 @@ export function LeftPanel({
|
|||||||
onLayerOpacityChange,
|
onLayerOpacityChange,
|
||||||
layerBrightness,
|
layerBrightness,
|
||||||
onLayerBrightnessChange,
|
onLayerBrightnessChange,
|
||||||
|
layerColors,
|
||||||
|
onLayerColorChange,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
@ -345,6 +347,8 @@ export function LeftPanel({
|
|||||||
onLayerOpacityChange={onLayerOpacityChange}
|
onLayerOpacityChange={onLayerOpacityChange}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
onLayerBrightnessChange={onLayerBrightnessChange}
|
onLayerBrightnessChange={onLayerBrightnessChange}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onLayerColorChange={onLayerColorChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Oil Boom Placement Guide Section */}
|
{/* Oil Boom Placement Guide Section */}
|
||||||
|
|||||||
@ -67,7 +67,11 @@ ${styles}
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden bg-bg-base">
|
<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 justify-between mb-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -216,9 +216,10 @@ export function OilSpillView() {
|
|||||||
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]);
|
const [drawingPoints, setDrawingPoints] = useState<BoomLineCoord[]>([]);
|
||||||
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null);
|
const [containmentResult, setContainmentResult] = useState<ContainmentResult | null>(null);
|
||||||
|
|
||||||
// 레이어 스타일 (투명도 / 밝기)
|
// 레이어 스타일 (투명도 / 밝기 / 색상)
|
||||||
const [layerOpacity, setLayerOpacity] = useState(50);
|
const [layerOpacity, setLayerOpacity] = useState(50);
|
||||||
const [layerBrightness, setLayerBrightness] = useState(50);
|
const [layerBrightness, setLayerBrightness] = useState(50);
|
||||||
|
const [layerColors, setLayerColors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 표시 정보 제어
|
// 표시 정보 제어
|
||||||
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
const [displayControls, setDisplayControls] = useState<DisplayControls>({
|
||||||
@ -1200,6 +1201,8 @@ export function OilSpillView() {
|
|||||||
onLayerOpacityChange={setLayerOpacity}
|
onLayerOpacityChange={setLayerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
onLayerBrightnessChange={setLayerBrightness}
|
onLayerBrightnessChange={setLayerBrightness}
|
||||||
|
layerColors={layerColors}
|
||||||
|
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||||
sensitiveResources={sensitiveResourceCategories}
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
@ -1236,11 +1239,11 @@ export function OilSpillView() {
|
|||||||
drawingPoints={drawingPoints}
|
drawingPoints={drawingPoints}
|
||||||
layerOpacity={layerOpacity}
|
layerOpacity={layerOpacity}
|
||||||
layerBrightness={layerBrightness}
|
layerBrightness={layerBrightness}
|
||||||
|
layerColors={layerColors}
|
||||||
sensitiveResources={sensitiveResources}
|
sensitiveResources={sensitiveResources}
|
||||||
sensitiveResourceGeojson={
|
sensitiveResourceGeojson={
|
||||||
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
|
displayControls.showSensitiveResources ? sensitiveResourceGeojson : null
|
||||||
}
|
}
|
||||||
lightMode
|
|
||||||
centerPoints={centerPoints.filter((p) =>
|
centerPoints={centerPoints.filter((p) =>
|
||||||
visibleModels.has((p.model || 'OpenDrift') as PredictionModel),
|
visibleModels.has((p.model || 'OpenDrift') as PredictionModel),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -166,8 +166,8 @@ export function RecalcModal({
|
|||||||
🔄
|
🔄
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-[15px] font-bold m-0">확산예측 재계산</h2>
|
<h2 className="text-subtitle font-bold m-0">확산예측 재계산</h2>
|
||||||
<div className="text-[10px] text-fg-disabled mt-[2px]">
|
<div className="text-caption text-fg-disabled mt-[2px]">
|
||||||
유출유·유출량 등 파라미터를 수정하여 재실행
|
유출유·유출량 등 파라미터를 수정하여 재실행
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,10 +202,10 @@ export function RecalcModal({
|
|||||||
borderRadius: '8px',
|
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
|
<div
|
||||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}
|
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px' }}
|
||||||
className="text-[9px]"
|
className="text-caption"
|
||||||
>
|
>
|
||||||
<InfoItem label="사고명" value={incidentName} />
|
<InfoItem label="사고명" value={incidentName} />
|
||||||
<InfoItem label="유종" value={initOilType} />
|
<InfoItem label="유종" value={initOilType} />
|
||||||
@ -281,7 +281,7 @@ export function RecalcModal({
|
|||||||
<FieldGroup label="유출 위치 (좌표)">
|
<FieldGroup label="유출 위치 (좌표)">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<div className="flex-1">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="prd-i font-mono"
|
className="prd-i font-mono"
|
||||||
@ -291,7 +291,7 @@ export function RecalcModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="prd-i font-mono"
|
className="prd-i font-mono"
|
||||||
@ -347,7 +347,7 @@ export function RecalcModal({
|
|||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
opacity: phase !== 'editing' ? 0.5 : 1,
|
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>
|
</button>
|
||||||
@ -378,7 +378,7 @@ export function RecalcModal({
|
|||||||
: '#fff',
|
: '#fff',
|
||||||
opacity: models.size === 0 && phase === 'editing' ? 0.5 : 1,
|
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'
|
{phase === 'done'
|
||||||
? '✅ 재계산 완료!'
|
? '✅ 재계산 완료!'
|
||||||
@ -395,7 +395,7 @@ export function RecalcModal({
|
|||||||
function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
function FieldGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -442,7 +442,7 @@ export function RightPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: 'rgba(6,182,212,0.1)',
|
background: 'rgba(6,182,212,0.1)',
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
border: '1px solid rgba(6,182,212,0.2)',
|
||||||
|
|||||||
@ -54,6 +54,8 @@ export interface LeftPanelProps {
|
|||||||
onLayerOpacityChange: (val: number) => void;
|
onLayerOpacityChange: (val: number) => void;
|
||||||
layerBrightness: number;
|
layerBrightness: number;
|
||||||
onLayerBrightnessChange: (val: number) => void;
|
onLayerBrightnessChange: (val: number) => void;
|
||||||
|
layerColors: Record<string, string>;
|
||||||
|
onLayerColorChange: (layerId: string, color: string) => void;
|
||||||
// 영향 민감자원
|
// 영향 민감자원
|
||||||
sensitiveResources?: SensitiveResourceCategory[];
|
sensitiveResources?: SensitiveResourceCategory[];
|
||||||
// 이미지 분석 결과 콜백
|
// 이미지 분석 결과 콜백
|
||||||
|
|||||||
@ -410,8 +410,8 @@ const S = {
|
|||||||
marginBottom: '24px',
|
marginBottom: '24px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
|
fontFamily: 'var(--font-korean)',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-label-1)',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -421,14 +421,14 @@ const S = {
|
|||||||
background: 'rgba(6,182,212,0.12)',
|
background: 'rgba(6,182,212,0.12)',
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
fontSize: '13px',
|
fontSize: 'var(--font-size-title-4)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: '1px solid rgba(6,182,212,0.2)',
|
border: '1px solid rgba(6,182,212,0.2)',
|
||||||
},
|
},
|
||||||
subHeader: {
|
subHeader: {
|
||||||
fontSize: '14px',
|
fontSize: 'var(--font-size-title-3)',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'var(--color-accent)',
|
color: 'var(--color-accent)',
|
||||||
marginBottom: '12px',
|
marginBottom: '12px',
|
||||||
@ -439,7 +439,7 @@ const S = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
tableLayout: 'fixed' as const,
|
tableLayout: 'fixed' as const,
|
||||||
borderCollapse: 'collapse' as const,
|
borderCollapse: 'collapse' as const,
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
},
|
},
|
||||||
th: {
|
th: {
|
||||||
@ -449,20 +449,20 @@ const S = {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: 'var(--fg-sub)',
|
color: 'var(--fg-sub)',
|
||||||
textAlign: 'center' as const,
|
textAlign: 'center' as const,
|
||||||
fontSize: '10px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
},
|
},
|
||||||
td: {
|
td: {
|
||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
padding: '5px 10px',
|
padding: '5px 10px',
|
||||||
textAlign: 'center' as const,
|
textAlign: 'center' as const,
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
color: 'var(--fg-sub)',
|
color: 'var(--fg-sub)',
|
||||||
},
|
},
|
||||||
tdLeft: {
|
tdLeft: {
|
||||||
border: '1px solid var(--stroke-default)',
|
border: '1px solid var(--stroke-default)',
|
||||||
padding: '5px 10px',
|
padding: '5px 10px',
|
||||||
textAlign: 'left' as const,
|
textAlign: 'left' as const,
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
color: 'var(--fg-sub)',
|
color: 'var(--fg-sub)',
|
||||||
},
|
},
|
||||||
thLabel: {
|
thLabel: {
|
||||||
@ -472,7 +472,7 @@ const S = {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: 'var(--fg-sub)',
|
color: 'var(--fg-sub)',
|
||||||
textAlign: 'left' as const,
|
textAlign: 'left' as const,
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
width: '120px',
|
width: '120px',
|
||||||
},
|
},
|
||||||
mapPlaceholder: {
|
mapPlaceholder: {
|
||||||
@ -485,7 +485,7 @@ const S = {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--fg-disabled)',
|
color: 'var(--fg-disabled)',
|
||||||
fontSize: '13px',
|
fontSize: 'var(--font-size-title-4)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
},
|
},
|
||||||
@ -498,7 +498,7 @@ const inputStyle: React.CSSProperties = {
|
|||||||
border: '1px solid var(--stroke-light)',
|
border: '1px solid var(--stroke-light)',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
fontSize: '11px',
|
fontSize: 'var(--font-size-caption)',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
};
|
};
|
||||||
@ -546,7 +546,7 @@ function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string })
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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 || '행 추가'}
|
+ {label || '행 추가'}
|
||||||
</button>
|
</button>
|
||||||
@ -569,11 +569,11 @@ function Page1({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<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)' }}
|
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.2)' }}
|
||||||
>
|
>
|
||||||
유류오염사고 대응지원 상황도
|
유류오염사고 대응지원 상황도
|
||||||
@ -713,7 +713,7 @@ function Page2({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>2. 해양기상정보</div>
|
<div style={S.sectionTitle}>2. 해양기상정보</div>
|
||||||
@ -969,7 +969,7 @@ function Page3({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>분석</div>
|
<div style={S.sectionTitle}>분석</div>
|
||||||
@ -985,7 +985,7 @@ function Page3({
|
|||||||
border: '1px solid var(--stroke-light)',
|
border: '1px solid var(--stroke-light)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
fontSize: '13px',
|
fontSize: 'var(--font-size-title-4)',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
lineHeight: '1.8',
|
lineHeight: '1.8',
|
||||||
@ -1002,7 +1002,7 @@ function Page3({
|
|||||||
padding: '16px',
|
padding: '16px',
|
||||||
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||||||
fontStyle: data.analysis ? 'normal' : 'italic',
|
fontStyle: data.analysis ? 'normal' : 'italic',
|
||||||
fontSize: '13px',
|
fontSize: 'var(--font-size-title-4)',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
lineHeight: '1.8',
|
lineHeight: '1.8',
|
||||||
}}
|
}}
|
||||||
@ -1684,7 +1684,7 @@ function Page4({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||||
@ -1820,7 +1820,9 @@ function Page4({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.esi.map((e, i) => (
|
{data.esi.map((e, i) => (
|
||||||
<tr key={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>
|
<td style={S.tdLeft}>{e.type}</td>
|
||||||
<ECell
|
<ECell
|
||||||
value={e.length}
|
value={e.length}
|
||||||
@ -2229,7 +2231,7 @@ function Page5({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
<div style={S.sectionTitle}>통합민감도 평가 (해당 계절)</div>
|
||||||
@ -2283,7 +2285,7 @@ function Page6({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>5. 방제전략 수립·실행</div>
|
<div style={S.sectionTitle}>5. 방제전략 수립·실행</div>
|
||||||
@ -2414,7 +2416,7 @@ function Page6({
|
|||||||
border: '1px solid var(--stroke-light)',
|
border: '1px solid var(--stroke-light)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-label-1)',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
}}
|
}}
|
||||||
@ -2430,7 +2432,7 @@ function Page6({
|
|||||||
padding: '12px',
|
padding: '12px',
|
||||||
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
|
||||||
fontStyle: data.etcEquipment ? 'normal' : 'italic',
|
fontStyle: data.etcEquipment ? 'normal' : 'italic',
|
||||||
fontSize: '12px',
|
fontSize: 'var(--font-size-label-1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.etcEquipment || '-'}
|
{data.etcEquipment || '-'}
|
||||||
@ -2458,7 +2460,7 @@ function Page7({
|
|||||||
onChange({ ...data, result: { ...data.result, [k]: v } });
|
onChange({ ...data, result: { ...data.result, [k]: v } });
|
||||||
return (
|
return (
|
||||||
<div style={S.page}>
|
<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>
|
||||||
<div style={S.sectionTitle}>방제선/자원 동원 결과</div>
|
<div style={S.sectionTitle}>방제선/자원 동원 결과</div>
|
||||||
@ -2591,25 +2593,25 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-[18px] font-bold">
|
<h2 className="text-title-1 font-bold">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
value={data.title}
|
value={data.title}
|
||||||
onChange={(e) => setData({ ...data, title: e.target.value })}
|
onChange={(e) => setData({ ...data, title: e.target.value })}
|
||||||
placeholder="보고서 제목 입력"
|
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 || '유류오염사고 대응지원 상황도'
|
data.title || '유류오염사고 대응지원 상황도'
|
||||||
)}
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<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={
|
style={
|
||||||
editing
|
editing
|
||||||
? {
|
? {
|
||||||
@ -2630,7 +2632,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('all')}
|
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={{
|
style={{
|
||||||
border:
|
border:
|
||||||
viewMode === 'all'
|
viewMode === 'all'
|
||||||
@ -2644,7 +2646,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewMode('page')}
|
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={{
|
style={{
|
||||||
border:
|
border:
|
||||||
viewMode === 'page'
|
viewMode === 'page'
|
||||||
@ -2659,14 +2661,14 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
{editing && (
|
{editing && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
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
|
인쇄 / PDF
|
||||||
</button>
|
</button>
|
||||||
@ -2680,7 +2682,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => setCurrentPage(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={{
|
style={{
|
||||||
border:
|
border:
|
||||||
currentPage === i
|
currentPage === i
|
||||||
@ -2707,7 +2709,7 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={currentPage === 0}
|
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={{
|
style={{
|
||||||
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
|
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
|
||||||
opacity: currentPage === 0 ? 0.4 : 1,
|
opacity: currentPage === 0 ? 0.4 : 1,
|
||||||
@ -2715,13 +2717,13 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr
|
|||||||
>
|
>
|
||||||
이전
|
이전
|
||||||
</button>
|
</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}
|
{currentPage + 1} / {pages.length}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
|
||||||
disabled={currentPage === pages.length - 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={{
|
style={{
|
||||||
border: '1px solid var(--color-accent)',
|
border: '1px solid var(--color-accent)',
|
||||||
background: 'rgba(6,182,212,0.1)',
|
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