From 7a80eaf75e8d0df69b5e0f5fe66efab432d9b3a1 Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 11:02:23 +0900 Subject: [PATCH 1/5] =?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/5] =?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 6433757262ecaf0fc8f10c74b38037ae5e5b19c5 Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 16:01:48 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor(design):=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=ED=8C=94=EB=A0=88=ED=8A=B8=20=EC=BB=A8=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20+=20base.css=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/common/styles/base.css | 78 ++ .../src/pages/design/ColorPaletteContent.tsx | 1077 ++++++++++------- 2 files changed, 747 insertions(+), 408 deletions(-) diff --git a/frontend/src/common/styles/base.css b/frontend/src/common/styles/base.css index aefe5f5..bd49569 100644 --- a/frontend/src/common/styles/base.css +++ b/frontend/src/common/styles/base.css @@ -23,6 +23,84 @@ --fM: JetBrains Mono, monospace; --rS: 6px; --rM: 8px; + + /* === Design Token System === */ + + /* Static */ + --static-black: #131415; + --static-white: #ffffff; + + /* Gray */ + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + --gray-1000: #020617; + + /* Blue */ + --blue-100: #dbeafe; + --blue-200: #bfdbfe; + --blue-300: #93c5fd; + --blue-400: #60a5fa; + --blue-500: #3b82f6; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --blue-800: #1e40af; + --blue-900: #1e3a8a; + --blue-1000: #172554; + + /* Green */ + --green-100: #dcfce7; + --green-200: #bbf7d0; + --green-300: #86efac; + --green-400: #4ade80; + --green-500: #22c55e; + --green-600: #16a34a; + --green-700: #15803d; + --green-800: #166534; + --green-900: #14532d; + --green-1000: #052e16; + + /* Yellow */ + --yellow-100: #fef9c3; + --yellow-200: #fef08a; + --yellow-300: #fde047; + --yellow-400: #facc15; + --yellow-500: #eab308; + --yellow-600: #ca8a04; + --yellow-700: #a16207; + --yellow-800: #854d0e; + --yellow-900: #713f12; + --yellow-1000: #422006; + + /* Red */ + --red-100: #fee2e2; + --red-200: #fecaca; + --red-300: #fca5a5; + --red-400: #f87171; + --red-500: #ef4444; + --red-600: #dc2626; + --red-700: #b91c1c; + --red-800: #991b1b; + --red-900: #7f1d1d; + --red-1000: #450a0a; + + /* Purple */ + --purple-100: #f3e8ff; + --purple-200: #e9d5ff; + --purple-300: #d8b4fe; + --purple-400: #c084fc; + --purple-500: #a855f7; + --purple-600: #9333ea; + --purple-700: #7e22ce; + --purple-800: #6b21a8; + --purple-900: #581c87; + --purple-1000: #3b0764; } * { diff --git a/frontend/src/pages/design/ColorPaletteContent.tsx b/frontend/src/pages/design/ColorPaletteContent.tsx index b0ac543..803f4e7 100644 --- a/frontend/src/pages/design/ColorPaletteContent.tsx +++ b/frontend/src/pages/design/ColorPaletteContent.tsx @@ -1,158 +1,434 @@ -// ColorPaletteContent.tsx — WING-OPS Color Palette 콘텐츠 (다크/라이트 테마 지원) +// ColorPaletteContent.tsx — WING-OPS Color 파운데이션 페이지 (다크/라이트 테마 지원) +import { useState } from 'react'; import type { DesignTheme } from './designTheme'; -// ---------- 데이터 타입 ---------- +// ---------- 타입 ---------- -interface PrimitiveColorStep { +interface ColorStep { + step: number; + color: string; +} + +interface Marker { + step: number; label: string; +} + +interface ContrastRating { + step: number; + rating: string; +} + +interface ColorScaleBarProps { + steps: ColorStep[]; + markers?: Marker[]; + contrastRatings?: ContrastRating[]; + darkBg?: boolean; + isDark: boolean; +} + +interface ColorToken { + name: string; hex: string; } -interface PrimitiveColorGroup { - name: string; - steps: PrimitiveColorStep[]; +interface ColorTokenGroup { + title: string; + tokens: ColorToken[]; } -interface SemanticToken { - token: string; - dark: string; - light: string; - usage: string[]; -} +// ---------- 데이터 ---------- -interface SemanticCategory { - name: string; - tokens: SemanticToken[]; -} +const TRANSPARENCY_STEPS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95, 100]; -// ---------- Primitive Colors 데이터 ---------- +const GRAY_STEPS: ColorStep[] = [ + { step: 0, color: '#f8fafc' }, + { step: 5, color: '#f1f5f9' }, + { step: 10, color: '#e2e8f0' }, + { step: 20, color: '#cbd5e1' }, + { step: 30, color: '#94a3b8' }, + { step: 40, color: '#64748b' }, + { step: 50, color: '#475569' }, + { step: 60, color: '#334155' }, + { step: 70, color: '#1e293b' }, + { step: 80, color: '#0f172a' }, + { step: 90, color: '#0c1322' }, + { step: 95, color: '#070d19' }, + { step: 100, color: '#020617' }, +]; -const PRIMITIVE_COLORS: PrimitiveColorGroup[] = [ +const PRIMARY_STEPS: ColorStep[] = [ + { step: 0, color: '#ecfeff' }, + { step: 5, color: '#cffafe' }, + { step: 10, color: '#a5f3fc' }, + { step: 20, color: '#67e8f9' }, + { step: 30, color: '#22d3ee' }, + { step: 40, color: '#06b6d4' }, + { step: 50, color: '#0891b2' }, + { step: 60, color: '#0e7490' }, + { step: 70, color: '#155e75' }, + { step: 80, color: '#164e63' }, + { step: 90, color: '#134152' }, + { step: 95, color: '#0c3140' }, + { step: 100, color: '#042f2e' }, +]; + +const SECONDARY_STEPS: ColorStep[] = [ + { step: 0, color: '#f0f4fa' }, + { step: 5, color: '#e1e8f4' }, + { step: 10, color: '#c3d1e8' }, + { step: 20, color: '#8da4c8' }, + { step: 30, color: '#5a7eb0' }, + { step: 40, color: '#3d6399' }, + { step: 50, color: '#2d4f80' }, + { step: 60, color: '#1e3a66' }, + { step: 70, color: '#1a2236' }, + { step: 80, color: '#121929' }, + { step: 90, color: '#0f1524' }, + { step: 95, color: '#0a0e1a' }, + { step: 100, color: '#050811' }, +]; + +const PRIMARY_CONTRAST_RATINGS: ContrastRating[] = PRIMARY_STEPS.map(({ step }) => ({ + step, + rating: step <= 30 ? 'AAA' : step <= 50 ? 'AA' : 'AAA', +})); + +const SECONDARY_CONTRAST_RATINGS: ContrastRating[] = SECONDARY_STEPS.map(({ step }) => ({ + step, + rating: step <= 30 ? 'AAA' : step <= 50 ? 'AA' : 'AAA', +})); + +const COLOR_TOKEN_GROUPS: ColorTokenGroup[] = [ { - name: 'Navy', - steps: [ - { label: '0', hex: '#0a0e1a' }, - { label: '1', hex: '#0f1524' }, - { label: '2', hex: '#121929' }, - { label: '3', hex: '#1a2236' }, - { label: 'hover', hex: '#1e2844' }, + title: 'Static', + tokens: [ + { name: 'static.black', hex: '#131415' }, + { name: 'static.white', hex: '#ffffff' }, ], }, { - name: 'Cyan', - steps: [ - { label: '00', hex: '#ecfeff' }, { label: '10', hex: '#cffafe' }, - { label: '20', hex: '#a5f3fc' }, { label: '30', hex: '#67e8f9' }, - { label: '40', hex: '#22d3ee' }, { label: '50', hex: '#06b6d4' }, - { label: '60', hex: '#0891b2' }, { label: '70', hex: '#0e7490' }, - { label: '80', hex: '#155e75' }, { label: '90', hex: '#164e63' }, - { label: '100', hex: '#083344' }, + title: 'Gray', + tokens: [ + { name: 'gray.100', hex: '#f1f5f9' }, + { name: 'gray.200', hex: '#e2e8f0' }, + { name: 'gray.300', hex: '#cbd5e1' }, + { name: 'gray.400', hex: '#94a3b8' }, + { name: 'gray.500', hex: '#64748b' }, + { name: 'gray.600', hex: '#475569' }, + { name: 'gray.700', hex: '#334155' }, + { name: 'gray.800', hex: '#1e293b' }, + { name: 'gray.900', hex: '#0f172a' }, + { name: 'gray.1000', hex: '#020617' }, ], }, { - name: 'Blue', - steps: [ - { label: '00', hex: '#eff6ff' }, { label: '10', hex: '#dbeafe' }, - { label: '20', hex: '#bfdbfe' }, { label: '30', hex: '#93c5fd' }, - { label: '40', hex: '#60a5fa' }, { label: '50', hex: '#3b82f6' }, - { label: '60', hex: '#2563eb' }, { label: '70', hex: '#1d4ed8' }, - { label: '80', hex: '#1e40af' }, { label: '90', hex: '#1e3a8a' }, - { label: '100', hex: '#172554' }, + title: 'Blue', + tokens: [ + { name: 'blue.100', hex: '#dbeafe' }, + { name: 'blue.200', hex: '#bfdbfe' }, + { name: 'blue.300', hex: '#93c5fd' }, + { name: 'blue.400', hex: '#60a5fa' }, + { name: 'blue.500', hex: '#3b82f6' }, + { name: 'blue.600', hex: '#2563eb' }, + { name: 'blue.700', hex: '#1d4ed8' }, + { name: 'blue.800', hex: '#1e40af' }, + { name: 'blue.900', hex: '#1e3a8a' }, + { name: 'blue.1000', hex: '#172554' }, ], }, { - name: 'Red', - steps: [ - { label: '00', hex: '#fef2f2' }, { label: '10', hex: '#fee2e2' }, - { label: '20', hex: '#fecaca' }, { label: '30', hex: '#fca5a5' }, - { label: '40', hex: '#f87171' }, { label: '50', hex: '#ef4444' }, - { label: '60', hex: '#dc2626' }, { label: '70', hex: '#b91c1c' }, - { label: '80', hex: '#991b1b' }, { label: '90', hex: '#7f1d1d' }, - { label: '100', hex: '#450a0a' }, + title: 'Green', + tokens: [ + { name: 'green.100', hex: '#dcfce7' }, + { name: 'green.200', hex: '#bbf7d0' }, + { name: 'green.300', hex: '#86efac' }, + { name: 'green.400', hex: '#4ade80' }, + { name: 'green.500', hex: '#22c55e' }, + { name: 'green.600', hex: '#16a34a' }, + { name: 'green.700', hex: '#15803d' }, + { name: 'green.800', hex: '#166534' }, + { name: 'green.900', hex: '#14532d' }, + { name: 'green.1000', hex: '#052e16' }, ], }, { - name: 'Green', - steps: [ - { label: '00', hex: '#f0fdf4' }, { label: '10', hex: '#dcfce7' }, - { label: '20', hex: '#bbf7d0' }, { label: '30', hex: '#86efac' }, - { label: '40', hex: '#4ade80' }, { label: '50', hex: '#22c55e' }, - { label: '60', hex: '#16a34a' }, { label: '70', hex: '#15803d' }, - { label: '80', hex: '#166534' }, { label: '90', hex: '#14532d' }, - { label: '100', hex: '#052e16' }, + title: 'Yellow', + tokens: [ + { name: 'yellow.100', hex: '#fef9c3' }, + { name: 'yellow.200', hex: '#fef08a' }, + { name: 'yellow.300', hex: '#fde047' }, + { name: 'yellow.400', hex: '#facc15' }, + { name: 'yellow.500', hex: '#eab308' }, + { name: 'yellow.600', hex: '#ca8a04' }, + { name: 'yellow.700', hex: '#a16207' }, + { name: 'yellow.800', hex: '#854d0e' }, + { name: 'yellow.900', hex: '#713f12' }, + { name: 'yellow.1000', hex: '#422006' }, ], }, { - name: 'Orange', - steps: [ - { label: '00', hex: '#fff7ed' }, { label: '10', hex: '#ffedd5' }, - { label: '20', hex: '#fed7aa' }, { label: '30', hex: '#fdba74' }, - { label: '40', hex: '#fb923c' }, { label: '50', hex: '#f97316' }, - { label: '60', hex: '#ea580c' }, { label: '70', hex: '#c2410c' }, - { label: '80', hex: '#9a3412' }, { label: '90', hex: '#7c2d12' }, - { label: '100', hex: '#431407' }, + title: 'Red', + tokens: [ + { name: 'red.100', hex: '#fee2e2' }, + { name: 'red.200', hex: '#fecaca' }, + { name: 'red.300', hex: '#fca5a5' }, + { name: 'red.400', hex: '#f87171' }, + { name: 'red.500', hex: '#ef4444' }, + { name: 'red.600', hex: '#dc2626' }, + { name: 'red.700', hex: '#b91c1c' }, + { name: 'red.800', hex: '#991b1b' }, + { name: 'red.900', hex: '#7f1d1d' }, + { name: 'red.1000', hex: '#450a0a' }, ], }, { - name: 'Yellow', - steps: [ - { label: '00', hex: '#fefce8' }, { label: '10', hex: '#fef9c3' }, - { label: '20', hex: '#fef08a' }, { label: '30', hex: '#fde047' }, - { label: '40', hex: '#facc15' }, { label: '50', hex: '#eab308' }, - { label: '60', hex: '#ca8a04' }, { label: '70', hex: '#a16207' }, - { label: '80', hex: '#854d0e' }, { label: '90', hex: '#713f12' }, - { label: '100', hex: '#422006' }, + title: 'Purple', + tokens: [ + { name: 'purple.100', hex: '#f3e8ff' }, + { name: 'purple.200', hex: '#e9d5ff' }, + { name: 'purple.300', hex: '#d8b4fe' }, + { name: 'purple.400', hex: '#c084fc' }, + { name: 'purple.500', hex: '#a855f7' }, + { name: 'purple.600', hex: '#9333ea' }, + { name: 'purple.700', hex: '#7e22ce' }, + { name: 'purple.800', hex: '#6b21a8' }, + { name: 'purple.900', hex: '#581c87' }, + { name: 'purple.1000', hex: '#3b0764' }, ], }, ]; -// ---------- Semantic Colors 데이터 ---------- +// ---------- 헬퍼 ---------- -const SEMANTIC_CATEGORIES: SemanticCategory[] = [ - { - name: 'Text', - tokens: [ - { token: 'text-1', dark: '#edf0f7', light: '#0f172a', usage: ['기본 텍스트 색상', '아이콘 기본 색상'] }, - { token: 'text-2', dark: '#b0b8cc', light: '#475569', usage: ['보조 텍스트 색상'] }, - { token: 'text-3', dark: '#8690a6', light: '#94a3b8', usage: ['비활성 텍스트', '플레이스홀더'] }, - ], - }, - { - name: 'Background', - tokens: [ - { token: 'bg-0', dark: '#0a0e1a', light: '#f8fafc', usage: ['페이지 배경'] }, - { token: 'bg-1', dark: '#0f1524', light: '#ffffff', usage: ['사이드바', '패널'] }, - { token: 'bg-2', dark: '#121929', light: '#f1f5f9', usage: ['테이블 헤더'] }, - { token: 'bg-3', dark: '#1a2236', light: '#e2e8f0', usage: ['카드 배경'] }, - { token: 'bg-hover', dark: '#1e2844', light: '#cbd5e1', usage: ['호버 상태'] }, - ], - }, - { - name: 'Border', - tokens: [ - { token: 'border', dark: '#1e2a42', light: '#cbd5e1', usage: ['기본 구분선'] }, - { token: 'border-light', dark: '#2a3a5c', light: '#e2e8f0', usage: ['연한 구분선'] }, - ], - }, - { - name: 'Accent', - tokens: [ - { token: 'primary-cyan', dark: '#06b6d4', light: '#06b6d4', usage: ['주요 강조', '활성 상태'] }, - { token: 'primary-blue', dark: '#3b82f6', light: '#0891b2', usage: ['보조 강조'] }, - { token: 'primary-purple', dark: '#a855f7', light: '#6366f1', usage: ['3차 강조'] }, - ], - }, - { - name: 'Status', - tokens: [ - { token: 'status-red', dark: '#ef4444', light: '#dc2626', usage: ['위험', '삭제'] }, - { token: 'status-orange', dark: '#f97316', light: '#c2410c', usage: ['주의'] }, - { token: 'status-yellow', dark: '#eab308', light: '#b45309', usage: ['경고'] }, - { token: 'status-green', dark: '#22c55e', light: '#047857', usage: ['정상', '성공'] }, - ], - }, -]; +const hexToRgb = (hex: string): string => { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgb(${r}, ${g}, ${b})`; +}; + +// ---------- 내부 컴포넌트: ColorScaleBar ---------- + +const ColorScaleBar = ({ + steps, + markers, + contrastRatings, + darkBg = false, + isDark, +}: ColorScaleBarProps) => { + const badgeBg = isDark ? '#374151' : '#374151'; + const badgeText = isDark ? '#e5e7eb' : '#fff'; + + const getContrastRating = (step: number): string | undefined => { + return contrastRatings?.find((r) => r.step === step)?.rating; + }; + + const getMarker = (step: number): Marker | undefined => { + return markers?.find((m) => m.step === step); + }; + + return ( +
+ {/* 색상 바 */} +
+ {steps.map(({ step, color }, idx) => { + const isFirst = idx === 0; + const isLast = idx === steps.length - 1; + const textColor = step < 50 + ? (darkBg ? '#e2e8f0' : '#1e293b') + : '#e2e8f0'; + + const rating = getContrastRating(step); + + return ( +
+ {/* 상단: 단계 번호 */} + {step} + {/* 하단: 접근성 등급 */} + {rating && ( + + {rating} + + )} +
+ ); + })} +
+ + {/* 마커 행 */} + {markers && markers.length > 0 && ( +
+ {steps.map(({ step }, idx) => { + const marker = getMarker(step); + const pct = (idx / (steps.length - 1)) * 100; + + if (!marker) return null; + + return ( +
+ {/* 점선 */} +
+ {/* 뱃지 */} + + {marker.label} + +
+ ); + })} +
+ )} +
+ ); +}; + +// ---------- 내부 컴포넌트: TransparencyRow ---------- + +interface TransparencyRowProps { + label: string; + rgbBase: string; // 'rgba base: "0,0,0"' or "255,255,255" + markerStep: number; + markerLabel: string; + isDark: boolean; +} + +const TransparencyRow = ({ + label, + rgbBase, + markerStep, + markerLabel, + isDark, +}: TransparencyRowProps) => { + const sectionCardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const badgeBg = '#374151'; + const badgeText = isDark ? '#e5e7eb' : '#fff'; + + const checkerboard = `repeating-conic-gradient(#d0d0d0 0% 25%, #fff 0% 50%)`; + + return ( +
+ + {label} + +
+ {/* 체크보드 + 색상 오버레이 */} +
+
+ {TRANSPARENCY_STEPS.map((step) => { + const alpha = step / 100; + const color = `rgba(${rgbBase},${alpha})`; + const isBlack = rgbBase === '0,0,0'; + const textColor = isBlack + ? (step < 50 ? '#333' : '#fff') + : (step < 50 ? '#333' : '#aaa'); + + return ( +
+ + {step} + +
+ ); + })} +
+
+ + {/* 마커 */} +
+ {TRANSPARENCY_STEPS.map((step, idx) => { + if (step !== markerStep) return null; + const pct = (idx / (TRANSPARENCY_STEPS.length - 1)) * 100; + + return ( +
+
+ + {markerLabel} + +
+ ); + })} +
+
+
+ ); +}; // ---------- Props ---------- @@ -165,324 +441,309 @@ interface ColorPaletteContentProps { export const ColorPaletteContent = ({ theme }: ColorPaletteContentProps) => { const t = theme; const isDark = t.mode === 'dark'; + const [activeColorTab, setActiveColorTab] = useState<'usage' | 'token'>('usage'); + + const sectionCardBg = isDark ? 'rgba(255,255,255,0.03)' : '#f5f5f5'; + const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e5e7eb'; return ( -
+
+
- {/* ── 섹션 1: 헤더 ── */} -
-
+ {/* ── 섹션 1: 헤더 ── */} +
+

+ Foundations +

Color

- WING-OPS 인터페이스에서 사용되는 색상 체계입니다. Primitive Token(원시 팔레트)과 Semantic Token(의미 기반 토큰) 두 계층으로 구성됩니다. + 브랜드 아이덴티티를 유지하기 위한 컬러 사용 기준입니다.
핵심 정보를 강조하고 현재 상태를 명확하게 전달합니다.

- {/* 토큰 네이밍 다이어그램 카드 */} -
- - bg - - - — - - - 0 - -
-
- - Category - - - 색상 범주 (bg, text, border, status…) - -
-
-
- - Value - - - 단계 또는 역할 (0–3, hover, red, cyan…) - -
-
-
-
- - {/* ── 섹션 2: Primitive Tokens ── */} -
-
-

- Primitive Tokens -

-
    -
  • Tailwind CSS 기반 색상 스케일로 구성된 원시 팔레트입니다.
  • -
  • 각 색조(Hue)는 00(가장 밝음)에서 100(가장 어두움)까지 11단계로 표현됩니다.
  • -
  • Navy는 UI 배경 계층에만 사용되는 특수 팔레트입니다.
  • -
-
- -
- {PRIMITIVE_COLORS.map((group) => ( -
- {/* 그룹명 */} - - {group.name} - - - {/* 가로 컬러 바 */} -
- {group.steps.map((step) => ( -
-
-
- - {step.label} - - - {step.hex} - -
-
- ))} -
-
- ))} -
-
- - {/* ── 섹션 3: Semantic Colors ── */} -
-
-

- Semantic Colors -

-
    -
  • Primitive Token을 역할 기반으로 추상화한 의미 토큰입니다.
  • -
  • 다크/라이트 모드에 따라 실제 색상 값이 달라집니다.
  • -
  • 코드에서는 항상 Semantic Token을 사용하고, Primitive Token을 직접 참조하지 않습니다.
  • -
-
- -
- {SEMANTIC_CATEGORIES.map((category) => ( -
- {/* 카테고리 제목 (좌측 2px cyan accent bar) */} -
-
- - {category.name} - -
- - {/* 테이블 */} -
+ {(['usage', 'token'] as const).map((tab) => { + const isActive = activeColorTab === tab; + return ( + + ); + })} +
+ + {/* ── Usage 탭 콘텐츠 ── */} + {activeColorTab === 'usage' && ( + <> + {/* ── 섹션 2: Primary color ── */} +
+

+ 프라이머리 색상(primary color) +

+

+ Primary 색상은 해양 방제 시스템의 핵심 인터랙션 요소에 사용됩니다. Cyan~Blue 그라디언트가 주요 액션 버튼과 강조 요소에 적용됩니다. +

+ +
+ {/* Light Mode */}
+

+ Light Mode +

+ +
+ + {/* Dark Mode */} +
+

+ Dark Mode +

+ +
+
+
+ + {/* ── 섹션 3: Secondary color ── */} +
+

+ 세컨더리 색상(secondary color) +

+

+ Secondary 색상은 UI의 배경과 구조적 요소에 사용됩니다. Navy 계열로 다크 모드의 깊이감과 계층 구조를 표현합니다. +

+ +
+ {/* Light Mode */} +
+

+ Light Mode +

+ +
+ + {/* Dark Mode */} +
+

+ Dark Mode +

+ +
+
+
+ + {/* ── 섹션 4: Gray color ── */} +
+

+ 그레이 색상(gray color) / 네추럴, 중립 색상 +

+

+ Gray 색상은 주로 배경, 텍스트, 구분 선에 사용되며, 시각적 집중을 방해하지 않고 콘텐츠에 초점을 맞추도록 도와주는 중립적인 색상이다. +

+

+ 표준형 스타일의 그레이 색상은 주요 색상과 선명한 모드에서의 조화를 고려해 블루 그레이 계열을 사용한다. +

+ +
+ +
+
+ + {/* ── 섹션 5: Transparent ── */} +
+

+ Transparent +

+

+ 투명도와 음영을 활용하여 정보의 집중도를 조절합니다. 배경의 음영 처리는 투명도 65%를 사용합니다. +

+ +
+ {/* Black */} + + + {/* White */} + +
+
+ + )} + + {/* ── Token 탭 콘텐츠 ── */} + {activeColorTab === 'token' && ( +
+ {COLOR_TOKEN_GROUPS.map((group, groupIdx) => ( +
+

- {(['Token', 'Dark Mode', 'Light Mode', 'Usage'] as const).map((col) => ( -
+ {group.title} +

+
+ {group.tokens.map((token) => ( +
+ {/* 색상 스와치 */} +
+ {/* 토큰명 */} - {col} + {token.name} + {/* HEX + RGB */} +
+
+ {token.hex} +
+
+ {hexToRgb(token.hex)} +
+
))}
- - {/* 데이터 행 */} - {category.tokens.map((token, rowIdx) => ( -
- {/* Token 컬럼 */} -
- - {token.token} - -
- - {/* Dark Mode 컬럼 */} -
-
-
- - {token.dark} - -
-
- - {/* Light Mode 컬럼 */} -
-
-
- - {token.light} - -
-
- - {/* Usage 컬럼 */} -
-
    - {token.usage.map((u) => ( -
  • - - - {u} - -
  • - ))} -
-
-
- ))}
-
- ))} -
+ ))} +
+ )} +
); From 448413f5b199a26a9047b63ed104a537379ee9d7 Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 16:04:23 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 614722f..d7d2fa3 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -9,6 +9,8 @@ - react-router-dom 도입, BrowserRouter 래핑 - SVG 아이콘 에셋 19종 추가 - @/ path alias 추가 +- 디자인: Components 탭 추가 (Button, TextField, Overview 페이지) +- 관리자: 수거인력 패널 및 선박모니터링 패널 추가 - 레이어: 레이어 데이터 테이블 매핑 구현 + 어장 팝업 수정 - 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장 - DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션) @@ -18,6 +20,7 @@ - DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation) ### 변경 +- 디자인: 색상 팔레트 컨텐츠 개선 + base.css 확장 - SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환 - 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거) - 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동) From 18f70ffab900140fc20937640903ffbbe426680b Mon Sep 17 00:00:00 2001 From: leedano Date: Wed, 25 Mar 2026 16:08:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 33e25d4..6f5a1f5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-25] + ### 추가 - 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회) - DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn)