From 7a80eaf75e8d0df69b5e0f5fe66efab432d9b3a1 Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 11:02:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(admin):=20=EC=88=98=EA=B1=B0=EC=9D=B8?= =?UTF-8?q?=EB=A0=A5=20=ED=8C=A8=EB=84=90=20=EB=B0=8F=20=EC=84=A0=EB=B0=95?= =?UTF-8?q?=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tabs/admin/components/AdminView.tsx | 4 + .../tabs/admin/components/CollectHrPanel.tsx | 333 ++++++++++++++++++ .../admin/components/MonitorVesselPanel.tsx | 246 +++++++++++++ .../tabs/admin/components/adminMenuConfig.ts | 1 - 4 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 frontend/src/tabs/admin/components/CollectHrPanel.tsx create mode 100644 frontend/src/tabs/admin/components/MonitorVesselPanel.tsx diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index be9e70b..df9a5d1 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -15,6 +15,8 @@ import LayerPanel from './LayerPanel'; import SensitiveLayerPanel from './SensitiveLayerPanel'; import DispersingZonePanel from './DispersingZonePanel'; import MonitorRealtimePanel from './MonitorRealtimePanel'; +import MonitorVesselPanel from './MonitorVesselPanel'; +import CollectHrPanel from './CollectHrPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record JSX.Element> = { @@ -34,6 +36,8 @@ const PANEL_MAP: Record JSX.Element> = { 'social-economy': () => , 'dispersant-zone': () => , 'monitor-realtime': () => , + 'monitor-vessel': () => , + 'collect-hr': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/CollectHrPanel.tsx b/frontend/src/tabs/admin/components/CollectHrPanel.tsx new file mode 100644 index 0000000..f68f62d --- /dev/null +++ b/frontend/src/tabs/admin/components/CollectHrPanel.tsx @@ -0,0 +1,333 @@ +import { useState, useEffect, useCallback } from 'react'; + +// ─── 타입 ────────────────────────────────────────────────── + +interface EtaClct { + startDate: string; + endDate: string; +} + +interface ResultClct { + resultDate: string; + count: number; +} + +interface HrCollectItem { + id: string; + rootId: string; + ip: string; + depth1: string; + depth2: string; + depth3: string; + depth4: string | null; + clctName: string; + clctType: string; + clctTypeName: string; + trnsmtCycle: string | null; + receiveCycle: string | null; + targetTable: string; + seq: number; + estmtRqrd: string; + activeYn: string; + clctStartDt: string; + clctEndDt: string; + clctDate: string | null; + jobName: string; + resultClctList: ResultClct[]; + etaClctList: EtaClct[]; +} + +// ─── Mock 데이터 ──────────────────────────────────────────── + +// TODO: 실제 API 연동 시 fetch 호출로 교체 +const MOCK_DATA: HrCollectItem[] = [ + { + id: '100200', + rootId: '2', + ip: '127.0.0.1', + depth1: '연계', + depth2: '해양경찰청', + depth3: '해경업무포탈시스템(KBP)', + depth4: null, + clctName: '사용자부서', + clctType: '000002', + clctTypeName: '배치', + trnsmtCycle: null, + receiveCycle: '0 20 4 * * *', + targetTable: 'common.t_dept_info', + seq: 101, + estmtRqrd: '1', + activeYn: 'Y', + clctStartDt: '2024-12-16', + clctEndDt: '9999-12-31', + clctDate: '2024-12-16', + jobName: 'DeptJob', + resultClctList: [], + etaClctList: [ + { startDate: '2025-09-12 04:20', endDate: '2025-09-12 04:21' }, + ], + }, + { + id: '100200', + rootId: '2', + ip: '127.0.0.1', + depth1: '연계', + depth2: '해양경찰청', + depth3: '해경업무포탈시스템(KBP)', + depth4: null, + clctName: '사용자계정', + clctType: '000002', + clctTypeName: '배치', + trnsmtCycle: null, + receiveCycle: '0 20 4 * 1 *', + targetTable: 'common.t_usr', + seq: 102, + estmtRqrd: '5', + activeYn: 'Y', + clctStartDt: '2024-12-17', + clctEndDt: '9999-12-31', + clctDate: null, + jobName: 'UserFlowJob', + resultClctList: [], + etaClctList: [], + }, + { + id: '100201', + rootId: '2', + ip: '127.0.0.1', + depth1: '연계', + depth2: '해양경찰청', + depth3: '해경업무포탈시스템(KBP)', + depth4: null, + clctName: '사용자직위', + clctType: '000002', + clctTypeName: '배치', + trnsmtCycle: null, + receiveCycle: '0 30 4 * * *', + targetTable: 'common.t_position_info', + seq: 103, + estmtRqrd: '1', + activeYn: 'Y', + clctStartDt: '2024-12-16', + clctEndDt: '9999-12-31', + clctDate: '2025-01-10', + jobName: 'PositionJob', + resultClctList: [{ resultDate: '2025-09-12 04:30', count: 42 }], + etaClctList: [ + { startDate: '2025-09-12 04:30', endDate: '2025-09-12 04:31' }, + ], + }, + { + id: '100202', + rootId: '2', + ip: '127.0.0.1', + depth1: '연계', + depth2: '해양경찰청', + depth3: '해경업무포탈시스템(KBP)', + depth4: null, + clctName: '조직정보', + clctType: '000002', + clctTypeName: '배치', + trnsmtCycle: null, + receiveCycle: '0 40 4 * * *', + targetTable: 'common.t_org_info', + seq: 104, + estmtRqrd: '2', + activeYn: 'Y', + clctStartDt: '2024-12-18', + clctEndDt: '9999-12-31', + clctDate: '2025-03-20', + jobName: 'OrgJob', + resultClctList: [{ resultDate: '2025-09-12 04:40', count: 15 }], + etaClctList: [ + { startDate: '2025-09-12 04:40', endDate: '2025-09-12 04:41' }, + ], + }, + { + id: '100203', + rootId: '2', + ip: '127.0.0.1', + depth1: '연계', + depth2: '해양경찰청', + depth3: '해경업무포탈시스템(KBP)', + depth4: null, + clctName: '근무상태', + clctType: '000002', + clctTypeName: '배치', + trnsmtCycle: null, + receiveCycle: '0 0 5 * * *', + targetTable: 'common.t_work_status', + seq: 105, + estmtRqrd: '3', + activeYn: 'N', + clctStartDt: '2025-01-15', + clctEndDt: '9999-12-31', + clctDate: null, + jobName: 'WorkStatusJob', + resultClctList: [], + etaClctList: [], + }, +]; + +function fetchHrCollectData(): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_DATA), 300); + }); +} + +// ─── 상태 뱃지 ───────────────────────────────────────────── + +function getCollectStatus(item: HrCollectItem): { label: string; color: string } { + if (item.activeYn !== 'Y') { + return { label: '비활성', color: 'text-t3 bg-bg-2' }; + } + if (item.etaClctList.length > 0) { + return { label: '완료', color: 'text-emerald-400 bg-emerald-500/10' }; + } + return { label: '대기', color: 'text-yellow-400 bg-yellow-500/10' }; +} + +// ─── cron 표현식 → 읽기 쉬운 형태 ───────────────────────── + +function formatCron(cron: string | null): string { + if (!cron) return '-'; + const parts = cron.split(' '); + if (parts.length < 6) return cron; + const [sec, min, hour, , , ] = parts; + return `매일 ${hour}:${min.padStart(2, '0')}:${sec.padStart(2, '0')}`; +} + +// ─── 테이블 ───────────────────────────────────────────────── + +const HEADERS = ['번호', '수집항목', '기관', '시스템', '유형', '수집주기', '대상테이블', 'Job명', '활성', '수집시작일', '최근수집일', '상태']; + +function HrTable({ rows, loading }: { rows: HrCollectItem[]; loading: boolean }) { + return ( +
+ + + + {HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + {HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row, idx) => { + const status = getCollectStatus(row); + return ( + + + + + + + + + + + + + + + ); + })} + +
{h}
+
+
{idx + 1}{row.clctName}{row.depth2}{row.depth3}{row.clctTypeName}{formatCron(row.receiveCycle)}{row.targetTable}{row.jobName} + + {row.activeYn === 'Y' ? 'Y' : 'N'} + + {row.clctStartDt}{row.clctDate ?? '-'} + + {status.label} + +
+
+ ); +} + +// ─── 메인 패널 ────────────────────────────────────────────── + +export default function CollectHrPanel() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + const data = await fetchHrCollectData(); + setRows(data); + setLoading(false); + setLastUpdate(new Date()); + }, []); + + useEffect(() => { + let isMounted = true; + if (rows.length === 0) { + void Promise.resolve().then(() => { if (isMounted) void fetchData(); }); + } + return () => { isMounted = false; }; + }, [rows.length, fetchData]); + + const activeCount = rows.filter((r) => r.activeYn === 'Y').length; + const completedCount = rows.filter((r) => r.etaClctList.length > 0).length; + + return ( +
+ {/* 헤더 */} +
+

인사정보 수집 현황

+
+ {lastUpdate && ( + + 갱신: {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + )} + +
+
+ + {/* 상태 표시줄 */} +
+ + + 수집 완료 {completedCount}건 + + + 전체 {rows.length}건 (활성: {activeCount} / 비활성: {rows.length - activeCount}) + +
+ + {/* 테이블 */} +
+ +
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx b/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx new file mode 100644 index 0000000..bcee5ca --- /dev/null +++ b/frontend/src/tabs/admin/components/MonitorVesselPanel.tsx @@ -0,0 +1,246 @@ +import { useState, useEffect, useCallback } from 'react'; + +// TODO: 실제 API 연동 시 fetch 호출로 교체 +interface VesselMonitorRow { + institution: string; + institutionCode: string; + systemName: string; + linkInfo: string; + storagePlace: string; + linkMethod: string; + collectionCycle: string; + collectionCount: string; + isNormal: boolean; + lastMessageTime: string; +} + +/** 기관코드 → 원천기관명 매핑 */ +const INSTITUTION_NAMES: Record = { + BS: '부산항', + BSN: '부산신항', + DH: '동해안', + DS: '대산항', + GI: '경인항', + GIC: '경인연안', + GS: '군산항', + IC: '인천항', + JDC: '진도연안', + JJ: '제주항', + MP: '목포항', +}; + +/** Mock 데이터 정의 (스크린샷 기반) */ +const MOCK_DATA: Omit[] = [ + { institutionCode: 'BS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '439 / 499', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'BS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '133 / 463', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'BSN', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '255 / 278', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'BSN', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '133 / 426', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'DH', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'DH', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'DS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '0', isNormal: false, lastMessageTime: '2026-03-15 15:38:57' }, + { institutionCode: 'DS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '0', isNormal: false, lastMessageTime: '2026-03-15 15:38:56' }, + { institutionCode: 'GI', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '120 / 136', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'GI', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '55 / 467', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'GIC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '180 / 216', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'GIC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'GS', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'GS', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'IC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '149 / 176', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'IC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '55 / 503', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'JDC', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '433 / 524', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'JDC', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '256 / 1619', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'JJ', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '429 / 508', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'JJ', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '00:00:00', collectionCount: '160 / 1592', isNormal: true, lastMessageTime: '2026-03-25 10:29:09' }, + { institutionCode: 'MP', systemName: 'VTS_AIS', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, + { institutionCode: 'MP', systemName: 'VTS_RT', linkInfo: 'VTS', storagePlace: 'signal.t_dynamic_all_reply', linkMethod: 'KAFKA', collectionCycle: '수신대기중', collectionCount: '0', isNormal: false, lastMessageTime: '' }, +]; + +/** Mock fetch — TODO: 실제 API 연동 시 fetch 호출로 교체 */ +function fetchVesselMonitorData(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const rows: VesselMonitorRow[] = MOCK_DATA.map((d) => ({ + ...d, + institution: INSTITUTION_NAMES[d.institutionCode] ?? d.institutionCode, + })); + resolve(rows); + }, 400); + }); +} + +/* ── 상태 뱃지 ── */ +function StatusBadge({ loading, onCount, total }: { loading: boolean; onCount: number; total: number }) { + if (loading) { + return ( + + + 조회 중... + + ); + } + const offCount = total - onCount; + if (offCount === total) { + return ( + + + 전체 OFF + + ); + } + if (offCount > 0) { + return ( + + + 일부 OFF ({offCount}/{total}) + + ); + } + return ( + + + 전체 정상 + + ); +} + +/* ── 연결상태 셀 ── */ +function ConnectionBadge({ isNormal, lastMessageTime }: { isNormal: boolean; lastMessageTime: string }) { + if (isNormal) { + return ( +
+ + ON + + {lastMessageTime && ( + {lastMessageTime} + )} +
+ ); + } + return ( +
+ + OFF + + {lastMessageTime && ( + {lastMessageTime} + )} +
+ ); +} + +/* ── 테이블 ── */ +const HEADERS = ['번호', '원천기관', '기관코드', '정보시스템명', '연계정보', '저장장소', '연계방식', '수집주기', '선박건수/신호건수', '연결상태']; + +function VesselTable({ rows, loading }: { rows: VesselMonitorRow[]; loading: boolean }) { + return ( +
+ + + + {HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 8 }).map((_, i) => ( + + {HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row, idx) => ( + + + + + + + + + + + + + ))} + +
{h}
+
+
{idx + 1}{row.institution}{row.institutionCode}{row.systemName}{row.linkInfo}{row.storagePlace}{row.linkMethod}{row.collectionCycle}{row.collectionCount} + +
+
+ ); +} + +/* ── 메인 패널 ── */ +export default function MonitorVesselPanel() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + const data = await fetchVesselMonitorData(); + setRows(data); + setLoading(false); + setLastUpdate(new Date()); + }, []); + + useEffect(() => { + let isMounted = true; + if (rows.length === 0) { + void Promise.resolve().then(() => { if (isMounted) void fetchData(); }); + } + return () => { isMounted = false; }; + }, [rows.length, fetchData]); + + const onCount = rows.filter((r) => r.isNormal).length; + + return ( +
+ {/* 헤더 */} +
+

선박위치정보 모니터링

+
+ {lastUpdate && ( + + 갱신: {lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + + )} + +
+
+ + {/* 상태 표시줄 */} +
+ + + 연계 채널 {rows.length}개 (ON: {onCount} / OFF: {rows.length - onCount}) + +
+ + {/* 테이블 */} +
+ +
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/adminMenuConfig.ts b/frontend/src/tabs/admin/components/adminMenuConfig.ts index 4bb3863..48d3593 100644 --- a/frontend/src/tabs/admin/components/adminMenuConfig.ts +++ b/frontend/src/tabs/admin/components/adminMenuConfig.ts @@ -74,7 +74,6 @@ export const ADMIN_MENU: AdminMenuItem[] = [ { id: 'monitor-realtime', label: '실시간 관측자료' }, { id: 'monitor-forecast', label: '수치예측자료' }, { id: 'monitor-vessel', label: '선박위치정보' }, - { id: 'monitor-hr', label: '인사' }, ], }, ], From 5f84d5f11ed14073cb110762e422ec5c90125aa7 Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 14:43:58 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(design):=20Components=20=ED=83=AD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(Button,=20TextField,=20Overview=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/design/ButtonContent.tsx | 741 ++++++++ .../src/pages/design/ComponentsOverview.tsx | 219 +++ frontend/src/pages/design/DesignHeader.tsx | 10 +- frontend/src/pages/design/DesignPage.tsx | 25 +- frontend/src/pages/design/DesignSidebar.tsx | 50 +- .../src/pages/design/FoundationsOverview.tsx | 274 +++ .../src/pages/design/TextFieldContent.tsx | 1546 +++++++++++++++++ 7 files changed, 2818 insertions(+), 47 deletions(-) create mode 100644 frontend/src/pages/design/ButtonContent.tsx create mode 100644 frontend/src/pages/design/ComponentsOverview.tsx create mode 100644 frontend/src/pages/design/FoundationsOverview.tsx create mode 100644 frontend/src/pages/design/TextFieldContent.tsx diff --git a/frontend/src/pages/design/ButtonContent.tsx b/frontend/src/pages/design/ButtonContent.tsx new file mode 100644 index 0000000..a02dd75 --- /dev/null +++ b/frontend/src/pages/design/ButtonContent.tsx @@ -0,0 +1,741 @@ +// ButtonContent.tsx — WING-OPS Button 컴포넌트 상세 페이지 (다크/라이트 테마 지원) + +import type { DesignTheme } from './designTheme'; + +// ---------- 타입 ---------- + +interface ButtonSizeRow { + label: string; + heightClass: string; + heightPx: number; + px: number; +} + +interface ButtonVariantStyle { + bg: string; + text: string; + border?: string; +} + +interface ButtonStateRow { + state: string; + accent: ButtonVariantStyle; + primary: ButtonVariantStyle; + secondary: ButtonVariantStyle; + tertiary: ButtonVariantStyle; + tertiaryFilled: ButtonVariantStyle; +} + +// ---------- 데이터 ---------- + +const BUTTON_SIZES: ButtonSizeRow[] = [ + { label: 'XLarge (56)', heightClass: 'h-14', heightPx: 56, px: 24 }, + { label: 'Large (48)', heightClass: 'h-12', heightPx: 48, px: 20 }, + { label: 'Medium (44)', heightClass: 'h-11', heightPx: 44, px: 16 }, + { label: 'Small (32)', heightClass: 'h-8', heightPx: 32, px: 12 }, + { label: 'XSmall (24)', heightClass: 'h-6', heightPx: 24, px: 8 }, +]; + +const VARIANTS = ['Accent', 'Primary', 'Secondary', 'Tertiary', 'Tertiary (filled)'] as const; + +const getDarkStateRows = (): ButtonStateRow[] => [ + { + state: 'Default', + accent: { bg: '#ef4444', text: '#fff' }, + primary: { bg: '#1a1a2e', text: '#fff' }, + secondary: { bg: '#6b7280', text: '#fff' }, + tertiary: { bg: 'transparent', text: '#c2c6d6', border: '#6b7280' }, + tertiaryFilled: { bg: '#374151', text: '#c2c6d6' }, + }, + { + state: 'Hover', + accent: { bg: '#dc2626', text: '#fff' }, + primary: { bg: '#2d2d44', text: '#fff' }, + secondary: { bg: '#7c8393', text: '#fff' }, + tertiary: { bg: 'rgba(255,255,255,0.05)', text: '#c2c6d6', border: '#9ca3af' }, + tertiaryFilled: { bg: '#4b5563', text: '#c2c6d6' }, + }, + { + state: 'Pressed', + accent: { bg: '#b91c1c', text: '#fff' }, + primary: { bg: '#3d3d5c', text: '#fff' }, + secondary: { bg: '#9ca3af', text: '#fff' }, + tertiary: { bg: 'rgba(255,255,255,0.1)', text: '#c2c6d6', border: '#9ca3af' }, + tertiaryFilled: { bg: '#6b7280', text: '#c2c6d6' }, + }, + { + state: 'Disabled', + accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' }, + primary: { bg: 'rgba(26,26,46,0.5)', text: 'rgba(255,255,255,0.4)' }, + secondary: { bg: 'rgba(107,114,128,0.3)', text: 'rgba(255,255,255,0.4)' }, + tertiary: { bg: 'transparent', text: 'rgba(255,255,255,0.3)', border: 'rgba(107,114,128,0.3)' }, + tertiaryFilled: { bg: 'rgba(55,65,81,0.3)', text: 'rgba(255,255,255,0.3)' }, + }, +]; + +const getLightStateRows = (): ButtonStateRow[] => [ + { + state: 'Default', + accent: { bg: '#ef4444', text: '#fff' }, + primary: { bg: '#1a1a2e', text: '#fff' }, + secondary: { bg: '#d1d5db', text: '#374151' }, + tertiary: { bg: 'transparent', text: '#374151', border: '#d1d5db' }, + tertiaryFilled: { bg: '#e5e7eb', text: '#374151' }, + }, + { + state: 'Hover', + accent: { bg: '#dc2626', text: '#fff' }, + primary: { bg: '#2d2d44', text: '#fff' }, + secondary: { bg: '#bcc0c7', text: '#374151' }, + tertiary: { bg: 'rgba(0,0,0,0.03)', text: '#374151', border: '#9ca3af' }, + tertiaryFilled: { bg: '#d1d5db', text: '#374151' }, + }, + { + state: 'Pressed', + accent: { bg: '#b91c1c', text: '#fff' }, + primary: { bg: '#3d3d5c', text: '#fff' }, + secondary: { bg: '#9ca3af', text: '#374151' }, + tertiary: { bg: 'rgba(0,0,0,0.06)', text: '#374151', border: '#6b7280' }, + tertiaryFilled: { bg: '#bcc0c7', text: '#374151' }, + }, + { + state: 'Disabled', + accent: { bg: 'rgba(239,68,68,0.3)', text: 'rgba(255,255,255,0.5)' }, + primary: { bg: 'rgba(26,26,46,0.3)', text: 'rgba(255,255,255,0.5)' }, + secondary: { bg: 'rgba(209,213,219,0.5)', text: 'rgba(55,65,81,0.4)' }, + tertiary: { bg: 'transparent', text: 'rgba(55,65,81,0.3)', border: 'rgba(209,213,219,0.5)' }, + tertiaryFilled: { bg: 'rgba(229,231,235,0.5)', text: 'rgba(55,65,81,0.3)' }, + }, +]; + +// ---------- Props ---------- + +interface ButtonContentProps { + theme: DesignTheme; +} + +// ---------- 헬퍼 ---------- + +function getVariantStyle(row: ButtonStateRow, variantIndex: number): ButtonVariantStyle { + const keys: (keyof Omit)[] = [ + 'accent', + 'primary', + 'secondary', + 'tertiary', + 'tertiaryFilled', + ]; + return row[keys[variantIndex]]; +} + +// ---------- 컴포넌트 ---------- + +export const ButtonContent = ({ theme }: ButtonContentProps) => { + const t = theme; + const isDark = t.mode === 'dark'; + + const sectionCardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e5e7eb'; + const badgeBg = isDark ? '#4a5568' : '#6b7280'; + const annotationColor = isDark ? '#f87171' : '#ef4444'; + const buttonDarkBg = isDark ? '#e2e8f0' : '#1a1a2e'; + const buttonDarkText = isDark ? '#1a1a2e' : '#fff'; + + const stateRows = isDark ? getDarkStateRows() : getLightStateRows(); + + return ( +
+
+ + {/* ── 섹션 1: 헤더 ── */} +
+

+ Components +

+

+ Button +

+

+ 사용자의 의도를 명확하게 전달하고, 행동을 유도합니다. +

+

+ 버튼의 형태와 색은 우선순위를 시각적으로 구분합니다. +

+
+ + {/* ── 섹션 2: Anatomy ── */} +
+

+ Anatomy +

+ + {/* Anatomy 카드 */} +
+
+ + {/* 왼쪽: 텍스트 + 아이콘 버튼 */} +
+
+ {/* 버튼 본체 */} +
+ {/* Container 번호 — 테두리 점선 */} + + 레이블 + + + + {/* 번호 뱃지 — Container (1) */} + + 1 + + + {/* 번호 뱃지 — Label (2) */} + + 2 + + + {/* 번호 뱃지 — Icon (3) */} + + 3 + +
+
+ + 텍스트 + 아이콘 버튼 + +
+ + {/* 오른쪽: 아이콘 전용 버튼 */} +
+
+
+ + ♥ + + {/* 번호 뱃지 — Container (1) */} + + 1 + + + {/* 번호 뱃지 — Icon (3) */} + + 3 + +
+
+ + 아이콘 전용 버튼 + +
+
+
+ + {/* 번호 목록 */} +
    + {[ + { label: 'Container', desc: '버튼의 외곽 영역. 클릭 가능한 전체 영역을 정의합니다.' }, + { label: 'Label', desc: '버튼의 텍스트 레이블.' }, + { label: 'Icon (Optional)', desc: '선택적으로 추가되는 아이콘 요소.' }, + ].map((item) => ( +
  1. + + {item.label} + + {' '}— {item.desc} +
  2. + ))} +
+
+ + {/* ── 섹션 3: Spec ── */} +
+

+ Spec +

+ + {/* 3-1. Size */} +
+

+ 1. Size +

+
+
+ {BUTTON_SIZES.map((size) => ( +
+ {/* 라벨 */} + + {size.label} + + + {/* 실제 크기 버튼 */} +
+ +
+
+ ))} +
+
+
+ + {/* 3-2. Container */} +
+

+ 2. Container +

+
+
+ + {/* Flexible */} +
+ + Flexible + +
+
+ {/* 좌측 padding 치수선 */} +
+
+ + 20px + +
+ + + + {/* 우측 padding 치수선 */} +
+
+ + 20px + +
+
+ + 콘텐츠에 맞게 너비가 자동으로 조정됩니다. + +
+
+ + {/* Fixed */} +
+ + Fixed + +
+
+ + {/* 고정 너비 표시 */} +
+
+ + Fixed Width + +
+
+
+ + 너비가 고정된 버튼입니다. + +
+
+
+
+
+ + {/* 3-3. Label */} +
+

+ 3. Label +

+
+
+ {[ + { resolution: '해상도 430', width: '100%', maxWidth: '390px', padding: 16 }, + { resolution: '해상도 360', width: '100%', maxWidth: '328px', padding: 16 }, + { resolution: '해상도 320', width: '248px', maxWidth: '248px', padding: 16 }, + ].map((item) => ( +
+ + {item.resolution} + +
+
+ + {/* 패딩 주석 */} + + padding {item.padding} + +
+
+
+ ))} +
+
+
+
+ + {/* ── 섹션 4: Style (변형 × 상태 매트릭스) ── */} +
+

+ Style +

+ +
+ + {/* 열 헤더 */} + + + {/* 빈 셀 (상태 열) */} + + ))} + + + + + {stateRows.map((row, rowIdx) => ( + + {/* 상태 라벨 */} + + + {/* 각 변형별 버튼 셀 */} + {VARIANTS.map((_, vIdx) => { + const style = getVariantStyle(row, vIdx); + return ( + + ); + })} + + ))} + +
+ {VARIANTS.map((variant) => ( + + {variant} +
+ {row.state} + + +
+
+
+ +
+
+ ); +}; + +export default ButtonContent; diff --git a/frontend/src/pages/design/ComponentsOverview.tsx b/frontend/src/pages/design/ComponentsOverview.tsx new file mode 100644 index 0000000..6610197 --- /dev/null +++ b/frontend/src/pages/design/ComponentsOverview.tsx @@ -0,0 +1,219 @@ +// ComponentsOverview.tsx — Components 탭 Overview 카드 그리드 + +import type { DesignTheme } from './designTheme'; + +// ---------- 타입 ---------- + +interface OverviewCard { + id: string; + label: string; + thumbnail: (isDark: boolean) => React.ReactNode; +} + +// ---------- 썸네일 구현 ---------- + +const ButtonsThumbnail = ({ isDark }: { isDark: boolean }) => { + const accent = isDark ? '#4cd7f6' : '#06b6d4'; + const secondaryBg = isDark ? 'rgba(255,255,255,0.07)' : '#e2e8f0'; + const secondaryText = isDark ? 'rgba(223,226,243,0.85)' : '#475569'; + const outlineBorder = isDark ? 'rgba(76,215,246,0.40)' : 'rgba(6,182,212,0.50)'; + + const buttons = [ + { label: 'Primary', bg: accent, border: accent, color: isDark ? '#0a0e1a' : '#ffffff' }, + { label: 'Secondary', bg: secondaryBg, border: 'transparent', color: secondaryText }, + { label: 'Outline', bg: 'transparent', border: outlineBorder, color: accent }, + ]; + + return ( +
+ {buttons.map(({ label, bg, border, color }) => ( +
+ {label} +
+ ))} +
+ ); +}; + +const TextInputsThumbnail = ({ isDark }: { isDark: boolean }) => { + const labelColor = isDark ? 'rgba(194,198,214,0.80)' : '#64748b'; + const inputBg = isDark ? 'rgba(255,255,255,0.04)' : '#ffffff'; + const inputBorder = isDark ? 'rgba(255,255,255,0.12)' : '#cbd5e1'; + const placeholderColor = isDark ? 'rgba(140,144,159,0.60)' : '#94a3b8'; + const accentBorder = isDark ? '#4cd7f6' : '#06b6d4'; + + return ( +
+ {/* 라벨 + 기본 입력 */} +
+
+
+
+
+
+ {/* 포커스 상태 입력 */} +
+
+
+
+
+
+
+ ); +}; + +// ---------- 카드 정의 ---------- + +const OVERVIEW_CARDS: OverviewCard[] = [ + { + id: 'buttons', + label: 'Buttons', + thumbnail: (isDark) => , + }, + { + id: 'text-field', + label: 'Text Field', + thumbnail: (isDark) => , + }, +]; + +// ---------- Props ---------- + +interface ComponentsOverviewProps { + theme: DesignTheme; + onNavigate: (id: string) => void; +} + +// ---------- 컴포넌트 ---------- + +const ComponentsOverview = ({ theme, onNavigate }: ComponentsOverviewProps) => { + const t = theme; + const isDark = t.mode === 'dark'; + + const cardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const cardBorder = isDark ? 'rgba(255,255,255,0.06)' : '#e5e5e5'; + const thumbnailBorderBottom = isDark ? 'rgba(255,255,255,0.06)' : '#e0e0e0'; + + return ( +
+ + {/* ── 헤더 영역 ── */} +
+ + Components + +

+ Overview +

+

+ 재사용 가능한 UI 컴포넌트 카탈로그입니다. +

+
+ + {/* ── 3열 카드 그리드 ── */} +
+ {OVERVIEW_CARDS.map((card) => ( +
onNavigate(card.id)} + onMouseEnter={(e) => { + const el = e.currentTarget; + el.style.transform = 'scale(1.025)'; + el.style.boxShadow = isDark + ? '0 8px 24px rgba(0,0,0,0.35)' + : '0 6px 18px rgba(0,0,0,0.10)'; + el.style.borderColor = isDark + ? 'rgba(76,215,246,0.22)' + : 'rgba(6,182,212,0.28)'; + }} + onMouseLeave={(e) => { + const el = e.currentTarget; + el.style.transform = 'scale(1)'; + el.style.boxShadow = 'none'; + el.style.borderColor = cardBorder; + }} + > + {/* 썸네일 영역 */} +
+ {card.thumbnail(isDark)} +
+ + {/* 카드 라벨 */} +
+ + {card.label} + +
+
+ ))} +
+
+ ); +}; + +export default ComponentsOverview; diff --git a/frontend/src/pages/design/DesignHeader.tsx b/frontend/src/pages/design/DesignHeader.tsx index afd37c5..96654ed 100644 --- a/frontend/src/pages/design/DesignHeader.tsx +++ b/frontend/src/pages/design/DesignHeader.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from 'react-router-dom'; import type { DesignTheme } from './designTheme'; export type DesignTab = 'foundations' | 'components'; @@ -16,6 +17,7 @@ const TABS: { label: string; id: DesignTab }[] = [ export const DesignHeader = ({ activeTab, onTabChange, theme, onThemeToggle }: DesignHeaderProps) => { const isDark = theme.mode === 'dark'; + const navigate = useNavigate(); return (
{/* 좌측: 로고 + 버전 뱃지 */}
- navigate('/')} + className="font-sans text-2xl leading-8 font-bold bg-transparent border-none p-0 cursor-pointer transition-opacity hover:opacity-70" style={{ letterSpacing: '2.4px', color: theme.textAccent }} > WING-OPS - +
= { - foundations: 'color', - components: 'buttons', + foundations: 'overview', + components: 'overview', }; export const DesignPage = () => { const [activeTab, setActiveTab] = useState('foundations'); const [themeMode, setThemeMode] = useState('dark'); - const [sidebarItem, setSidebarItem] = useState('color'); + const [sidebarItem, setSidebarItem] = useState('overview'); const theme = getTheme(themeMode); @@ -32,6 +36,8 @@ export const DesignPage = () => { const renderContent = () => { if (activeTab === 'foundations') { switch (sidebarItem) { + case 'overview': + return setSidebarItem(id as MenuItemId)} />; case 'color': return ; case 'typography': @@ -41,10 +47,19 @@ export const DesignPage = () => { case 'layout': return ; default: - return ; + return setSidebarItem(id as MenuItemId)} />; } } - return ; + switch (sidebarItem) { + case 'overview': + return setSidebarItem(id as MenuItemId)} />; + case 'buttons': + return ; + case 'text-field': + return ; + default: + return ; + } }; return ( diff --git a/frontend/src/pages/design/DesignSidebar.tsx b/frontend/src/pages/design/DesignSidebar.tsx index d63daa5..b953cf0 100644 --- a/frontend/src/pages/design/DesignSidebar.tsx +++ b/frontend/src/pages/design/DesignSidebar.tsx @@ -1,38 +1,27 @@ import type { DesignTheme } from './designTheme'; import type { DesignTab } from './DesignHeader'; -import wingColorPaletteIcon from '../../assets/icons/wing-color-palette.svg'; -import wingElevationIcon from '../../assets/icons/wing-elevation.svg'; -import wingFoundationsIcon from '../../assets/icons/wing-foundations.svg'; -import wingLayoutGridIcon from '../../assets/icons/wing-layout-grid.svg'; -import wingTypographyIcon from '../../assets/icons/wing-typography.svg'; - -export type FoundationsMenuItemId = 'color' | 'typography' | 'radius' | 'layout'; -export type ComponentsMenuItemId = 'buttons' | 'text-inputs' | 'controls' | 'badge' | 'dialog' | 'tabs' | 'popup' | 'navigation'; +export type FoundationsMenuItemId = 'overview' | 'color' | 'typography' | 'radius' | 'layout'; +export type ComponentsMenuItemId = 'overview' | 'buttons' | 'text-field'; export type MenuItemId = FoundationsMenuItemId | ComponentsMenuItemId; interface MenuItem { id: MenuItemId; label: string; - icon: string; } const FOUNDATIONS_MENU: MenuItem[] = [ - { id: 'color', label: 'Color', icon: wingColorPaletteIcon }, - { id: 'typography', label: 'Typography', icon: wingTypographyIcon }, - { id: 'radius', label: 'Radius', icon: wingElevationIcon }, - { id: 'layout', label: 'Layout', icon: wingLayoutGridIcon }, + { id: 'overview', label: 'Overview' }, + { id: 'color', label: 'Color' }, + { id: 'typography', label: 'Typography' }, + { id: 'radius', label: 'Radius' }, + { id: 'layout', label: 'Layout' }, ]; const COMPONENTS_MENU: MenuItem[] = [ - { id: 'buttons', label: 'Buttons', icon: wingFoundationsIcon }, - { id: 'text-inputs', label: 'Text Inputs', icon: wingFoundationsIcon }, - { id: 'controls', label: 'Controls', icon: wingFoundationsIcon }, - { id: 'badge', label: 'Badge', icon: wingColorPaletteIcon }, - { id: 'dialog', label: 'Dialog', icon: wingLayoutGridIcon }, - { id: 'tabs', label: 'Tabs', icon: wingLayoutGridIcon }, - { id: 'popup', label: 'Popup', icon: wingElevationIcon }, - { id: 'navigation', label: 'Navigation', icon: wingTypographyIcon }, + { id: 'overview', label: 'Overview' }, + { id: 'buttons', label: 'Buttons' }, + { id: 'text-field', label: 'Text Field' }, ]; const SIDEBAR_CONFIG: Record = { @@ -58,7 +47,7 @@ export function DesignSidebar({ theme, activeTab, activeItem, onItemChange }: De ); @@ -82,22 +70,6 @@ export function DesignSidebar({ theme, activeTab, activeItem, onItemChange }: De boxShadow: `0px 25px 50px -12px ${theme.sidebarShadow}`, }} > - {/* 타이틀 영역 */} - {/*
-

- {title} -

-

- {subtitle} -

-
*/} - {/* 메뉴 네비게이션 */} diff --git a/frontend/src/pages/design/FoundationsOverview.tsx b/frontend/src/pages/design/FoundationsOverview.tsx new file mode 100644 index 0000000..72fd34d --- /dev/null +++ b/frontend/src/pages/design/FoundationsOverview.tsx @@ -0,0 +1,274 @@ +// FoundationsOverview.tsx — Foundations 탭 Overview 카드 그리드 + +import type { DesignTheme } from './designTheme'; + +// ---------- 타입 ---------- + +interface OverviewCard { + id: string; + label: string; + thumbnail: (isDark: boolean) => React.ReactNode; +} + +// ---------- 썸네일 구현 ---------- + +const ColorThumbnail = ({ isDark }: { isDark: boolean }) => { + // 3x3 도트 그리드: gray / pink / cyan 컬럼, 어두운 순 + const dots: string[][] = [ + ['#9ca3af', '#f9a8d4', '#67e8f9'], + ['#4b5563', '#ec4899', '#06b6d4'], + ['#1f2937', '#9d174d', '#0e7490'], + ]; + + return ( +
+
+ {dots.map((row, ri) => + row.map((color, ci) => ( +
+ )), + )} +
+
+ ); +}; + +const TypographyThumbnail = ({ isDark }: { isDark: boolean }) => ( +
+ + 가 + + + a + +
+); + +const RadiusThumbnail = ({ isDark }: { isDark: boolean }) => { + const items = [ + { radius: '0px', size: 36 }, + { radius: '6px', size: 36 }, + { radius: '12px', size: 36 }, + { radius: '50%', size: 36 }, + ]; + + const borderColor = isDark ? 'rgba(76,215,246,0.55)' : 'rgba(6,182,212,0.65)'; + const bgColor = isDark ? 'rgba(76,215,246,0.08)' : 'rgba(6,182,212,0.08)'; + + return ( +
+ {items.map(({ radius, size }) => ( +
+ ))} +
+ ); +}; + +const LayoutThumbnail = ({ isDark }: { isDark: boolean }) => { + const accent = isDark ? 'rgba(76,215,246,0.18)' : 'rgba(6,182,212,0.14)'; + const accentStrong = isDark ? 'rgba(76,215,246,0.40)' : 'rgba(6,182,212,0.38)'; + const faint = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'; + + return ( +
+ {/* 헤더 바 */} +
+ {/* 2열 바디 */} +
+
+
+
+
+
+
+
+ {/* 푸터 바 */} +
+
+ ); +}; + +// ---------- 카드 정의 ---------- + +const OVERVIEW_CARDS: OverviewCard[] = [ + { + id: 'color', + label: 'Color', + thumbnail: (isDark) => , + }, + { + id: 'typography', + label: 'Typography', + thumbnail: (isDark) => , + }, + { + id: 'radius', + label: 'Radius', + thumbnail: (isDark) => , + }, + { + id: 'layout', + label: 'Layout', + thumbnail: (isDark) => , + }, +]; + +// ---------- Props ---------- + +interface FoundationsOverviewProps { + theme: DesignTheme; + onNavigate: (id: string) => void; +} + +// ---------- 컴포넌트 ---------- + +const FoundationsOverview = ({ theme, onNavigate }: FoundationsOverviewProps) => { + const t = theme; + const isDark = t.mode === 'dark'; + + const cardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const cardBorder = isDark ? 'rgba(255,255,255,0.06)' : '#e5e5e5'; + const thumbnailBorderBottom = isDark ? 'rgba(255,255,255,0.06)' : '#e0e0e0'; + + return ( +
+ + {/* ── 헤더 영역 ── */} +
+ + Foundations + +

+ Overview +

+

+ 디자인의 기반이 되는 핵심 요소 사용 기준입니다. +

+
+ + {/* ── 3열 카드 그리드 ── */} +
+ {OVERVIEW_CARDS.map((card) => ( +
onNavigate(card.id)} + onMouseEnter={(e) => { + const el = e.currentTarget; + el.style.transform = 'scale(1.025)'; + el.style.boxShadow = isDark + ? '0 8px 24px rgba(0,0,0,0.35)' + : '0 6px 18px rgba(0,0,0,0.10)'; + el.style.borderColor = isDark + ? 'rgba(76,215,246,0.22)' + : 'rgba(6,182,212,0.28)'; + }} + onMouseLeave={(e) => { + const el = e.currentTarget; + el.style.transform = 'scale(1)'; + el.style.boxShadow = 'none'; + el.style.borderColor = cardBorder; + }} + > + {/* 썸네일 영역 */} +
+ {card.thumbnail(isDark)} +
+ + {/* 카드 라벨 */} +
+ + {card.label} + +
+
+ ))} +
+
+ ); +}; + +export default FoundationsOverview; diff --git a/frontend/src/pages/design/TextFieldContent.tsx b/frontend/src/pages/design/TextFieldContent.tsx new file mode 100644 index 0000000..084ce5d --- /dev/null +++ b/frontend/src/pages/design/TextFieldContent.tsx @@ -0,0 +1,1546 @@ +// TextFieldContent.tsx — WING-OPS Text Field 컴포넌트 상세 페이지 (다크/라이트 테마 지원) + +import type { DesignTheme } from './designTheme'; + +// ---------- 타입 ---------- + +interface InputFieldStyle { + bg: string; + border: string; + textColor: string; + placeholderColor: string; + borderWidth?: string; + opacity?: string; +} + +interface StateRow { + state: string; + badge: string; + placeholder: string; + hasCursor: boolean; + style: InputFieldStyle; + showClear?: boolean; + showSparkle?: boolean; +} + +// ---------- 헬퍼 데이터 ---------- + +const getDarkStateRows = (): StateRow[] => [ + { + state: 'Enabled', + badge: 'Enabled', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#1e293b', border: '#334155', textColor: '#e2e8f0', placeholderColor: '#64748b' }, + }, + { + state: 'Focused', + badge: 'Focused', + placeholder: '플레이스홀더', + hasCursor: true, + style: { bg: '#1e293b', border: '#e2e8f0', textColor: '#e2e8f0', placeholderColor: '#64748b', borderWidth: '2px' }, + }, + { + state: 'Error', + badge: 'Error', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#1e293b', border: '#ef4444', textColor: '#e2e8f0', placeholderColor: '#64748b' }, + }, + { + state: 'Error Focused', + badge: 'Error Focused', + placeholder: '플레이스홀더', + hasCursor: true, + style: { bg: '#1e293b', border: '#ef4444', textColor: '#e2e8f0', placeholderColor: '#64748b', borderWidth: '2px' }, + }, + { + state: 'Disabled', + badge: 'Disabled', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: 'rgba(255,255,255,0.02)', border: '#1e293b', textColor: '#e2e8f0', placeholderColor: '#64748b', opacity: '0.4' }, + }, + { + state: 'Read Only', + badge: 'Read Only', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: 'rgba(255,255,255,0.02)', border: '#334155', textColor: '#e2e8f0', placeholderColor: '#64748b' }, + }, + { + state: 'AI Loading', + badge: 'AI Loading', + placeholder: '단서를 모아서 추리 중...', + hasCursor: false, + showSparkle: true, + style: { bg: 'rgba(255,255,255,0.02)', border: '#334155', textColor: '#e2e8f0', placeholderColor: '#64748b' }, + }, +]; + +const getLightStateRows = (): StateRow[] => [ + { + state: 'Enabled', + badge: 'Enabled', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#fff', border: '#d1d5db', textColor: '#1f2937', placeholderColor: '#9ca3af' }, + }, + { + state: 'Focused', + badge: 'Focused', + placeholder: '플레이스홀더', + hasCursor: true, + style: { bg: '#fff', border: '#1f2937', textColor: '#1f2937', placeholderColor: '#9ca3af', borderWidth: '2px' }, + }, + { + state: 'Error', + badge: 'Error', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#fff', border: '#ef4444', textColor: '#1f2937', placeholderColor: '#9ca3af' }, + }, + { + state: 'Error Focused', + badge: 'Error Focused', + placeholder: '플레이스홀더', + hasCursor: true, + style: { bg: '#fff', border: '#ef4444', textColor: '#1f2937', placeholderColor: '#9ca3af', borderWidth: '2px' }, + }, + { + state: 'Disabled', + badge: 'Disabled', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#f9fafb', border: '#e5e7eb', textColor: '#1f2937', placeholderColor: '#9ca3af', opacity: '0.4' }, + }, + { + state: 'Read Only', + badge: 'Read Only', + placeholder: '플레이스홀더', + hasCursor: false, + style: { bg: '#f9fafb', border: '#d1d5db', textColor: '#1f2937', placeholderColor: '#9ca3af' }, + }, + { + state: 'AI Loading', + badge: 'AI Loading', + placeholder: '단서를 모아서 추리 중...', + hasCursor: false, + showSparkle: true, + style: { bg: '#f9fafb', border: '#d1d5db', textColor: '#1f2937', placeholderColor: '#9ca3af' }, + }, +]; + +// ---------- Props ---------- + +interface TextFieldContentProps { + theme: DesignTheme; +} + +// ---------- 컴포넌트 ---------- + +export const TextFieldContent = ({ theme }: TextFieldContentProps) => { + const t = theme; + const isDark = t.mode === 'dark'; + + const sectionCardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e5e7eb'; + const annotationColor = '#8b5cf6'; + + // 입력 필드 공통 스타일 (Anatomy, Guideline용) + const fieldBg = isDark ? '#1e293b' : '#fff'; + const fieldBorder = isDark ? '#334155' : '#d1d5db'; + const fieldText = isDark ? '#e2e8f0' : '#1f2937'; + const fieldPlaceholder = isDark ? '#64748b' : '#9ca3af'; + + const stateRows = isDark ? getDarkStateRows() : getLightStateRows(); + + return ( +
+
+ + {/* ── 섹션 1: 헤더 ── */} +
+

+ Components +

+

+ Text Field +

+

+ 사용자로부터 텍스트 데이터를 입력받는 기본 입력 컴포넌트입니다. +

+
+ + {/* ── Input Field 소제목 ── */} +
+

+ Input Field +

+

+ 단일 행 텍스트를 입력받는 필드입니다. +

+
+ + {/* ── 섹션 2: Anatomy ── */} +
+

+ Anatomy +

+ + {/* Anatomy 카드 */} +
+
+ + {/* 입력 필드 구조 분해도 */} +
+ + {/* 상단 주석 라벨: Prefix, Input, Suffix */} +
+ {/* Prefix label */} +
+ + Prefix + + + (Optional) + + {/* 아래 화살표 선 */} +
+
+
+ + {/* Input label */} +
+ + Input + + {/* 아래 화살표 선 */} +
+
+
+ + {/* Suffix label */} +
+ + Suffix + + + (Optional) + + {/* 아래 화살표 선 */} +
+
+
+
+ + {/* 실제 입력 필드 */} +
+ {/* Prefix 아이콘 영역 */} +
+ + + + +
+ + {/* 입력 텍스트 영역 */} +
+ 입력값 텍스트 +
+ + {/* Clear 버튼 */} +
+ × +
+ + {/* Suffix 텍스트 */} + + 원 + + + {/* Container 점선 테두리 */} + +
+ + {/* Container 왼쪽 주석 */} +
+ + Container + + {/* 화살표 선 */} +
+
+
+ + {/* 하단 주석 라벨: Clear Button */} +
+
+ {/* 위 화살표 선 */} +
+
+ + Clear Button + + + (Optional) + +
+
+
+
+
+
+ + {/* ── 섹션 3: Guideline ── */} +
+

+ Guideline +

+ + {/* 3-1. Container */} +
+
+ + 1 + +

+ Container +

+
+

+ 입력 필드의 외곽 영역입니다. 테두리, 곡률, 내부 여백을 정의합니다. +

+
+
+ {/* 컨테이너 박스 + 치수선 */} +
+ {/* 높이 치수선 (오른쪽) */} +
+
+ + 44px + +
+
+ + {/* padding 치수선 (상단 왼쪽) */} + + px: 12px + + + {/* 빈 컨테이너 박스 */} +
+
+ +
+

+ height: 44px (Medium) +

+

+ padding: 12px (좌우) +

+

+ border-radius: 6px +

+
+
+
+
+ + {/* 3-2. Placeholder */} +
+
+ + 2 + +

+ Placeholder +

+
+

+ 값이 입력되지 않았을 때 표시되는 안내 텍스트입니다. 입력 시 사라집니다. +

+
+
+ {/* 플레이스홀더 있는 필드 */} +
+ + 플레이스홀더 있음 + +
+ 검색어를 입력하세요 +
+
+ + {/* 빈 필드 (플레이스홀더 없음) */} +
+ + 플레이스홀더 없음 + +
+
+
+
+
+ + {/* 3-3. Label */} +
+
+ + 3 + +

+ Label +

+
+

+ 입력 필드의 용도를 설명하는 텍스트입니다. 필수 항목은 * 표시로 구분합니다. +

+
+
+ {/* 일반 라벨 */} +
+ + 이름 + +
+ 홍길동 +
+
+ + {/* 필수 라벨 */} +
+ + 이메일{' '} + * + +
+ 이메일을 입력하세요 +
+
+
+
+
+ + {/* 3-4. Input Text */} +
+
+ + 4 + +

+ Input Text +

+
+

+ 사용자가 실제로 입력한 텍스트입니다. 플레이스홀더보다 진한 색상으로 표시됩니다. +

+
+
+
+ 홍길동 +
+
+ font-size: 14px + color: textPrimary + font-weight: 400 +
+
+
+
+ + {/* 3-5. Clear Icon */} +
+
+ + 5 + +

+ Clear Icon +

+
+

+ 입력값이 있을 때 표시되는 초기화 버튼입니다. 클릭 시 입력값을 삭제합니다. +

+
+
+ {/* 텍스트 입력 + Clear 아이콘 표시 */} +
+ + 입력값 있음 (Clear 표시) + +
+ 홍길동 + {/* Clear 버튼 */} +
+ × +
+
+
+ + {/* 빈 상태 (Clear 미표시) */} +
+ + 입력값 없음 (Clear 미표시) + +
+ 플레이스홀더 +
+
+
+
+
+ + {/* 3-6. Helper Text */} +
+
+ + 6 + +

+ Helper Text +

+
+

+ 입력 필드 하단에 표시되는 보조 텍스트입니다. 안내 또는 에러 메시지로 사용됩니다. +

+
+
+ {/* 기본 도움말 */} +
+
+ 비밀번호 +
+ + 영문, 숫자 포함 8자 이상 + +
+ + {/* 에러 메시지 */} +
+
+ 비밀번호 +
+ + 필수 입력 항목입니다. + +
+
+
+
+
+ + {/* ── 섹션 4: State (Input Field) ── */} +
+

+ State +

+ +
+
+ {stateRows.map((row) => ( +
+ {/* 왼쪽: State 라벨 + 뱃지 */} +
+ + State + + + {row.badge} + +
+ + {/* 오른쪽: 입력 필드 */} +
+ {row.showSparkle && ( + + )} + + {row.placeholder} + {row.hasCursor && ( + + | + + )} + +
+
+ ))} +
+
+
+ + {/* ════════════════════════════════════════════════════ + Text Area 단락 + ════════════════════════════════════════════════════ */} + + {/* ── Text Area 소제목 ── */} +
+

+ Text Area +

+

+ 여러 줄의 텍스트를 입력받는 필드입니다. +

+
+ + {/* ── Text Area Anatomy ── */} +
+

+ Anatomy +

+ +
+
+ + {/* TextArea 구조 분해도 */} +
+ + {/* 상단 주석 라벨: Input Area, Character Counter */} +
+ {/* Input Area label */} +
+ + Input Area + +
+
+
+ + {/* Placeholder label */} +
+ + Placeholder + +
+
+
+ + {/* Character Counter label */} +
+ + Character Counter + + + (Optional) + +
+
+
+
+ + {/* 실제 TextArea */} +
+ {/* 플레이스홀더 텍스트 */} +
+ 내용을 입력하세요 +
+ + {/* 우하단: 문자 수 카운터 + resize 핸들 */} +
+ + 0/500 + + {/* Resize 핸들 (대각선 줄무늬) */} +
+
+ + {/* Container 점선 테두리 */} + +
+ + {/* Container 왼쪽 주석 */} +
+ + Container + +
+
+
+ + {/* 하단 주석 라벨: Resize Handle */} +
+
+
+
+ + Resize Handle + +
+
+
+
+
+
+ + {/* ── Text Area Guideline ── */} +
+

+ Guideline +

+ + {/* TA-1. Container */} +
+
+ + 1 + +

+ Container +

+
+

+ 텍스트 영역의 외곽 컨테이너입니다. 기본 높이 112px이며 사용자가 리사이즈할 수 있습니다. +

+
+
+
+ {/* 높이 치수선 (오른쪽) */} +
+
+ + 112px + +
+
+ + {/* padding 치수선 (상단 왼쪽) */} + + p: 12px + + + {/* 빈 컨테이너 박스 */} +
+
+ +
+

+ height: 112px (default) +

+

+ padding: 12px +

+

+ border-radius: 6px +

+

+ resize: vertical +

+
+
+
+
+ + {/* TA-2. Placeholder */} +
+
+ + 2 + +

+ Placeholder +

+
+

+ 값이 입력되지 않았을 때 표시되는 안내 텍스트입니다. +

+
+
+ {/* 플레이스홀더 있는 TextArea */} +
+ + 플레이스홀더 있음 + +
+ 내용을 입력하세요 +
+
+ + {/* 빈 TextArea */} +
+ + 플레이스홀더 없음 + +
+
+
+
+
+ + {/* TA-3. Label */} +
+
+ + 3 + +

+ Label +

+
+

+ 텍스트 영역의 용도를 설명하는 라벨입니다. +

+
+
+ {/* 기본 라벨 */} +
+ + 내용 + +
+ 내용을 입력하세요 +
+
+ + {/* 필수(*) 라벨 */} +
+ + 비고{' '} + * + +
+ 필수 항목입니다 +
+
+
+
+
+ + {/* TA-4. Input Text */} +
+
+ + 4 + +

+ Input Text +

+
+

+ 사용자가 입력한 여러 줄의 텍스트입니다. +

+
+
+
+ {'오늘 점검 내용을 기록합니다.\n상세 내용은 아래와 같습니다.'} +
+
+ font-size: 14px + color: textPrimary + line-height: 1.6 +
+
+
+
+ + {/* TA-5. Clear Icon */} +
+
+ + 5 + +

+ Clear Icon +

+
+

+ 입력값 초기화 버튼입니다. 텍스트 영역 우상단에 표시됩니다. +

+
+
+ {/* 텍스트 있는 상태 (Clear 표시) */} +
+ + 입력값 있음 (Clear 표시) + +
+ 입력된 내용이 있습니다. + {/* Clear 버튼 우상단 */} +
+ × +
+
+
+ + {/* 빈 상태 (Clear 미표시) */} +
+ + 입력값 없음 (Clear 미표시) + +
+ 내용을 입력하세요 +
+
+
+
+
+ + {/* TA-6. Helper Text */} +
+
+ + 6 + +

+ Helper Text +

+
+

+ 텍스트 영역 하단의 도움말 또는 에러 메시지입니다. +

+
+
+ {/* 기본 도움말 + 문자 수 카운터 */} +
+
+ 내용을 입력하세요 +
+
+ + 상세 내용을 입력해 주세요 + + + 0/500 + +
+
+ + {/* 에러 메시지 */} +
+
+ 내용을 입력하세요 +
+ + 필수 입력 항목입니다. + +
+
+
+
+
+ + {/* ── Text Area State ── */} +
+

+ State +

+ +
+
+ {stateRows.map((row) => ( +
+ {/* 왼쪽: State 라벨 + 뱃지 */} +
+ + State + + + {row.badge} + +
+ + {/* 오른쪽: TextArea */} +
+
+ {row.showSparkle && ( + + )} + + {row.placeholder} + {row.hasCursor && ( + + | + + )} + +
+
+
+ ))} +
+
+
+ +
+
+ ); +}; + +export default TextFieldContent; From 3fd5537553f02016460c415f07d55df9fb8d8e88 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 25 Mar 2026 15:35:43 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=98=88=EC=B8=A1=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=9D=B4=EB=A0=A5=20=EC=84=A0=ED=83=9D,=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=EC=84=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0,=20=EC=88=98=EC=B9=98=EC=98=88=EC=B8=A1=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/workflow-version.json | 2 +- backend/src/monitor/monitorRouter.ts | 20 + backend/src/monitor/monitorService.ts | 121 ++++++ backend/src/prediction/predictionRouter.ts | 5 +- backend/src/prediction/predictionService.ts | 46 +- backend/src/routes/simulation.ts | 61 ++- backend/src/server.ts | 2 + database/migration/028_pred_run_sn.sql | 25 ++ .../src/tabs/admin/components/AdminView.tsx | 2 + .../admin/components/MonitorForecastPanel.tsx | 273 ++++++++++++ .../src/tabs/admin/services/monitorApi.ts | 17 + .../components/AnalysisListTable.tsx | 4 +- .../prediction/components/OilSpillView.tsx | 11 +- .../prediction/components/RecalcModal.tsx | 38 +- .../tabs/prediction/components/RightPanel.tsx | 23 +- .../tabs/prediction/services/predictionApi.ts | 9 +- .../reports/components/ReportGenerator.tsx | 2 +- .../tabs/reports/components/ReportsView.tsx | 47 ++- .../reports/components/TemplateEditPage.tsx | 396 ++++++++++++++++++ .../src/tabs/reports/components/hwpxExport.ts | 13 +- .../tabs/reports/components/reportTypes.ts | 9 + .../tabs/reports/components/reportUtils.ts | 13 + 22 files changed, 1048 insertions(+), 91 deletions(-) create mode 100644 backend/src/monitor/monitorRouter.ts create mode 100644 backend/src/monitor/monitorService.ts create mode 100644 database/migration/028_pred_run_sn.sql create mode 100644 frontend/src/tabs/admin/components/MonitorForecastPanel.tsx create mode 100644 frontend/src/tabs/admin/services/monitorApi.ts create mode 100644 frontend/src/tabs/reports/components/TemplateEditPage.tsx diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index a9c8b08..839d5f4 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-24", + "applied_date": "2026-03-25", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/monitor/monitorRouter.ts b/backend/src/monitor/monitorRouter.ts new file mode 100644 index 0000000..700be1e --- /dev/null +++ b/backend/src/monitor/monitorRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express' +import { requireAuth } from '../auth/authMiddleware.js' +import { getNumericalDataStatus } from './monitorService.js' + +const router = Router() + +router.use(requireAuth) + +// GET /api/monitor/numerical — 수치예측자료 다운로드 상태 조회 +router.get('/numerical', async (_req, res) => { + try { + const data = await getNumericalDataStatus() + res.json(data) + } catch (err) { + console.error('[monitor] 수치예측자료 상태 조회 오류:', err) + res.status(500).json({ error: '수치예측자료 상태를 조회할 수 없습니다.' }) + } +}) + +export default router diff --git a/backend/src/monitor/monitorService.ts b/backend/src/monitor/monitorService.ts new file mode 100644 index 0000000..29adff8 --- /dev/null +++ b/backend/src/monitor/monitorService.ts @@ -0,0 +1,121 @@ +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; // 데이터 기준일 (YYYY-MM-DD) + lastDownloadedAt: string | null; // 마지막 실행 완료 시각 (ISO) + nextScheduledAt: string | null; // Quartz 다음 예정 시각 (ISO) + durationSec: number | null; // 소요 시간 (초) + consecutiveFailures: number; // 연속 실패 횟수 +} + +// ============================================================ +// Mock 데이터 (Spring Batch/Quartz DB 연동 전) +// DB 연동 준비 완료 후 getMockNumericalDataStatus → getActualNumericalDataStatus 교체 +// ============================================================ +const MOCK_DATA: NumericalDataStatus[] = [ + { + modelName: 'HYCOM', + jobName: 'downloadHycomJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:12:34', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 342, + consecutiveFailures: 0, + }, + { + modelName: 'GFS', + jobName: 'downloadGfsJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:48:11', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 518, + consecutiveFailures: 0, + }, + { + modelName: 'WW3', + jobName: 'downloadWw3Job', + lastStatus: 'FAILED', + lastDataDate: '2026-03-24', + lastDownloadedAt: '2026-03-25T07:03:55', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: null, + consecutiveFailures: 2, + }, + { + modelName: 'KOAST POS_WIND', + jobName: 'downloadKoastWindJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:21:05', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 127, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_HYDR', + jobName: 'downloadKoastHydrJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:35:48', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 183, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_WAVE', + jobName: 'downloadKoastWaveJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:52:19', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 156, + consecutiveFailures: 0, + }, +]; + +export async function getNumericalDataStatus(): Promise { + // TODO: Spring Batch + Quartz DB 테이블 생성 후 아래 실제 쿼리로 교체 + // + // import { wingDb } from '../db/wingDb.js' + // + // -- 각 Job의 최신 실행 결과 조회 (BATCH_JOB_EXECUTION) + // SELECT + // ji.JOB_NAME, + // je.START_TIME, je.END_TIME, + // je.STATUS, je.EXIT_CODE, je.EXIT_MESSAGE, + // jep.STRING_VAL AS data_date, + // EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))::INT AS duration_sec + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // LEFT JOIN BATCH_JOB_EXECUTION_PARAMS jep + // ON je.JOB_EXECUTION_ID = jep.JOB_EXECUTION_ID + // AND jep.KEY_NAME = 'data_date' + // WHERE je.JOB_EXECUTION_ID IN ( + // SELECT MAX(je2.JOB_EXECUTION_ID) + // FROM BATCH_JOB_EXECUTION je2 + // GROUP BY je2.JOB_INSTANCE_ID + // ) + // ORDER BY je.START_TIME DESC; + // + // -- Quartz 다음 실행 예정 시각 (NEXT_FIRE_TIME은 epoch milliseconds) + // SELECT JOB_NAME, to_timestamp(NEXT_FIRE_TIME / 1000) AS next_fire_time + // FROM QRTZ_TRIGGERS; + // + // -- 연속 실패 횟수 집계 (최근 실행부터 COMPLETED 전까지 카운트) + // SELECT ji.JOB_NAME, COUNT(*) AS consecutive_failures + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // WHERE je.STATUS = 'FAILED' + // AND je.JOB_EXECUTION_ID > ( + // SELECT COALESCE(MAX(je2.JOB_EXECUTION_ID), 0) + // FROM BATCH_JOB_EXECUTION je2 + // WHERE je2.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID + // AND je2.STATUS = 'COMPLETED' + // ) + // GROUP BY ji.JOB_NAME; + + return MOCK_DATA; +} diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index a2111b1..6c0e562 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -46,7 +46,7 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R } }); -// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회 +// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능) router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { const acdntSn = parseInt(req.params.acdntSn as string, 10); @@ -54,7 +54,8 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred res.status(400).json({ error: '유효하지 않은 사고 번호' }); return; } - const result = await getAnalysisTrajectory(acdntSn); + const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined; + const result = await getAnalysisTrajectory(acdntSn, predRunSn); if (!result) { res.json({ trajectory: null, summary: null }); return; diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 833304e..649c92d 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -19,6 +19,8 @@ interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } interface PredictionDetail { @@ -142,21 +144,26 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise { // 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회 - const sql = ` - SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC - WHERE ACDNT_SN = $1 - AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') - AND EXEC_STTS_CD = 'COMPLETED' - ORDER BY CMPL_DTM DESC - `; - const { rows } = await wingPool.query(sql, [acdntSn]); + // predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과 + const sql = predRunSn != null + ? ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND PRED_RUN_SN = $2 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + ` + : ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + `; + const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; + const { rows } = await wingPool.query(sql, params); if (rows.length === 0) return null; // 모든 모델의 파티클을 하나의 배열로 병합 diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index 2eea5bd..8c4aaf4 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -210,14 +210,21 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation] SPIL_DATA INSERT 실패:', dbErr) } } @@ -545,30 +552,47 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation/run-model] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation/run-model] SPIL_DATA INSERT 실패:', dbErr) } } const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined const execNmBase = `EXPC_${Date.now()}` + // 이번 예측 실행을 식별하는 그룹 SN 생성 + let predRunSn: number + try { + const runSnRes = await wingPool.query("SELECT nextval('wing.PRED_RUN_SN_SEQ') AS pred_run_sn") + predRunSn = runSnRes.rows[0].pred_run_sn as number + } catch (dbErr) { + console.error('[simulation/run-model] PRED_RUN_SN_SEQ 조회 실패:', dbErr) + return res.status(500).json({ error: '실행 SN 생성 실패' }) + } + // KOSPS: PRED_EXEC INSERT(PENDING)만 수행 const execSns: Array<{ model: string; execSn: number }> = [] if (requestedModels.includes('KOSPS')) { try { const kospsExecNm = `${execNmBase}_KOSPS` const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn] ) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) } catch (dbErr) { @@ -602,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { let predExecSn: number try { const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn] ) predExecSn = insertRes.rows[0].pred_exec_sn as number } catch (dbErr) { @@ -713,6 +737,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { res.json({ success: true, acdntSn: resolvedAcdntSn, + predRunSn, execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))], results: modelResults, }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 8e48bdb..cf7e6a1 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,6 +23,7 @@ import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' +import monitorRouter from './monitor/monitorRouter.js' import { sanitizeBody, sanitizeQuery, @@ -170,6 +171,7 @@ app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) +app.use('/api/monitor', monitorRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/028_pred_run_sn.sql b/database/migration/028_pred_run_sn.sql new file mode 100644 index 0000000..9ce7c67 --- /dev/null +++ b/database/migration/028_pred_run_sn.sql @@ -0,0 +1,25 @@ +-- Migration 028: PRED_EXEC에 실행 그룹 식별자(PRED_RUN_SN) 추가 +-- 같은 시점에 여러 모델로 실행된 PRED_EXEC 레코드를 하나의 "예측 실행"으로 묶는다. +-- 목록 화면에서 사고당 예측 실행 횟수만큼 행을 표시하기 위한 기반 구조. + +-- 1. 컬럼 추가 +ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS PRED_RUN_SN INTEGER; + +-- 2. 기존 데이터 마이그레이션 +-- 같은 ACDNT_SN + 시작 시각 60초 이내의 레코드를 동일 실행 그룹으로 묶는다. +-- MIN(PRED_EXEC_SN)을 그룹 대표 키로 사용한다. +UPDATE wing.PRED_EXEC pe1 +SET PRED_RUN_SN = ( + SELECT MIN(pe2.PRED_EXEC_SN) + FROM wing.PRED_EXEC pe2 + WHERE pe2.ACDNT_SN = pe1.ACDNT_SN + AND ABS(EXTRACT(EPOCH FROM ( + COALESCE(pe2.BGNG_DTM, NOW()) - COALESCE(pe1.BGNG_DTM, NOW()) + ))) < 60 +) +WHERE pe1.PRED_RUN_SN IS NULL; + +-- 3. 시퀀스 생성 (신규 실행용 — 기존 최대값보다 충분히 높은 값에서 시작) +CREATE SEQUENCE IF NOT EXISTS wing.PRED_RUN_SN_SEQ + START WITH 10000 + INCREMENT BY 1; diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index f39a80b..979471b 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -15,6 +15,7 @@ import LayerPanel from './LayerPanel'; import SensitiveLayerPanel from './SensitiveLayerPanel'; import DispersingZonePanel from './DispersingZonePanel'; import MonitorRealtimePanel from './MonitorRealtimePanel'; +import MonitorForecastPanel from './MonitorForecastPanel'; import VesselMaterialsPanel from './VesselMaterialsPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ @@ -36,6 +37,7 @@ const PANEL_MAP: Record JSX.Element> = { 'dispersant-zone': () => , 'vessel-materials': () => , 'monitor-realtime': () => , + 'monitor-forecast': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx new file mode 100644 index 0000000..47d4841 --- /dev/null +++ b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getNumericalDataStatus, + type NumericalDataStatus, +} from '../services/monitorApi'; + +type TabId = 'all' | 'ocean' | 'koast'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'all', label: '전체' }, + { id: 'ocean', label: '기상·해양 모델' }, + { id: 'koast', label: 'KOAST' }, +]; + +const OCEAN_MODELS = ['HYCOM', 'GFS', 'WW3']; +const KOAST_MODELS = ['KOAST POS_WIND', 'KOAST POS_HYDR', 'KOAST POS_WAVE']; + +function filterByTab(rows: NumericalDataStatus[], tab: TabId): NumericalDataStatus[] { + if (tab === 'ocean') return rows.filter((r) => OCEAN_MODELS.includes(r.modelName)); + if (tab === 'koast') return rows.filter((r) => KOAST_MODELS.includes(r.modelName)); + return rows; +} + +function formatDuration(sec: number | null): string { + if (sec == null) return '-'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return m > 0 ? `${m}분 ${s}초` : `${s}초`; +} + +function formatDatetime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${mm}-${dd} ${hh}:${min}`; +} + +function formatTime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${min}`; +} + +function StatusCell({ row }: { row: NumericalDataStatus }) { + if (row.lastStatus === 'COMPLETED') { + return 정상; + } + if (row.lastStatus === 'FAILED') { + return ( + + 오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''} + + ); + } + if (row.lastStatus === 'STARTED') { + return ( + + + 실행 중 + + ); + } + return -; +} + +function StatusBadge({ + loading, + errorCount, + total, +}: { + loading: boolean; + errorCount: number; + total: number; +}) { + if (loading) { + return ( + + + 조회 중... + + ); + } + if (errorCount === total && total > 0) { + return ( + + + 연계 오류 + + ); + } + if (errorCount > 0) { + return ( + + + 일부 오류 ({errorCount}/{total}) + + ); + } + return ( + + + 정상 + + ); +} + +const TABLE_HEADERS = [ + '모델명', + '데이터 기준일', + '마지막 다운로드', + '상태', + '소요 시간', + '다음 예정', +]; + +function ForecastTable({ + rows, + loading, +}: { + rows: NumericalDataStatus[]; + loading: boolean; +}) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 6 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
+
+
+ {row.modelName} + {row.lastDataDate ?? '-'}{formatDatetime(row.lastDownloadedAt)} + + {formatDuration(row.durationSec)}{formatTime(row.nextScheduledAt)}
+
+ ); +} + +export default function MonitorForecastPanel() { + const [activeTab, setActiveTab] = useState('all'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await getNumericalDataStatus(); + setRows(data); + setLastUpdate(new Date()); + } catch { + // 오류 시 기존 데이터 유지 + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + const visibleRows = filterByTab(rows, activeTab); + const errorCount = visibleRows.filter( + (r) => r.lastStatus === 'FAILED' + ).length; + const totalCount = visibleRows.length; + + return ( +
+ {/* 헤더 */} +
+

수치예측자료 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ + {/* 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 상태 표시줄 */} +
+ + {!loading && totalCount > 0 && ( + 모델 {totalCount}개 + )} +
+ + {/* 테이블 */} +
+ +
+
+ ); +} diff --git a/frontend/src/tabs/admin/services/monitorApi.ts b/frontend/src/tabs/admin/services/monitorApi.ts new file mode 100644 index 0000000..3ad9c23 --- /dev/null +++ b/frontend/src/tabs/admin/services/monitorApi.ts @@ -0,0 +1,17 @@ +import { api } from '@common/services/api'; + +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; + lastDownloadedAt: string | null; + nextScheduledAt: string | null; + durationSec: number | null; + consecutiveFailures: number; +} + +export async function getNumericalDataStatus(): Promise { + const res = await api.get('/monitor/numerical'); + return res.data; +} diff --git a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx index 2341958..5fb41c3 100755 --- a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx +++ b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx @@ -153,6 +153,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis 번호 사고명 사고일시 + 예측 실행 예측시간 유종 유출량 @@ -167,7 +168,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis {currentAnalyses.map((analysis) => ( {analysis.acdntSn} @@ -188,6 +189,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} + {analysis.runDtm ? new Date(analysis.runDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} {analysis.duration} {analysis.oilType} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d8d5196..cc84007 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -519,7 +519,7 @@ export function OilSpillView() { analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed'; if (hasCompletedModel) { try { - const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn) + const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn, analysis.predRunSn ?? undefined) if (trajectory && trajectory.length > 0) { setOilTrajectory(trajectory) if (summary) setSimulationSummary(summary) @@ -1264,7 +1264,13 @@ export function OilSpillView() { {activeSubTab === 'analysis' && ( setRecalcModalOpen(true)} + onOpenRecalc={() => { + if (!selectedAnalysis) { + alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); + return; + } + setRecalcModalOpen(true); + }} onOpenReport={handleOpenReport} detail={analysisDetail} summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary} @@ -1312,6 +1318,7 @@ export function OilSpillView() { setRecalcModalOpen(false)} + incidentName={selectedAnalysis?.acdntNm || incidentName} oilType={oilType} spillAmount={spillAmount} spillType={spillType} diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx index 080079d..0b81783 100755 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -4,6 +4,7 @@ import type { PredictionModel } from './OilSpillView' interface RecalcModalProps { isOpen: boolean onClose: () => void + incidentName: string oilType: string spillAmount: number spillType: string @@ -24,11 +25,14 @@ type RecalcPhase = 'editing' | 'running' | 'done' const OIL_TYPES = ['벙커C유', '원유(중질)', '원유(경질)', '디젤유(경유)', '휘발유', '등유', '윤활유', 'HFO 380', 'HFO 180'] const SPILL_TYPES = ['연속', '순간', '점진적'] -const PREDICTION_TIMES = [12, 24, 48, 72, 96, 120] +const PREDICTION_TIMES = [6, 12, 24, 48, 72, 96, 120] +const snapToValidTime = (t: number): number => + PREDICTION_TIMES.includes(t) ? t : (PREDICTION_TIMES.find(h => h >= t) ?? PREDICTION_TIMES[0]) export function RecalcModal({ isOpen, onClose, + incidentName, oilType: initOilType, spillAmount: initSpillAmount, spillType: initSpillType, @@ -43,7 +47,7 @@ export function RecalcModal({ const [spillAmount, setSpillAmount] = useState(initSpillAmount) const [spillUnit, setSpillUnit] = useState<'kl' | 'ton' | 'bbl'>('kl') const [spillType, setSpillType] = useState(initSpillType) - const [predictionTime, setPredictionTime] = useState(initPredictionTime) + const [predictionTime, setPredictionTime] = useState(() => snapToValidTime(initPredictionTime)) const [lat, setLat] = useState(initCoord.lat) const [lon, setLon] = useState(initCoord.lon) const [models, setModels] = useState>(new Set(initModels)) @@ -56,7 +60,7 @@ export function RecalcModal({ setOilType(initOilType) setSpillAmount(initSpillAmount) setSpillType(initSpillType) - setPredictionTime(initPredictionTime) + setPredictionTime(snapToValidTime(initPredictionTime)) setLat(initCoord.lat) setLon(initCoord.lon) setModels(new Set(initModels)) @@ -163,7 +167,7 @@ export function RecalcModal({ 현재 분석 정보
- + @@ -265,30 +269,26 @@ export function RecalcModal({
{([ - { model: 'KOSPS' as PredictionModel, color: '#3b82f6' }, - { model: 'POSEIDON' as PredictionModel, color: '#22c55e' }, - { model: 'OpenDrift' as PredictionModel, color: '#f97316' }, - ]).map(({ model, color }) => ( + { model: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, + { model: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true }, + { model: 'OpenDrift' as PredictionModel, color: 'var(--blue)', ready: true }, + ]).map(({ model, color, ready }) => ( ))} -
diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 2094c32..3f93d4d 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -63,10 +63,11 @@ export function RightPanel({ const [insuranceExpanded, setInsuranceExpanded] = useState(false) const weatheringStatus = useMemo(() => { - if (!summary) return null; + const zero = { surface: 0, evaporation: 0, dispersion: 0, boom: 0, beached: 0 }; + if (!summary) return zero; const total = summary.remainingVolume + summary.evaporationVolume + summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume; - if (total <= 0) return null; + if (total <= 0) return zero; const pct = (v: number) => Math.round((v / total) * 100); return { surface: pct(summary.remainingVolume), @@ -288,17 +289,13 @@ export function RightPanel({ {/* 유출유 풍화 상태 */}
- {weatheringStatus ? ( - <> - - - - - - - ) : ( -

시뮬레이션 실행 후 표시됩니다

- )} + <> + + + + + +
diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 0d1c12b..025c2a1 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -19,6 +19,8 @@ export interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } export interface PredictionDetail { @@ -216,8 +218,11 @@ export interface TrajectoryResponse { stepSummariesByModel?: Record; } -export const fetchAnalysisTrajectory = async (acdntSn: number): Promise => { - const response = await api.get(`/prediction/analyses/${acdntSn}/trajectory`); +export const fetchAnalysisTrajectory = async (acdntSn: number, predRunSn?: number): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/trajectory`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); return response.data; }; diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index fb0cbc1..ca2eb70 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -68,7 +68,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { const handleSave = async () => { const report = createEmptyReport() - report.reportType = activeCat === 0 ? '예측보고서' : activeCat === 1 ? '종합보고서' : '초기보고서' + report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서' report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난' report.title = cat.reportName report.status = '완료' diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 3a21559..7bd2296 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -21,6 +21,7 @@ import { import type { TemplateType } from './reportTypes'; import TemplateFormEditor from './TemplateFormEditor' import ReportGenerator from './ReportGenerator' +import TemplateEditPage from './TemplateEditPage' // ─── Main ReportsView ──────────────────────────────────── export function ReportsView() { @@ -216,8 +217,19 @@ export function ReportsView() { {/* ──── 수정 ──── */} {view.screen === 'edit' && ( -
- { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} /> +
+ {view.data.reportType === '유출유 보고' ? ( +
+ { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} /> +
+ ) : ( + { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} + /> + )}
)} @@ -283,7 +295,8 @@ export function ReportsView() {
문서 저장