Compare commits

...

2 커밋

46개의 변경된 파일2165개의 추가작업 그리고 244개의 파일을 삭제

파일 보기

@ -0,0 +1,462 @@
# WING-OPS 디자인 시스템 (백업)
Tailwind CSS + @apply 기반 디자인 시스템. `wing-*` CSS 클래스와 React UI 컴포넌트로 구성.
---
## 브랜드 (Brand)
### 로고
해양 환경 위기대응을 위한 통합 솔루션 **WING**의 로고.
- **파일**: `frontend/public/wing_logo_white.svg`
- **네이티브 크기**: 280 × 20px (비율 14:1)
- **색상**: 단색 흰색 (다크 배경 전용)
#### 로고 규격
| 용도 | 높이 | Tailwind | 비고 |
|------|------|----------|------|
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
#### 여백 규칙 (Clear Space)
- 최소 여백: 로고 높이의 **50%** (상하좌우)
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
### 테마 컬러
다크 테마 기반. 게시판(Board) 메뉴에서 추출한 컬러 체계.
#### 배경 (Background) — 딥 네이비
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `bg-bg-0` | `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
| `bg-bg-1` | `--bg1` | `#0f1524` | 패널, 모달, 푸터 배경 |
| `bg-bg-2` | `--bg2` | `#121929` | 테이블 헤더, elevated 영역 |
| `bg-bg-3` | `--bg3` | `#1a2236` | 카드, 보조 버튼, 비활성 요소 |
| `bg-hover` | `--bgH` | `#1e2844` | 행 hover |
#### 텍스트 (Text)
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `text-text-1` | `--t1` | `#edf0f7` | 주 텍스트 (제목, 본문) |
| `text-text-2` | `--t2` | `#b0b8cc` | 보조 텍스트 (라벨, 설명) |
| `text-text-3` | `--t3` | `#8690a6` | 비활성/메타 (날짜, 조회수) |
#### 브랜드 강조색 (Accent)
| 토큰 | HEX | 용도 |
|------|-----|------|
| `primary-cyan` | `#06b6d4` | 주 강조색 — 활성 상태, 링크, CTA |
| `primary-blue` | `#3b82f6` | 보조 강조색 — 그라데이션 끝점 |
| **Primary Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | Primary 버튼 |
#### 시맨틱 (Semantic)
| 토큰 | HEX | 용도 |
|------|-----|------|
| `red` / `danger` | `#ef4444` | 삭제, 필수 표시(*) |
| `orange` | `#f97316` | 경고 |
| `yellow` | `#eab308` | 주의 |
| `green` | `#22c55e` | 성공, 정상 |
| `purple` | `#a855f7` | 특수 강조 |
#### 테두리 (Border)
| 토큰 | CSS 변수 | HEX | 용도 |
|------|----------|-----|------|
| `border` | `--bd` | `#1e2a42` | 기본 구분선 |
| `border-light` | `--bdL` | `#2a3a5c` | 밝은 테두리 |
#### 오버레이 (Overlay)
| 토큰 | 값 | 용도 |
|------|-----|------|
| `overlay` | `rgba(0, 0, 0, 0.55)` | 모달 배경 오버레이 |
---
## 디자인 원칙
### 컬러 사용 규칙
- **상태 표현** (위험/정상/주의) → 컬러 배지 사용 (red, green, yellow)
- **단순 분류** (공지사항, 자료실, Q&A) → 기본 텍스트 컬러 또는 neutral 배지
- **강조** (고정글, 선택 항목) → accent 컬러 (cyan)
- **원칙**: 색상은 정보를 전달할 때만 사용. 장식 목적 금지.
---
## 1. 토큰
### 컬러 팔레트
| CSS 변수 | 값 | 용도 |
|----------|-----|------|
| `--bg0` | `#0a0e1a` | 최하위 배경 (body, input) |
| `--bg1` | `#0f1524` | 기본 패널 배경 |
| `--bg2` | `#121929` | 테이블 헤더, elevated |
| `--bg3` | `#1a2236` | 카드, 섹션 배경 |
| `--bgH` | `#1e2844` | hover 상태 |
| `--bd` | `#1e2a42` | 기본 테두리 |
| `--bdL` | `#2a3a5c` | 밝은 테두리 |
| `--t1` | `#edf0f7` | 기본 텍스트 (밝음) |
| `--t2` | `#b0b8cc` | 보조 텍스트 |
| `--t3` | `#8690a6` | 비활성/메타 텍스트 |
| `--cyan` | `#06b6d4` | Primary accent |
| `--blue` | `#3b82f6` | Secondary accent |
| `--purple` | `#a855f7` | 특수 강조 |
| `--red` | `#ef4444` | 위험/삭제 |
| `--orange` | `#f97316` | 경고 |
| `--yellow` | `#eab308` | 주의 |
| `--green` | `#22c55e` | 성공/정상 |
### Z-Index 스케일
| 변수 | 값 | 용도 |
|------|-----|------|
| `--z-dropdown` | 100 | 드롭다운, 콤보박스 |
| `--z-sticky` | 200 | sticky 헤더 |
| `--z-overlay` | 1000 | 오버레이 |
| `--z-modal` | 10000 | 모달 |
| `--z-toast` | 10100 | 토스트 알림 |
### 패널 너비
| 변수 | 값 | 용도 |
|------|-----|------|
| `--panel-narrow` | 280px | 좁은 사이드패널 |
| `--panel-default` | 300px | 기본 사이드패널 |
| `--panel-wide` | 340px | 넓은 사이드패널 |
### 타이포그래피 (Tailwind)
| 클래스 | 크기 | 용도 |
|--------|------|------|
| `text-wing-meta` | 9px | 메타 텍스트, 날짜, 부가 정보 |
| `text-wing-caption` | 10px | 캡션, 설명, 라벨 부연 |
| `text-wing-body` | 11px | 본문, 라벨, 값 (가장 많이 사용) |
| `text-wing-heading` | 13px | 섹션 헤더 |
| `text-wing-title` | 15px | 페이지/모달 타이틀 |
---
## 2. CSS 클래스 (`wing-*`)
모든 클래스는 `frontend/src/common/styles/wing.css`에 정의.
### Layout
| 클래스 | 설명 |
|--------|------|
| `wing-panel` | flex column, full height, overflow hidden |
| `wing-panel-scroll` | flex-1, overflow-y-auto, thin scrollbar |
| `wing-panel-right` | 우측 사이드패널 (border-l, 300px) |
| `wing-panel-left` | 좌측 사이드패널 (border-r, 300px) |
| `wing-header-bar` | 헤더 바 (flex between, border-b, px-5) |
| `wing-sidebar` | 사이드바 (flex col, border-r, bg1) |
### Card / Section
| 클래스 | 설명 |
|--------|------|
| `wing-card` | 카드 (rounded-md, p-4, border, bg3) |
| `wing-card-sm` | 작은 카드 (rounded-sm, p-3) |
| `wing-section` | 섹션 (rounded-md, p-4, mb-3) |
| `wing-section-header` | 섹션 제목 (13px bold) |
| `wing-section-desc` | 섹션 설명 (10px, t3 색상) |
### Typography
| 클래스 | 설명 |
|--------|------|
| `wing-title` | 15px bold korean |
| `wing-subtitle` | 10px korean, t3 색상 |
| `wing-label` | 11px semibold korean |
| `wing-value` | 11px semibold mono |
| `wing-meta` | 9px korean, t3 색상 |
### Button
| 클래스 | 설명 |
|--------|------|
| `wing-btn` | 기본 버튼 (px-3, py-1.5, 11px) |
| `wing-btn-primary` | cyan→blue gradient, white |
| `wing-btn-secondary` | bg3, border, t2 색상 |
| `wing-btn-outline` | transparent, border |
| `wing-btn-pdf` | blue 테마 |
| `wing-btn-danger` | red 테마 |
### Input / Select / Textarea
| 클래스 | 설명 |
|--------|------|
| `wing-input` | 기본 입력 (w-full, 11px, cyan focus) |
| `wing-select` | 셀렉트 (커스텀 화살표 포함) |
| `wing-textarea` | 텍스트영역 (resize-vertical, min-h 80px) |
| `wing-input-search` | 검색 입력 (256px 고정) |
### Table
| 클래스 | 설명 |
|--------|------|
| `wing-table` | 테이블 (w-full, 10px, collapse) |
| `wing-table-head` | 헤더 셀 (bg2, t3, bold) |
| `wing-table-cell` | 데이터 셀 (t2, border-b) |
| `wing-table-row` | 행 hover (bgH, cursor-pointer) |
### Badge
| 클래스 | 설명 |
|--------|------|
| `wing-badge` | 기본 배지 (inline-flex, 9px bold) |
| `wing-badge-neutral` | 회색 (단순 분류용 기본값) |
| `wing-badge-red` | 위험/삭제 |
| `wing-badge-blue` | 정보 |
| `wing-badge-green` | 성공/정상 |
| `wing-badge-yellow` | 주의 |
| `wing-badge-purple` | 특수 |
| `wing-badge-cyan` | 주요 |
### Modal
| 클래스 | 설명 |
|--------|------|
| `wing-overlay` | 오버레이 (fixed, blur, z-modal) |
| `wing-modal` | 모달 컨테이너 (rounded-xl, bg1) |
| `wing-modal-header` | 모달 헤더 (flex between, border-b) |
| `wing-modal-body` | 모달 본문 (flex-1, scroll) |
| `wing-modal-footer` | 모달 푸터 (flex end, border-t) |
| `wing-modal-sm` | 400px |
| `wing-modal-md` | 560px |
| `wing-modal-lg` | 720px |
### Tab
| 클래스 | 설명 |
|--------|------|
| `wing-tab-bar` | 탭 바 (flex, rounded-lg, border) |
| `wing-tab` | 탭 아이템 (flex-1, text-center) |
| `wing-tab.active` | 활성 탭 (cyan border/bg/text) |
### Utility
| 클래스 | 설명 |
|--------|------|
| `wing-divider` | 구분선 (1px, full width) |
| `wing-info-row` | 키-값 행 (flex between) |
| `wing-info-label` | 키 라벨 (10px, t3) |
| `wing-info-value` | 값 (11px, semibold mono) |
---
## 3. React 컴포넌트
위치: `frontend/src/common/components/ui/`
### Modal
```tsx
import Modal from '@common/components/ui/Modal';
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="제목"
size="md"
footer={
<>
<button className="wing-btn wing-btn-secondary" onClick={handleCancel}>취소</button>
<button className="wing-btn wing-btn-primary" onClick={handleConfirm}>확인</button>
</>
}
>
<p>모달 내용</p>
</Modal>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `isOpen` | `boolean` | 필수 | 표시 여부 |
| `onClose` | `() => void` | 필수 | 닫기 콜백 |
| `title` | `string` | 필수 | 헤더 제목 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 너비 (400/560/720px) |
| `children` | `ReactNode` | 필수 | 본문 |
| `footer` | `ReactNode` | - | 하단 버튼 영역 |
| `closeOnBackdrop` | `boolean` | `true` | 배경 클릭 닫기 |
### Pagination
```tsx
import Pagination from '@common/components/ui/Pagination';
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
/>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `currentPage` | `number` | 필수 | 현재 페이지 (1-based) |
| `totalPages` | `number` | 필수 | 전체 페이지 수 |
| `onPageChange` | `(page: number) => void` | 필수 | 페이지 변경 콜백 |
| `showFirstLast` | `boolean` | `true` | 처음/끝 버튼 표시 |
### DataTable
```tsx
import DataTable, { Column } from '@common/components/ui/DataTable';
interface Post {
id: number;
title: string;
author: string;
createdAt: string;
}
const columns: Column<Post>[] = [
{ key: 'id', label: '번호', width: '60px', align: 'center' },
{ key: 'title', label: '제목' },
{ key: 'author', label: '작성자', width: '100px' },
{ key: 'createdAt', label: '작성일', width: '120px',
render: (val) => new Date(val as string).toLocaleDateString() },
];
<DataTable
columns={columns}
data={posts}
onRowClick={(post) => navigate(`/board/${post.id}`)}
/>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `columns` | `Column<T>[]` | 필수 | 컬럼 정의 |
| `data` | `T[]` | 필수 | 데이터 배열 |
| `onRowClick` | `(row: T) => void` | - | 행 클릭 콜백 |
| `stickyHeader` | `boolean` | `true` | 헤더 고정 |
| `emptyMessage` | `string` | `'데이터가 없습니다.'` | 빈 상태 메시지 |
### SidePanel
```tsx
import SidePanel from '@common/components/ui/SidePanel';
<SidePanel
position="right"
width="default"
header={<span className="wing-title">상세 정보</span>}
footer={
<button className="wing-btn wing-btn-primary w-full">저장</button>
}
>
<div className="p-3">
<div className="wing-info-row">
<span className="wing-info-label">상태</span>
<Badge color="green">정상</Badge>
</div>
</div>
</SidePanel>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `position` | `'left' \| 'right'` | 필수 | 배치 방향 |
| `width` | `'narrow' \| 'default' \| 'wide'` | `'default'` | 너비 (280/300/340px) |
| `header` | `ReactNode` | - | 헤더 영역 |
| `footer` | `ReactNode` | - | 하단 영역 |
### Badge
```tsx
import Badge from '@common/components/ui/Badge';
<Badge color="green">정상</Badge> {/* 상태 표현 */}
<Badge color="red">위험</Badge> {/* 상태 표현 */}
<Badge>공지사항</Badge> {/* 단순 분류 → neutral */}
<Badge>자료실</Badge> {/* 단순 분류 → neutral */}
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `color` | `'red' \| 'blue' \| 'green' \| 'yellow' \| 'purple' \| 'cyan' \| 'neutral'` | `'neutral'` | 배지 색상 |
---
## 4. 적용 예시: Before → After
### Before (raw Tailwind 복붙)
```tsx
{/* 검색 */}
<input
type="text"
placeholder="검색..."
className="w-64 px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1
placeholder-text-3 focus:border-primary-cyan focus:outline-none"
/>
{/* 테이블 */}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-xs font-bold text-text-3">제목</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-border hover:bg-bg-2 cursor-pointer">
<td className="px-4 py-4 text-sm text-text-2">{item.title}</td>
</tr>
</tbody>
</table>
{/* 배지 — 의미 없는 컬러 분화 */}
<span className="px-2.5 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
공지사항
</span>
{/* 페이지네이션 — 직접 구현 */}
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">이전</button>
<button className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 ...">다음</button>
```
### After (디자인 시스템)
```tsx
{/* 검색 */}
<input type="text" placeholder="검색..." className="wing-input-search" />
{/* 테이블 — React 컴포넌트 */}
<DataTable columns={columns} data={posts} onRowClick={handleClick} />
{/* 배지 — neutral로 통일 */}
<Badge>공지사항</Badge>
<Badge>자료실</Badge>
<Badge color="green">진행중</Badge> {/* 상태만 컬러 */}
{/* 페이지네이션 — React 컴포넌트 */}
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
```
---
## 5. 마이그레이션 가이드
### 작업 순서
1. 파일 내 raw Tailwind 문자열을 `wing-*` 클래스로 교체
2. 반복되는 행동 패턴 (모달, 페이지네이션, 테이블)을 React 컴포넌트로 교체
3. 의미 없는 컬러 배지를 `<Badge>` (neutral)로 교체
4. 브라우저에서 시각적 동일성 확인
### 판단 기준
```
순수 시각 + 5회 이상 반복 → wing-* CSS 클래스
동작 포함 + 5회 이상 반복 → React 컴포넌트
1-2회 사용 → 인라인 Tailwind 유지
도메인 전용 시각 → components.css (유지)
```

169
docs/DESIGN-SYSTEM.md Normal file
파일 보기

@ -0,0 +1,169 @@
# WING 디자인 시스템
해양 환경 위기대응 통합 솔루션 **WING**의 디자인 시스템.
---
## 브랜드 (Brand)
### 로고
해양에서 발생하는 상황을 종합적으로 지원하는 솔루션 WING의 로고.
- **파일**: `frontend/public/wing_logo_white.svg`
- **네이티브 크기**: 280 × 20px (비율 14:1)
- **색상**: 단색 흰색 (다크 배경 전용)
#### 로고 규격
| 용도 | 높이 | Tailwind | 비고 |
|------|------|----------|------|
| Header | 14px | `h-3.5` | TopBar 52px 높이 내 사용 (현재) |
| Standard | 24px | `h-6` | 일반 UI, 문서 내 |
| Large | 32px | `h-8` | 로그인, 랜딩 화면 |
| **Minimum** | **14px** | `h-3.5` | 이보다 작게 사용 금지 |
#### 여백 규칙 (Clear Space)
- 최소 여백: 로고 높이의 **50%** (상하좌우)
- 로고 주변에 다른 텍스트나 아이콘이 침범하지 않도록 유지
---
## 파운데이션 (Foundations)
### 브랜드 컬러 (Brand Color)
현재 다크 테마만 구현되어 있으며, 라이트 팔레트는 향후 전환용으로 정의해 둔다.
> **현재 상태**: 다크 테마 단일 고정 (테마 전환 인프라 미구축)
#### 배경 (Background)
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--bg0` | `#0a0e1a` | `#ffffff` | 최하위 배경 (body, input) |
| `--bg1` | `#0f1524` | `#f8f9fb` | 패널, 모달, 푸터 배경 |
| `--bg2` | `#121929` | `#f0f2f5` | 테이블 헤더, elevated 영역 |
| `--bg3` | `#1a2236` | `#e8ebf0` | 카드, 보조 버튼, 비활성 요소 |
| `--bgH` | `#1e2844` | `#dde1e8` | 행 hover |
#### 텍스트 (Text)
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--t1` | `#edf0f7` | `#1a1d26` | 주 텍스트 (제목, 본문) |
| `--t2` | `#b0b8cc` | `#4a5568` | 보조 텍스트 (라벨, 설명) |
| `--t3` | `#8690a6` | `#8690a6` | 비활성/메타 — 양 테마 공유 |
#### Accent Color
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--cyan` | `#06b6d4` | `#0891b2` | 주 강조색 — 활성 상태, 링크, CTA |
| `--blue` | `#3b82f6` | `#2563eb` | 보조 강조색 — 그라데이션 끝점 |
| **Gradient** | `linear-gradient(135deg, #06b6d4, #3b82f6)` | `linear-gradient(135deg, #0891b2, #2563eb)` | Primary 버튼 |
> 라이트 테마에서 강조색을 한 단계 진하게 적용하여 흰 배경 위 가독성을 확보한다.
> **보조 컬러 검토**: 필요에 따라 시맨틱 컬러(red `#ef4444`, orange `#f97316`, yellow `#eab308`, green `#22c55e`, purple `#a855f7`)를 보조 컬러로 추가 검토 예정.
---
### 타이포그래피 (Typography)
#### 폰트 패밀리
| 토큰 | CSS 변수 | 폰트 | 용도 |
|------|----------|------|------|
| `font-sans` | — | Outfit, Noto Sans KR, sans-serif | 기본 UI (body) |
| `font-korean` | `--fK` | Noto Sans KR, sans-serif | 한글 강조 |
| `font-mono` | `--fM` | JetBrains Mono, monospace | 수치, 코드 |
#### 폰트 스케일
| 클래스 | 크기 | Line Height | Weight | 용도 |
|--------|------|-------------|--------|------|
| `text-wing-meta` | 9px | 1.4 | 400 | 메타 텍스트, 날짜, 부가 정보 |
| `text-wing-caption` | 10px | 1.4 | 400 | 캡션, 설명, 라벨 부연 |
| `text-wing-body` | 11px | 1.5 | 400 | 본문, 라벨, 값 (가장 많이 사용) |
| `text-wing-heading` | 13px | 1.4 | 700 | 섹션 헤더 |
| `text-wing-title` | 15px | 1.3 | 700 | 페이지/모달 타이틀 |
> 11px(`text-wing-body`)이 기본 본문 크기. 정보 밀도가 높은 운영 시스템 특성에 맞춘 compact 설계.
---
### 아이콘 (Icons)
- **라이브러리**: `lucide-react` (^0.564.0)
- **스타일**: Stroke 기반, 일관된 2px 선 두께
| 크기 | px | 용도 |
|------|-----|------|
| Small | 16px | 인라인 텍스트, 버튼 내부 |
| Default | 18px | 일반 UI 아이콘 |
| Large | 20px | 강조, 헤더 영역 |
```tsx
import { Search, ChevronDown, X } from 'lucide-react';
<Search size={16} /> {/* 인라인 */}
<ChevronDown size={18} /> {/* 일반 */}
<X size={20} /> {/* 강조 */}
```
---
### 간격 (Spacing)
4px 그리드 기반. Tailwind 유틸리티 클래스 사용.
| 토큰 | 값 | Tailwind | 주요 사용처 |
|------|-----|----------|------------|
| `spacing-1` | 4px | `p-1`, `gap-1` | 최소 간격, 아이콘-텍스트 |
| `spacing-1.5` | 6px | `py-1.5`, `gap-1.5` | 인풋 수직 패딩, 버튼 수직 패딩 |
| `spacing-2` | 8px | `p-2`, `gap-2` | 테이블 셀, 버튼 그룹 간격 |
| `spacing-3` | 12px | `p-3`, `gap-3` | 소형 카드 패딩 |
| `spacing-4` | 16px | `p-4`, `gap-4` | 카드/섹션 패딩 |
| `spacing-5` | 20px | `px-5` | 헤더/모달 수평 패딩 |
---
### 테두리 (Border)
#### 색상
| CSS 변수 | Dark | Light | 용도 |
|----------|------|-------|------|
| `--bd` | `#1e2a42` | `#d0d5dd` | 기본 구분선 |
| `--bdL` | `#2a3a5c` | `#e0e4ea` | 밝은 테두리 |
#### Radius
| 토큰 | 값 | Tailwind | 용도 |
|------|-----|----------|------|
| `radius-xs` | 4px | `rounded` | 배지, 최소 요소 |
| `radius-sm` | 6px | `rounded-sm` | 버튼, 인풋, 소형 카드 (가장 많이 사용) |
| `radius-md` | 10px | `rounded-md` | 카드, 섹션 |
| `radius-lg` | 12px | `rounded-xl` | 모달 |
> `rounded-sm`(6px)이 프로젝트 전반에서 지배적인 기본 반경값.
---
### 단위 체계 (Units)
> **현재 상태**: px 기반 단일 체계
> **향후 방향**: rem 전환 검토 (접근성/반응성 고려)
| 영역 | 현재 단위 | 비고 |
|------|-----------|------|
| font-size | px | `9px`~`15px`, Tailwind arbitrary `text-[11px]` |
| spacing/padding | px | raw CSS `6px 10px` 등 |
| layout 치수 | px | 패널 280~340px, 모달 400~720px |
| border-radius | px | `6px`, `10px`, `12px` |
| html font-size | 미설정 | rem 기준점 없음 (브라우저 기본 16px) |
- Tailwind 기본 유틸리티(`px-5`, `text-sm` 등)는 내부적으로 rem을 사용하나, 이는 의도적 설계가 아닌 Tailwind 부산물
- rem 전환 시 로드맵: `html { font-size }` 기준 설정 → Tailwind config rem 토큰 → wing.css 점진적 전환

파일 보기

@ -18,6 +18,7 @@ import { IncidentsView } from '@tabs/incidents'
import { AdminView } from '@tabs/admin' import { AdminView } from '@tabs/admin'
import { ScatView } from '@tabs/scat' import { ScatView } from '@tabs/scat'
import { RescueView } from '@tabs/rescue' import { RescueView } from '@tabs/rescue'
import { ShowcaseView } from '@tabs/showcase'
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
@ -97,6 +98,8 @@ function App() {
return <AdminView /> return <AdminView />
case 'rescue': case 'rescue':
return <RescueView /> return <RescueView />
case 'showcase':
return <ShowcaseView />
default: default:
return <div className="flex items-center justify-center h-full text-text-3"> ...</div> return <div className="flex items-center justify-center h-full text-text-3"> ...</div>
} }

파일 보기

@ -107,6 +107,17 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"> <button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
🔔 🔔
</button> </button>
<button
onClick={() => onTabChange('showcase')}
title="쇼케이스"
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
activeTab === 'showcase'
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
}`}
>
🎨
</button>
{hasPermission('admin') && ( {hasPermission('admin') && (
<button <button
onClick={() => onTabChange('admin')} onClick={() => onTabChange('admin')}

파일 보기

@ -0,0 +1,17 @@
import { cn } from '@common/utils/cn';
interface BadgeProps {
color?: 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'cyan' | 'neutral';
children: React.ReactNode;
className?: string;
}
const Badge = ({ color = 'neutral', children, className }: BadgeProps) => {
return (
<span className={cn('wing-badge', `wing-badge-${color}`, className)}>
{children}
</span>
);
};
export default Badge;

파일 보기

@ -0,0 +1,21 @@
import { cn } from '@common/utils/cn';
interface CardProps {
children: React.ReactNode;
hover?: boolean;
onClick?: () => void;
className?: string;
}
const Card = ({ children, hover = true, onClick, className }: CardProps) => {
return (
<div
className={cn('wing-card', hover && 'wing-card-hover', onClick && 'cursor-pointer', className)}
onClick={onClick}
>
{children}
</div>
);
};
export default Card;

파일 보기

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '@common/utils/cn';
export interface Column<T> {
key: keyof T | string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
render?: (value: unknown, row: T) => React.ReactNode;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
onRowClick?: (row: T) => void;
stickyHeader?: boolean;
emptyMessage?: string;
className?: string;
}
const alignClass = (align?: 'left' | 'center' | 'right'): string => {
if (align === 'center') return 'text-center';
if (align === 'right') return 'text-right';
return 'text-left';
};
const DataTable = <T extends object>({
columns,
data,
onRowClick,
stickyHeader = true,
emptyMessage = '데이터가 없습니다.',
className,
}: DataTableProps<T>) => {
return (
<div className={cn('w-full overflow-auto', className)}>
<table className='wing-table'>
<thead className={cn(stickyHeader && 'sticky top-0 z-10')}>
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
className={cn('wing-table-head', alignClass(col.align))}
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className='wing-table-cell text-center'
style={{ color: 'var(--t3)' }}
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, rowIdx) => (
<tr
key={rowIdx}
className={cn(onRowClick && 'wing-table-row')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => {
const rawValue = (row as Record<string, unknown>)[String(col.key)];
const cellContent = col.render
? col.render(rawValue, row)
: (rawValue as React.ReactNode);
return (
<td
key={String(col.key)}
className={cn('wing-table-cell', alignClass(col.align))}
style={col.width ? { width: col.width } : undefined}
>
{cellContent}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
);
};
export default DataTable;

파일 보기

@ -0,0 +1,73 @@
import { useEffect } from 'react';
import { cn } from '@common/utils/cn';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
footer?: React.ReactNode;
closeOnBackdrop?: boolean;
className?: string;
}
const Modal = ({
isOpen,
onClose,
title,
size = 'md',
children,
footer,
closeOnBackdrop = true,
className,
}: ModalProps) => {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleBackdropClick = () => {
if (closeOnBackdrop) onClose();
};
const handleModalClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
const sizeClass =
size === 'sm' ? 'wing-modal-sm' :
size === 'lg' ? 'wing-modal-lg' :
'wing-modal-md';
return (
<div className='wing-overlay' onClick={handleBackdropClick}>
<div
className={cn('wing-modal', 'flex flex-col', sizeClass, className)}
onClick={handleModalClick}
>
<div className='wing-modal-header'>
<span className='wing-title'>{title}</span>
<button className='wing-btn' onClick={onClose}>×</button>
</div>
<div className='wing-modal-body'>{children}</div>
{footer && <div className='wing-modal-footer'>{footer}</div>}
</div>
</div>
);
};
export default Modal;

파일 보기

@ -0,0 +1,123 @@
import { cn } from '@common/utils/cn';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
showFirstLast?: boolean;
className?: string;
}
const getPageNumbers = (currentPage: number, totalPages: number): (number | '...')[] => {
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const delta = 2;
const left = currentPage - delta;
const right = currentPage + delta;
if (left > 1) {
pages.push(1);
if (left > 2) pages.push('...');
}
for (let i = Math.max(1, left); i <= Math.min(totalPages, right); i++) {
pages.push(i);
}
if (right < totalPages) {
if (right < totalPages - 1) pages.push('...');
pages.push(totalPages);
}
return pages;
};
const Pagination = ({
currentPage,
totalPages,
onPageChange,
showFirstLast = true,
className,
}: PaginationProps) => {
if (totalPages <= 1) return null;
const isFirst = currentPage === 1;
const isLast = currentPage === totalPages;
const pageNumbers = getPageNumbers(currentPage, totalPages);
const navBtnClass = (disabled: boolean) =>
cn(
'wing-btn wing-btn-secondary',
disabled && 'opacity-40 cursor-not-allowed',
);
return (
<div className={cn('flex items-center justify-center gap-1', className)}>
{showFirstLast && (
<button
className={navBtnClass(isFirst)}
onClick={() => !isFirst && onPageChange(1)}
disabled={isFirst}
aria-label='첫 페이지'
>
«
</button>
)}
<button
className={navBtnClass(isFirst)}
onClick={() => !isFirst && onPageChange(currentPage - 1)}
disabled={isFirst}
aria-label='이전 페이지'
>
</button>
{pageNumbers.map((page, idx) =>
page === '...' ? (
<span
key={`ellipsis-${idx}`}
className='wing-btn wing-btn-secondary opacity-50 cursor-default'
>
</span>
) : (
<button
key={page}
className={cn(
'wing-btn',
page === currentPage ? 'wing-btn-primary' : 'wing-btn-secondary',
)}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
),
)}
<button
className={navBtnClass(isLast)}
onClick={() => !isLast && onPageChange(currentPage + 1)}
disabled={isLast}
aria-label='다음 페이지'
>
</button>
{showFirstLast && (
<button
className={navBtnClass(isLast)}
onClick={() => !isLast && onPageChange(totalPages)}
disabled={isLast}
aria-label='마지막 페이지'
>
»
</button>
)}
</div>
);
};
export default Pagination;

파일 보기

@ -0,0 +1,45 @@
import React from 'react';
import { cn } from '@common/utils/cn';
interface SidePanelProps {
position: 'left' | 'right';
width?: 'narrow' | 'default' | 'wide';
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
const WIDTH_MAP: Record<NonNullable<SidePanelProps['width']>, string> = {
narrow: 'var(--panel-narrow)',
default: 'var(--panel-default)',
wide: 'var(--panel-wide)',
};
const SidePanel = ({
position,
width = 'default',
header,
footer,
children,
className,
}: SidePanelProps) => {
const panelClass = position === 'left' ? 'wing-panel-left' : 'wing-panel-right';
const style: React.CSSProperties =
width !== 'default' ? { width: WIDTH_MAP[width] } : {};
return (
<div className={cn(panelClass, className)} style={style}>
{header && <div className='wing-header-bar'>{header}</div>}
<div className='wing-panel-scroll'>{children}</div>
{footer && (
<div className='flex flex-col gap-1.5 p-2.5 border-t border-border'>
{footer}
</div>
)}
</div>
);
};
export default SidePanel;

파일 보기

@ -23,6 +23,22 @@
--fM: JetBrains Mono, monospace; --fM: JetBrains Mono, monospace;
--rS: 6px; --rS: 6px;
--rM: 8px; --rM: 8px;
/* ── Z-index 스케일 ── */
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 1000;
--z-modal: 10000;
--z-toast: 10100;
/* ── Panel Width ── */
--panel-narrow: 280px;
--panel-default: 300px;
--panel-wide: 340px;
/* ── Transition ── */
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
} }
* { * {

파일 보기

@ -24,6 +24,7 @@
} }
/* ═══ Prediction Input Form ═══ */ /* ═══ Prediction Input Form ═══ */
/* @deprecated: wing-input 사용 권장 */
.prd-i { .prd-i {
width: 100%; width: 100%;
padding: 6px 10px; padding: 6px 10px;
@ -45,6 +46,7 @@
} }
/* Select Dropdown */ /* Select Dropdown */
/* @deprecated: wing-select 사용 권장 */
select.prd-i { select.prd-i {
cursor: pointer; cursor: pointer;
padding-right: 30px; padding-right: 30px;
@ -219,6 +221,7 @@
} }
/* ═══ Buttons ═══ */ /* ═══ Buttons ═══ */
/* @deprecated: wing-btn + wing-btn-primary/secondary 사용 권장 */
.prd-btn { .prd-btn {
width: 100%; width: 100%;
padding: 10px; padding: 10px;

파일 보기

@ -28,6 +28,11 @@
.wing-card { .wing-card {
@apply rounded-md p-4 border border-border; @apply rounded-md p-4 border border-border;
background: var(--bg3); background: var(--bg3);
transition: border-color var(--transition-fast);
}
.wing-card-hover:hover {
border-color: rgba(6, 182, 212, 0.4);
} }
.wing-card-sm { .wing-card-sm {
@ -83,7 +88,42 @@
/* ── Badge ── */ /* ── Badge ── */
.wing-badge { .wing-badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold font-korean; @apply inline-flex items-center px-2 py-0.5 rounded text-xs font-bold font-korean;
}
.wing-badge-neutral {
background: rgba(134, 144, 166, 0.15);
color: var(--t2);
}
.wing-badge-red {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
}
.wing-badge-blue {
background: rgba(59, 130, 246, 0.15);
color: var(--blue);
}
.wing-badge-green {
background: rgba(34, 197, 94, 0.15);
color: var(--green);
}
.wing-badge-yellow {
background: rgba(234, 179, 8, 0.15);
color: var(--yellow);
}
.wing-badge-purple {
background: rgba(168, 85, 247, 0.15);
color: var(--purple);
}
.wing-badge-cyan {
background: rgba(6, 182, 212, 0.15);
color: var(--cyan);
} }
/* ── Button ── */ /* ── Button ── */
@ -151,11 +191,11 @@
/* ── Table ── */ /* ── Table ── */
.wing-table { .wing-table {
@apply w-full text-[10px] font-korean; @apply w-full text-sm font-korean;
border-collapse: collapse; border-collapse: collapse;
} }
.wing-th { .wing-table-head {
@apply text-left font-semibold; @apply text-left font-semibold;
padding: 8px 10px; padding: 8px 10px;
color: var(--t3); color: var(--t3);
@ -163,17 +203,26 @@
border-bottom: 1px solid var(--bd); border-bottom: 1px solid var(--bd);
} }
.wing-td { .wing-table-cell {
padding: 8px 10px; padding: 8px 10px;
color: var(--t2); color: var(--t2);
border-bottom: 1px solid var(--bd); border-bottom: 1px solid var(--bd);
} }
.wing-tr-hover:hover { .wing-table-row {
transition: background 0.15s;
}
.wing-table-row:hover {
background: var(--bgH); background: var(--bgH);
cursor: pointer; cursor: pointer;
} }
/* 기존 alias (하위 호환) */
.wing-th { @apply text-left font-semibold; padding: 8px 10px; color: var(--t3); background: var(--bg2); border-bottom: 1px solid var(--bd); }
.wing-td { padding: 8px 10px; color: var(--t2); border-bottom: 1px solid var(--bd); }
.wing-tr-hover:hover { background: var(--bgH); cursor: pointer; }
/* ── Tab Bar ── */ /* ── Tab Bar ── */
.wing-tab-bar { .wing-tab-bar {
@apply flex gap-0.5 rounded-lg p-1 border border-border; @apply flex gap-0.5 rounded-lg p-1 border border-border;
@ -201,7 +250,7 @@
/* ── Modal ── */ /* ── Modal ── */
.wing-overlay { .wing-overlay {
@apply fixed inset-0 flex items-center justify-center; @apply fixed inset-0 flex items-center justify-center;
z-index: 10000; z-index: var(--z-modal);
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@ -219,6 +268,127 @@
padding-bottom: 14px; padding-bottom: 14px;
} }
/* ── Select ── */
.wing-select {
@apply w-full rounded-sm text-[11px] font-korean outline-none;
padding: 6px 10px;
padding-right: 30px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px;
transition: border-color 0.2s;
}
.wing-select:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.wing-select:focus {
border-color: var(--cyan);
}
.wing-select option {
background: #1a1f2e;
color: var(--t1);
padding: 10px;
font-size: 11px;
font-family: 'Noto Sans KR', sans-serif;
}
/* ── Textarea ── */
.wing-textarea {
@apply w-full rounded-sm text-[11px] font-korean outline-none;
padding: 8px 10px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
resize: vertical;
min-height: 80px;
}
.wing-textarea:focus {
border-color: var(--cyan);
}
.wing-textarea::placeholder {
color: var(--t3);
}
/* ── Search Input ── */
.wing-input-search {
@apply rounded-sm text-[11px] font-korean outline-none;
width: 256px;
padding: 6px 10px;
background: var(--bg0);
border: 1px solid var(--bd);
color: var(--t1);
}
.wing-input-search:focus {
border-color: var(--cyan);
}
.wing-input-search::placeholder {
color: var(--t3);
}
/* ── Side Panel ── */
.wing-panel-right {
@apply flex flex-col h-full overflow-hidden;
width: var(--panel-default);
border-left: 1px solid var(--bd);
background: var(--bg1);
}
.wing-panel-left {
@apply flex flex-col h-full overflow-hidden;
width: var(--panel-default);
border-right: 1px solid var(--bd);
background: var(--bg1);
}
/* ── Modal Size Variants ── */
.wing-modal-sm { width: 400px; max-height: 90vh; }
.wing-modal-md { width: 560px; max-height: 90vh; }
.wing-modal-lg { width: 720px; max-height: 90vh; }
/* ── Modal Body / Footer ── */
.wing-modal-body {
@apply flex-1 overflow-y-auto px-5 py-4;
scrollbar-width: thin;
scrollbar-color: var(--bdL) transparent;
}
.wing-modal-footer {
@apply flex justify-end gap-2 px-5 border-t border-border;
padding-top: 12px;
padding-bottom: 12px;
}
/* ── Info Row (wing-kv alias) ── */
.wing-info-row {
@apply flex items-center justify-between;
padding: 6px 0;
}
.wing-info-label {
@apply text-[10px] font-korean;
color: var(--t3);
}
.wing-info-value {
@apply text-[11px] font-semibold font-mono;
}
/* ── Utility ── */ /* ── Utility ── */
.wing-divider { .wing-divider {
@apply w-full; @apply w-full;

파일 보기

@ -1 +1 @@
export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'; export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' | 'showcase';

파일 보기

@ -184,7 +184,7 @@ export function MediaManagement() {
<select <select
value={sortBy} value={sortBy}
onChange={e => setSortBy(e.target.value)} onChange={e => setSortBy(e.target.value)}
className="prd-i py-1.5 w-auto" className="wing-select py-1.5 w-auto"
> >
<option value="latest"></option> <option value="latest"></option>
<option value="name"></option> <option value="name"></option>
@ -370,7 +370,7 @@ export function MediaManagement() {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label> <label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full"> <select className="wing-select w-full">
<option> (DJI M300 RTK)</option> <option> (DJI M300 RTK)</option>
<option> (DJI Mavic 3E)</option> <option> (DJI Mavic 3E)</option>
<option> (CN-235)</option> <option> (CN-235)</option>
@ -382,7 +382,7 @@ export function MediaManagement() {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label> <label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full"> <select className="wing-select w-full">
<option> (2026-01-18)</option> <option> (2026-01-18)</option>
<option> (2026-01-18)</option> <option> (2026-01-18)</option>
<option> (2026-01-18)</option> <option> (2026-01-18)</option>
@ -391,7 +391,7 @@ export function MediaManagement() {
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"></label> <label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"></label>
<textarea <textarea
className="prd-i w-full h-[60px] resize-y" className="wing-input w-full h-[60px] resize-y"
placeholder="촬영 조건, 비고 등..." placeholder="촬영 조건, 비고 등..."
/> />
</div> </div>

파일 보기

@ -116,9 +116,9 @@ function AssetManagement() {
placeholder="기관명 검색..." placeholder="기관명 검색..."
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="prd-i w-40 py-1.5 px-2.5" className="wing-input w-40 py-1.5 px-2.5"
/> />
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2"> <select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option> <option value="all"> </option>
<option value="남해"></option> <option value="남해"></option>
<option value="서해"></option> <option value="서해"></option>
@ -126,7 +126,7 @@ function AssetManagement() {
<option value="동해"></option> <option value="동해"></option>
<option value="제주"></option> <option value="제주"></option>
</select> </select>
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2"> <select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option> <option value="all"> </option>
<option value="해경관할"></option> <option value="해경관할"></option>
<option value="해경경찰서"></option> <option value="해경경찰서"></option>
@ -140,7 +140,7 @@ function AssetManagement() {
<option value="해군"></option> <option value="해군"></option>
<option value="기타"></option> <option value="기타"></option>
</select> </select>
<select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2"> <select value={equipFilter} onChange={e => setEquipFilter(e.target.value)} className="wing-select w-[100px] py-1.5 px-2">
<option value="all"> </option> <option value="all"> </option>
<option value="vessel"></option> <option value="vessel"></option>
<option value="skimmer"></option> <option value="skimmer"></option>

파일 보기

@ -37,7 +37,7 @@ function AssetUpload() {
{/* Asset Classification */} {/* Asset Classification */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label> <label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full"> <select className="wing-select w-full">
<option></option> <option></option>
<option></option> <option></option>
<option></option> <option></option>
@ -50,7 +50,7 @@ function AssetUpload() {
{/* Jurisdiction */} {/* Jurisdiction */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label> <label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean"> </label>
<select className="prd-i w-full"> <select className="wing-select w-full">
<option> - </option> <option> - </option>
<option> - </option> <option> - </option>
<option> - </option> <option> - </option>

파일 보기

@ -173,7 +173,7 @@ function ShipInsurance() {
</div> </div>
<div> <div>
<label className="block text-[10px] font-semibold text-text-3 mb-1"></label> <label className="block text-[10px] font-semibold text-text-3 mb-1"></label>
<select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="prd-i min-w-[120px] border-border"> <select value={shipTpFilter} onChange={e => setShipTpFilter(e.target.value)} className="wing-select min-w-[120px] border-border">
<option value=""></option> <option value=""></option>
<option value="일반선박"></option> <option value="일반선박"></option>
<option value="유조선"></option> <option value="유조선"></option>
@ -181,7 +181,7 @@ function ShipInsurance() {
</div> </div>
<div> <div>
<label className="block text-[10px] font-semibold text-text-3 mb-1"></label> <label className="block text-[10px] font-semibold text-text-3 mb-1"></label>
<select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="prd-i min-w-[160px] border-border"> <select value={issueOrgFilter} onChange={e => setIssueOrgFilter(e.target.value)} className="wing-select min-w-[160px] border-border">
<option value=""></option> <option value=""></option>
<option></option> <option></option>
<option></option> <option></option>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuthStore } from '@common/store/authStore' import { useAuthStore } from '@common/store/authStore'
import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi' import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
import Badge from '@common/components/ui/Badge'
// 카테고리 코드 → 표시명 // 카테고리 코드 → 표시명
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
@ -10,14 +11,6 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼', MANUAL: '해경매뉴얼',
} }
// 카테고리별 배지 색상
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
}
interface BoardDetailViewProps { interface BoardDetailViewProps {
postSn: number postSn: number
onBack: () => void onBack: () => void
@ -64,10 +57,10 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
return ( return (
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="wing-header-bar">
<button <button
onClick={onBack} onClick={onBack}
className="flex items-center gap-2 text-sm font-semibold text-text-2 hover:text-text-1 transition-colors" className="wing-btn wing-btn-secondary flex items-center gap-2"
> >
<span></span> <span></span>
<span></span> <span></span>
@ -76,13 +69,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={onEdit} onClick={onEdit}
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors" className="wing-btn wing-btn-secondary"
> >
</button> </button>
<button <button
onClick={onDelete} onClick={onDelete}
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors" className="wing-btn bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
> >
</button> </button>
@ -96,13 +89,9 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
{/* 게시글 헤더 */} {/* 게시글 헤더 */}
<div className="pb-6 border-b border-border"> <div className="pb-6 border-b border-border">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}> <Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
{post.pinnedYn === 'Y' && ( {post.pinnedYn === 'Y' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400"> <Badge color="yellow">📌 </Badge>
📌
</span>
)} )}
</div> </div>
<h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1> <h1 className="text-2xl font-bold text-text-1 mb-4">{post.title}</h1>

파일 보기

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAuthStore } from '@common/store/authStore'; import { useAuthStore } from '@common/store/authStore';
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi'; import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
import Badge from '@common/components/ui/Badge';
// 카테고리 코드 ↔ 표시명 매핑 // 카테고리 코드 ↔ 표시명 매핑
const CATEGORY_MAP: Record<string, string> = { const CATEGORY_MAP: Record<string, string> = {
@ -17,13 +18,6 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
{ label: 'Q&A', code: 'QNA' }, { label: 'Q&A', code: 'QNA' },
]; ];
const CATEGORY_STYLE: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-blue-500/20 text-blue-400',
QNA: 'bg-green-500/20 text-green-400',
MANUAL: 'bg-yellow-500/20 text-yellow-400',
};
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
interface BoardListTableProps { interface BoardListTableProps {
@ -97,7 +91,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
return ( return (
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* Header with Search and Write Button */} {/* Header with Search and Write Button */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="wing-header-bar">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex gap-2"> <div className="flex gap-2">
{CATEGORY_FILTER.map((cat) => ( {CATEGORY_FILTER.map((cat) => (
@ -123,13 +117,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64" className="wing-input-search"
/> />
{canWrite && ( {canWrite && (
<button <button
onClick={onWriteClick} onClick={onWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity flex items-center gap-2" className="wing-btn wing-btn-primary flex items-center gap-2"
> >
<span>+</span> <span>+</span>
<span></span> <span></span>
@ -146,15 +140,15 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
</div> </div>
) : ( ) : (
<> <>
<table className="w-full border-collapse"> <table className="wing-table">
<thead> <thead>
<tr className="border-b-2 border-border"> <tr className="border-b-2 border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-20"></th> <th className="wing-table-head w-20"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th> <th className="wing-table-head w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th> <th className="wing-table-head"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th> <th className="wing-table-head w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32"></th> <th className="wing-table-head w-32"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-24"></th> <th className="wing-table-head w-24"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -162,27 +156,19 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<tr <tr
key={post.sn} key={post.sn}
onClick={() => onPostClick(post.sn)} onClick={() => onPostClick(post.sn)}
className="border-b border-border hover:bg-bg-2 cursor-pointer transition-colors" className="wing-table-row"
> >
<td className="px-4 py-4 text-sm text-text-1"> <td className="wing-table-cell text-text-1">
{post.pinnedYn === 'Y' ? ( {post.pinnedYn === 'Y' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400"> <Badge color="cyan"></Badge>
</span>
) : ( ) : (
post.sn post.sn
)} )}
</td> </td>
<td className="px-4 py-4"> <td className="wing-table-cell">
<span <Badge>{CATEGORY_MAP[post.categoryCd] || post.categoryCd}</Badge>
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
}`}
>
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
</span>
</td> </td>
<td className="px-4 py-4"> <td className="wing-table-cell">
<span <span
className={`text-sm ${ className={`text-sm ${
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1' post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
@ -191,9 +177,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
{post.title} {post.title}
</span> </span>
</td> </td>
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td> <td className="wing-table-cell">{post.authorName}</td>
<td className="px-4 py-4 text-sm text-text-3">{formatDate(post.regDtm)}</td> <td className="wing-table-cell text-text-3">{formatDate(post.regDtm)}</td>
<td className="px-4 py-4 text-sm text-text-3">{post.viewCnt}</td> <td className="wing-table-cell text-text-3">{post.viewCnt}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -214,7 +200,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button <button
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40" className="wing-btn wing-btn-secondary disabled:opacity-40"
> >
</button> </button>
@ -222,10 +208,10 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button <button
key={p} key={p}
onClick={() => setPage(p)} onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded ${ className={`wing-btn ${
page === p page === p
? 'bg-primary-cyan text-bg-0 font-semibold' ? 'wing-btn-primary font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors' : 'wing-btn-secondary'
}`} }`}
> >
{p} {p}
@ -234,7 +220,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
<button <button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
className="px-3 py-1.5 text-sm rounded bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors disabled:opacity-40" className="wing-btn wing-btn-secondary disabled:opacity-40"
> >
</button> </button>

파일 보기

@ -3,6 +3,8 @@ import { useSubMenu } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore' import { useAuthStore } from '@common/store/authStore'
import { BoardWriteForm } from './BoardWriteForm' import { BoardWriteForm } from './BoardWriteForm'
import { BoardDetailView } from './BoardDetailView' import { BoardDetailView } from './BoardDetailView'
import Badge from '@common/components/ui/Badge'
import Card from '@common/components/ui/Card'
import { import {
fetchBoardPosts, fetchBoardPosts,
deleteBoardPost, deleteBoardPost,
@ -33,14 +35,6 @@ const CATEGORY_LABELS: Record<string, string> = {
MANUAL: '해경매뉴얼', MANUAL: '해경매뉴얼',
} }
// 카테고리별 배지 색상
const CATEGORY_COLORS: Record<string, string> = {
NOTICE: 'bg-red-500/20 text-red-400',
DATA: 'bg-green-500/20 text-green-400',
QNA: 'bg-purple-500/20 text-purple-400',
MANUAL: 'bg-blue-500/20 text-blue-400',
}
const PAGE_SIZE = 20 const PAGE_SIZE = 20
export function BoardView() { export function BoardView() {
@ -190,15 +184,6 @@ export function BoardView() {
const filteredManuals = manualList const filteredManuals = manualList
const catColor = (cat: string) => {
switch (cat) {
case '방제매뉴얼': return { bg: 'rgba(6,182,212,.15)', text: '#22d3ee' }
case '대응매뉴얼': return { bg: 'rgba(249,115,22,.15)', text: '#f97316' }
case '교육자료': return { bg: 'rgba(34,197,94,.15)', text: '#22c55e' }
case '법령·규정': return { bg: 'rgba(168,85,247,.15)', text: '#a855f7' }
default: return { bg: 'rgba(100,100,100,.15)', text: '#999' }
}
}
if (activeSubTab === 'manual') { if (activeSubTab === 'manual') {
return ( return (
@ -206,7 +191,7 @@ export function BoardView() {
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b" style={{ borderColor: 'var(--bd)', background: 'var(--bg1)' }}> <div className="wing-header-bar">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg">📘</span> <span className="text-lg">📘</span>
@ -216,12 +201,11 @@ export function BoardView() {
<div className="flex gap-1 ml-4"> <div className="flex gap-1 ml-4">
{manualCategories.map(cat => ( {manualCategories.map(cat => (
<button key={cat} onClick={() => setManualCategory(cat)} <button key={cat} onClick={() => setManualCategory(cat)}
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all" className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all border ${
style={{ manualCategory === cat
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg3)', ? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--bd)', : 'bg-bg-3 border-border text-text-3'
color: manualCategory === cat ? 'var(--cyan)' : 'var(--t3)', }`}>
}}>
{cat} {cat}
</button> </button>
))} ))}
@ -229,10 +213,9 @@ export function BoardView() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)} <input type="text" placeholder="매뉴얼 검색..." value={manualSearch} onChange={e => setManualSearch(e.target.value)}
className="px-4 py-2 text-sm rounded w-64" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', outline: 'none' }} /> className="wing-input-search" />
<button onClick={() => setShowUploadModal(true)} <button onClick={() => setShowUploadModal(true)}
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5" className="wing-btn wing-btn-primary flex items-center gap-1.5">
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
📤 📤
</button> </button>
</div> </div>
@ -246,33 +229,18 @@ export function BoardView() {
</div> </div>
) : ( ) : (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}> <div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
{filteredManuals.map(file => { {filteredManuals.map(file => (
const cc = catColor(file.catgNm) <Card key={file.manualSn}>
return (
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
background: 'var(--bg3)', border: '1px solid var(--bd)',
cursor: 'pointer',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(6,182,212,.4)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--bd)' }}
>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}> <Badge>{file.catgNm}</Badge>
{file.catgNm} <Badge>{file.version}</Badge>
</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
{file.version}
</span>
</div> </div>
<div className="text-[12px] font-bold mb-3 leading-[1.5]"> <div className="text-[12px] font-bold mb-3 leading-[1.5]">
{file.title} {file.title}
</div> </div>
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}> <Badge>📄 {file.fileTp || 'PDF'}</Badge>
<span style={{ fontSize: 12 }}>📄</span> <span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
</div> </div>
<div className="flex items-center justify-end gap-1 mb-2"> <div className="flex items-center justify-end gap-1 mb-2">
<button onClick={(e) => { <button onClick={(e) => {
@ -287,8 +255,7 @@ export function BoardView() {
}) })
setShowUploadModal(true) setShowUploadModal(true)
}} }}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all" className="wing-btn wing-btn-secondary text-[10px]"
style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)', color: '#3b82f6', cursor: 'pointer' }}
title="수정"> title="수정">
</button> </button>
@ -303,19 +270,18 @@ export function BoardView() {
} }
} }
}} }}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all" className="wing-btn text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: '#ef4444', cursor: 'pointer' }}
title="삭제"> title="삭제">
🗑 🗑
</button> </button>
</div> </div>
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}> <div className="flex items-center justify-between pt-3 border-t border-border">
<div className="flex items-center gap-3 text-[10px] text-text-3"> <div className="flex items-center gap-3 text-[10px] text-text-3">
<span>{file.authorNm}</span> <span>{file.authorNm}</span>
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span> <span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}> <span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
{file.dwnldCnt} {file.dwnldCnt}
</span> </span>
<button onClick={async (e) => { <button onClick={async (e) => {
@ -350,17 +316,13 @@ export function BoardView() {
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}} }}
className="px-3 py-1 rounded text-[10px] font-semibold transition-all" style={{ className="wing-btn wing-btn-primary text-[10px]">
background: 'rgba(6,182,212,.1)', border: '1px solid rgba(6,182,212,.25)',
color: '#22d3ee', cursor: 'pointer',
}}>
📥 📥
</button> </button>
</div> </div>
</div> </div>
</div> </Card>
) ))}
})}
</div> </div>
)} )}
@ -392,16 +354,14 @@ export function BoardView() {
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label> <label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => { {['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
const cc = catColor(cat)
const isActive = uploadForm.category === cat const isActive = uploadForm.category === cat
return ( return (
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))} <button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer" className={`flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer border ${
style={{ isActive
background: isActive ? cc.bg : 'var(--bg3)', ? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--bd)', : 'bg-bg-3 border-border text-text-3'
color: isActive ? cc.text : 'var(--t3)', }`}>
}}>
{cat} {cat}
</button> </button>
) )
@ -412,13 +372,13 @@ export function BoardView() {
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label> <label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label>
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title} <input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))} onChange={e => setUploadForm(prev => ({ ...prev, title: e.target.value }))}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} /> className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
</div> </div>
<div> <div>
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label> <label className="block text-[11px] font-semibold text-text-2 mb-1.5"></label>
<input type="text" placeholder="예: v1.0" value={uploadForm.version} <input type="text" placeholder="예: v1.0" value={uploadForm.version}
onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))} onChange={e => setUploadForm(prev => ({ ...prev, version: e.target.value }))}
className="w-full px-3 py-2.5 rounded-md text-xs bg-bg-2 border border-border outline-none" style={{ boxSizing: 'border-box' }} /> className="wing-input w-full" style={{ boxSizing: 'border-box' }} />
</div> </div>
<div> <div>
<label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label> <label className="block text-[11px] font-semibold text-text-2 mb-1.5"> </label>
@ -458,7 +418,7 @@ export function BoardView() {
</div> </div>
<div className="px-5 py-3 border-t border-border flex justify-end gap-2"> <div className="px-5 py-3 border-t border-border flex justify-end gap-2">
<button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }} <button onClick={() => { setShowUploadModal(false); setEditingManualId(null) }}
className="px-5 py-2 rounded-md text-xs font-semibold bg-bg-3 border border-border text-text-3 cursor-pointer"> className="wing-btn wing-btn-secondary">
</button> </button>
<button onClick={async () => { <button onClick={async () => {
@ -491,7 +451,7 @@ export function BoardView() {
alert((err as { message?: string })?.message || '저장에 실패했습니다.') alert((err as { message?: string })?.message || '저장에 실패했습니다.')
} }
}} }}
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer" style={{ background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee' }}> className="wing-btn wing-btn-primary px-6 py-2">
{editingManualId ? '✏️ 수정' : '📤 업로드'} {editingManualId ? '✏️ 수정' : '📤 업로드'}
</button> </button>
</div> </div>
@ -547,7 +507,7 @@ export function BoardView() {
<div className="flex-1 relative overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="wing-header-bar">
<div className="text-sm text-text-3"> <div className="text-sm text-text-3">
<span className="text-text-1 font-semibold">{totalCount}</span> <span className="text-text-1 font-semibold">{totalCount}</span>
</div> </div>
@ -558,12 +518,12 @@ export function BoardView() {
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleSearchKeyDown} onKeyDown={handleSearchKeyDown}
className="px-4 py-2 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none w-64" className="wing-input-search"
/> />
{hasPermission(getWriteResource(), 'CREATE') && ( {hasPermission(getWriteResource(), 'CREATE') && (
<button <button
onClick={handleWriteClick} onClick={handleWriteClick}
className="px-6 py-2 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity" className="wing-btn wing-btn-primary"
> >
</button> </button>
@ -579,42 +539,40 @@ export function BoardView() {
</div> </div>
) : ( ) : (
<> <>
<table className="w-full border-collapse"> <table className="wing-table">
<thead> <thead>
<tr className="border-b-2 border-border"> <tr className="border-b-2 border-border">
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16"></th> <th className="wing-table-head text-center w-16"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24"></th> <th className="wing-table-head text-center w-24"></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2"></th> <th className="wing-table-head"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24"></th> <th className="wing-table-head text-center w-24"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-28"></th> <th className="wing-table-head text-center w-28"></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-16"></th> <th className="wing-table-head text-center w-16"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{posts.map((post) => ( {posts.map((post) => (
<tr <tr
key={post.sn} key={post.sn}
className="border-b border-border hover:bg-bg-2 transition-colors" className="wing-table-row"
> >
<td className="px-4 py-4 text-sm text-text-1 text-center">{post.sn}</td> <td className="wing-table-cell text-text-1 text-center">{post.sn}</td>
<td className="px-4 py-4 text-center"> <td className="wing-table-cell text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}> <Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
</span>
</td> </td>
<td <td
className="px-4 py-4 cursor-pointer" className="wing-table-cell cursor-pointer"
onClick={() => handlePostClick(post.sn)} onClick={() => handlePostClick(post.sn)}
> >
<span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}> <span className={`text-sm ${post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'} hover:text-primary-cyan transition-colors`}>
{post.pinnedYn === 'Y' && '📌 '}{post.title} {post.pinnedYn === 'Y' && '📌 '}{post.title}
</span> </span>
</td> </td>
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td> <td className="wing-table-cell text-center">{post.authorName}</td>
<td className="px-4 py-4 text-sm text-text-3 text-center"> <td className="wing-table-cell text-text-3 text-center">
{new Date(post.regDtm).toLocaleDateString('ko-KR')} {new Date(post.regDtm).toLocaleDateString('ko-KR')}
</td> </td>
<td className="px-4 py-4 text-sm text-text-3 text-center">{post.viewCnt}</td> <td className="wing-table-cell text-text-3 text-center">{post.viewCnt}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -636,10 +594,10 @@ export function BoardView() {
<button <button
key={p} key={p}
onClick={() => setPage(p)} onClick={() => setPage(p)}
className={`px-3 py-1.5 text-sm rounded transition-colors ${ className={`wing-btn ${
p === page p === page
? 'bg-primary-cyan/20 text-primary-cyan font-semibold' ? 'wing-btn-primary font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1' : 'wing-btn-secondary'
}`} }`}
> >
{p} {p}

파일 보기

@ -121,7 +121,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
return ( return (
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-8 py-4 border-b border-border bg-bg-1"> <div className="wing-header-bar">
<h2 className="text-lg font-semibold text-text-1"> <h2 className="text-lg font-semibold text-text-1">
{isEditMode ? '게시글 수정' : '게시글 작성'} {isEditMode ? '게시글 수정' : '게시글 작성'}
</h2> </h2>
@ -140,7 +140,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
value={categoryCd} value={categoryCd}
onChange={(e) => setCategoryCd(e.target.value)} onChange={(e) => setCategoryCd(e.target.value)}
disabled={isEditMode} disabled={isEditMode}
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 focus:border-primary-cyan focus:outline-none disabled:opacity-50" className="wing-select w-full disabled:opacity-50"
> >
{CATEGORY_OPTIONS.map(opt => ( {CATEGORY_OPTIONS.map(opt => (
<option key={opt.code} value={opt.code}>{opt.label}</option> <option key={opt.code} value={opt.code}>{opt.label}</option>
@ -159,7 +159,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
maxLength={200} maxLength={200}
placeholder="제목을 입력하세요" placeholder="제목을 입력하세요"
className="w-full px-4 py-2.5 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none" className="wing-input w-full"
/> />
</div> </div>
@ -174,7 +174,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
maxLength={10000} maxLength={10000}
placeholder="내용을 입력하세요" placeholder="내용을 입력하세요"
rows={15} rows={15}
className="w-full px-4 py-3 text-sm bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none resize-none" className="wing-textarea w-full"
/> />
</div> </div>
@ -192,7 +192,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
/> />
<label <label
htmlFor="file-upload" htmlFor="file-upload"
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 cursor-pointer transition-colors" className="wing-btn wing-btn-secondary cursor-pointer"
> >
</label> </label>
@ -207,14 +207,14 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="px-6 py-2.5 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors" className="wing-btn wing-btn-secondary"
> >
</button> </button>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="px-6 py-2.5 text-sm font-semibold rounded bg-primary-cyan text-bg-0 hover:opacity-90 transition-opacity disabled:opacity-50" className="wing-btn wing-btn-primary disabled:opacity-50"
> >
{isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'} {isLoading ? '저장 중...' : isEditMode ? '수정하기' : '등록하기'}
</button> </button>

파일 보기

@ -229,7 +229,7 @@ export function HNSLeftPanel({
{/* 사고명 직접 입력 */} {/* 사고명 직접 입력 */}
<input <input
className="prd-i w-full" className="wing-input w-full"
value={accidentName} value={accidentName}
onChange={(e) => setAccidentName(e.target.value)} onChange={(e) => setAccidentName(e.target.value)}
placeholder="사고명 직접 입력" placeholder="사고명 직접 입력"
@ -237,7 +237,7 @@ export function HNSLeftPanel({
{/* 또는 사고 리스트에서 선택 */} {/* 또는 사고 리스트에서 선택 */}
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={selectedIncidentSn} value={selectedIncidentSn}
onChange={handleSelectIncident} onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택" placeholder="또는 사고 리스트에서 선택"
@ -252,13 +252,13 @@ export function HNSLeftPanel({
<label className="text-[10px] text-text-3 block mb-0.5"> </label> <label className="text-[10px] text-text-3 block mb-0.5"> </label>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<input <input
className="prd-i" className="wing-input"
type="date" type="date"
value={accidentDate} value={accidentDate}
onChange={(e) => setAccidentDate(e.target.value)} onChange={(e) => setAccidentDate(e.target.value)}
/> />
<input <input
className="prd-i" className="wing-input"
type="time" type="time"
value={accidentTime} value={accidentTime}
onChange={(e) => setAccidentTime(e.target.value)} onChange={(e) => setAccidentTime(e.target.value)}
@ -269,7 +269,7 @@ export function HNSLeftPanel({
{/* 좌표 + 지도 버튼 */} {/* 좌표 + 지도 버튼 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}> <div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input <input
className="prd-i flex-1 font-mono" className="wing-input flex-1 font-mono"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord?.lat.toFixed(4) ?? ''} value={incidentCoord?.lat.toFixed(4) ?? ''}
@ -280,7 +280,7 @@ export function HNSLeftPanel({
}} }}
/> />
<input <input
className="prd-i flex-1 font-mono" className="wing-input flex-1 font-mono"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord?.lon.toFixed(4) ?? ''} value={incidentCoord?.lon.toFixed(4) ?? ''}
@ -304,7 +304,7 @@ export function HNSLeftPanel({
{/* 유출형태 + 물질명 */} {/* 유출형태 + 물질명 */}
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={releaseType} value={releaseType}
onChange={(v) => setReleaseType(v as ReleaseType)} onChange={(v) => setReleaseType(v as ReleaseType)}
options={[ options={[
@ -314,7 +314,7 @@ export function HNSLeftPanel({
]} ]}
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={substance} value={substance}
onChange={handleSubstanceChange} onChange={handleSubstanceChange}
options={[ options={[
@ -335,14 +335,14 @@ export function HNSLeftPanel({
{/* 유출량 + 단위 + 예측시간 */} {/* 유출량 + 단위 + 예측시간 */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}> <div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
<input <input
className="prd-i font-mono" className="wing-input font-mono"
type="number" type="number"
value={releaseType === '순간 유출' ? totalRelease : emissionRate} value={releaseType === '순간 유출' ? totalRelease : emissionRate}
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)} onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'} placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={releaseType === '순간 유출' ? 'g' : 'g/s'} value={releaseType === '순간 유출' ? 'g' : 'g/s'}
onChange={() => {}} onChange={() => {}}
options={ options={
@ -352,7 +352,7 @@ export function HNSLeftPanel({
} }
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={predictionTime} value={predictionTime}
onChange={setPredictionTime} onChange={setPredictionTime}
options={[ options={[
@ -371,7 +371,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> </label> <label className="text-[10px] text-text-3 block mb-0.5"> </label>
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={algorithm} value={algorithm}
onChange={setAlgorithm} onChange={setAlgorithm}
options={[ options={[
@ -385,7 +385,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> </label> <label className="text-[10px] text-text-3 block mb-0.5"> </label>
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={criteriaModel} value={criteriaModel}
onChange={setCriteriaModel} onChange={setCriteriaModel}
options={[ options={[
@ -419,7 +419,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={emissionRate} value={emissionRate}
onChange={(e) => setEmissionRate(e.target.value)} onChange={(e) => setEmissionRate(e.target.value)}
@ -430,7 +430,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (s)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (s)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={releaseDuration} value={releaseDuration}
onChange={(e) => setReleaseDuration(e.target.value)} onChange={(e) => setReleaseDuration(e.target.value)}
@ -442,7 +442,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={releaseHeight} value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)} onChange={(e) => setReleaseHeight(e.target.value)}
@ -459,7 +459,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (g)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={totalRelease} value={totalRelease}
onChange={(e) => setTotalRelease(e.target.value)} onChange={(e) => setTotalRelease(e.target.value)}
@ -470,7 +470,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={releaseHeight} value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)} onChange={(e) => setReleaseHeight(e.target.value)}
@ -488,7 +488,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (g/s)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={emissionRate} value={emissionRate}
onChange={(e) => setEmissionRate(e.target.value)} onChange={(e) => setEmissionRate(e.target.value)}
@ -499,7 +499,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={poolRadius} value={poolRadius}
onChange={(e) => setPoolRadius(e.target.value)} onChange={(e) => setPoolRadius(e.target.value)}
@ -511,7 +511,7 @@ export function HNSLeftPanel({
<div> <div>
<label className="text-[10px] text-text-3 block mb-0.5"> (m)</label> <label className="text-[10px] text-text-3 block mb-0.5"> (m)</label>
<input <input
className="prd-i w-full font-mono" className="wing-input w-full font-mono"
type="number" type="number"
value={releaseHeight} value={releaseHeight}
onChange={(e) => setReleaseHeight(e.target.value)} onChange={(e) => setReleaseHeight(e.target.value)}
@ -588,7 +588,7 @@ export function HNSLeftPanel({
{/* 실행 버튼 */} {/* 실행 버튼 */}
<div className="flex flex-col gap-1 mt-2"> <div className="flex flex-col gap-1 mt-2">
<button <button
className="prd-btn pri" className="wing-btn wing-btn-primary"
style={{ padding: '7px', fontSize: '11px' }} style={{ padding: '7px', fontSize: '11px' }}
onClick={onRunPrediction} onClick={onRunPrediction}
disabled={isRunningPrediction} disabled={isRunningPrediction}
@ -596,7 +596,7 @@ export function HNSLeftPanel({
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'} {isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
</button> </button>
<button <button
className="prd-btn sec" className="wing-btn wing-btn-secondary"
style={{ padding: '7px', fontSize: '11px' }} style={{ padding: '7px', fontSize: '11px' }}
onClick={handleReset} onClick={handleReset}
> >

파일 보기

@ -109,7 +109,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
{/* HNS 물질 */} {/* HNS 물질 */}
<FG label="HNS 물질"> <FG label="HNS 물질">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={substance} value={substance}
onChange={setSubstance} onChange={setSubstance}
options={[ options={[
@ -127,7 +127,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
<div className="grid grid-cols-2 gap-[10px]"> <div className="grid grid-cols-2 gap-[10px]">
<FG label="유출 유형"> <FG label="유출 유형">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={releaseType} value={releaseType}
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])} onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
options={[ options={[
@ -139,7 +139,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
</FG> </FG>
<FG label={`${amountLabel} (${amountUnit})`}> <FG label={`${amountLabel} (${amountUnit})`}>
<input <input
className="prd-i font-mono" className="wing-input font-mono"
type="number" type="number"
value={amount} value={amount}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
@ -152,7 +152,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
<div className="grid grid-cols-2 gap-[10px]"> <div className="grid grid-cols-2 gap-[10px]">
<FG label="예측 알고리즘"> <FG label="예측 알고리즘">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={algorithm} value={algorithm}
onChange={setAlgorithm} onChange={setAlgorithm}
options={[ options={[
@ -165,7 +165,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
</FG> </FG>
<FG label="예측 시간"> <FG label="예측 시간">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={predictionTime} value={predictionTime}
onChange={setPredictionTime} onChange={setPredictionTime}
options={[ options={[

파일 보기

@ -148,7 +148,7 @@ export function HNSScenarioView() {
<select <select
value={selectedIncident} value={selectedIncident}
onChange={(e) => setSelectedIncident(Number(e.target.value))} onChange={(e) => setSelectedIncident(Number(e.target.value))}
className="prd-i w-[280px] text-[11px]" className="wing-select w-[280px] text-[11px]"
> >
{incidents.length === 0 {incidents.length === 0
? <option value={0}> </option> ? <option value={0}> </option>
@ -708,16 +708,16 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
{/* 기본 정보 */} {/* 기본 정보 */}
<ModalSection title="기본 정보"> <ModalSection title="기본 정보">
<ModalField label="시나리오명"> <ModalField label="시나리오명">
<input className="prd-i" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" /> <input className="wing-input" value={name} onChange={e => setName(e.target.value)} placeholder="예: 풍향 변화 시나리오" />
</ModalField> </ModalField>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<ModalField label="시간 단계"> <ModalField label="시간 단계">
<select className="prd-i" value={timeStep} onChange={e => setTimeStep(e.target.value)}> <select className="wing-select" value={timeStep} onChange={e => setTimeStep(e.target.value)}>
{['T+0h', 'T+1h', 'T+3h', 'T+6h', 'T+12h', 'T+24h'].map(t => <option key={t} value={t}>{t}</option>)} {['T+0h', 'T+1h', 'T+3h', 'T+6h', 'T+12h', 'T+24h'].map(t => <option key={t} value={t}>{t}</option>)}
</select> </select>
</ModalField> </ModalField>
<ModalField label="기준 시각"> <ModalField label="기준 시각">
<input className="prd-i" type="text" defaultValue="2024-11-03 08:00" /> <input className="wing-input" type="text" defaultValue="2024-11-03 08:00" />
</ModalField> </ModalField>
</div> </div>
</ModalSection> </ModalSection>
@ -725,7 +725,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
{/* 물질·유출 조건 */} {/* 물질·유출 조건 */}
<ModalSection title="물질 · 유출 조건"> <ModalSection title="물질 · 유출 조건">
<ModalField label="HNS 물질"> <ModalField label="HNS 물질">
<select className="prd-i" value={material} onChange={e => setMaterial(e.target.value)}> <select className="wing-select" value={material} onChange={e => setMaterial(e.target.value)}>
{MATERIALS.map(m => <option key={m.key} value={m.key}>{m.name} ({m.key})</option>)} {MATERIALS.map(m => <option key={m.key} value={m.key}>{m.name} ({m.key})</option>)}
</select> </select>
</ModalField> </ModalField>
@ -750,7 +750,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<ModalField label="유출 유형"> <ModalField label="유출 유형">
<select className="prd-i" value={releaseType} onChange={e => setReleaseType(e.target.value)}> <select className="wing-select" value={releaseType} onChange={e => setReleaseType(e.target.value)}>
<option value="instant"> </option> <option value="instant"> </option>
<option value="continuous"> </option> <option value="continuous"> </option>
<option value="semi"></option> <option value="semi"></option>
@ -758,8 +758,8 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
</ModalField> </ModalField>
<ModalField label="유출량"> <ModalField label="유출량">
<div className="flex gap-1"> <div className="flex gap-1">
<input className="prd-i flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} /> <input className="wing-input flex-1" type="number" value={amount} onChange={e => setAmount(e.target.value)} />
<select className="prd-i w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}> <select className="wing-select w-[60px]" value={unit} onChange={e => setUnit(e.target.value)}>
{['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)} {['t', 'kg', 'm³', 'L'].map(u => <option key={u} value={u}>{u}</option>)}
</select> </select>
</div> </div>
@ -771,31 +771,31 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
<ModalSection title="기상 조건"> <ModalSection title="기상 조건">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<ModalField label="풍향"> <ModalField label="풍향">
<select className="prd-i" value={windDir} onChange={e => setWindDir(e.target.value)}> <select className="wing-select" value={windDir} onChange={e => setWindDir(e.target.value)}>
{['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)} {['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'].map(d => <option key={d} value={d}>{d}</option>)}
</select> </select>
</ModalField> </ModalField>
<ModalField label="풍속 (m/s)"> <ModalField label="풍속 (m/s)">
<input className="prd-i" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} /> <input className="wing-input" type="number" value={windSpeed} onChange={e => setWindSpeed(e.target.value)} step={0.1} />
</ModalField> </ModalField>
<ModalField label="기온 (°C)"> <ModalField label="기온 (°C)">
<input className="prd-i" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} /> <input className="wing-input" type="number" value={temp} onChange={e => setTemp(e.target.value)} step={0.1} />
</ModalField> </ModalField>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<ModalField label="대기안정도 (Pasquill)"> <ModalField label="대기안정도 (Pasquill)">
<select className="prd-i" value={stability} onChange={e => setStability(e.target.value)}> <select className="wing-select" value={stability} onChange={e => setStability(e.target.value)}>
{['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)'].map(s => <option key={s[0]} value={s[0]}>{s}</option>)} {['A (매우 불안정)', 'B (불안정)', 'C (약간 불안정)', 'D (중립)', 'E (약간 안정)', 'F (안정)'].map(s => <option key={s[0]} value={s[0]}>{s}</option>)}
</select> </select>
</ModalField> </ModalField>
<ModalField label="확산 모델"> <ModalField label="확산 모델">
<select className="prd-i" value={model} onChange={e => setModel(e.target.value)}> <select className="wing-select" value={model} onChange={e => setModel(e.target.value)}>
{['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian'].map(m => <option key={m} value={m}>{m}</option>)} {['ALOHA', 'PHAST', 'CALPUFF', 'Lagrangian'].map(m => <option key={m} value={m}>{m}</option>)}
</select> </select>
</ModalField> </ModalField>
</div> </div>
<ModalField label="예측 시간"> <ModalField label="예측 시간">
<select className="prd-i" value={predTime} onChange={e => setPredTime(e.target.value)}> <select className="wing-select" value={predTime} onChange={e => setPredTime(e.target.value)}>
{['1', '3', '6', '12', '24', '48'].map(h => <option key={h} value={h}>{h}</option>)} {['1', '3', '6', '12', '24', '48'].map(h => <option key={h} value={h}>{h}</option>)}
</select> </select>
</ModalField> </ModalField>

파일 보기

@ -151,12 +151,12 @@ const PredictionInputSection = ({
{inputMode === 'direct' && ( {inputMode === 'direct' && (
<> <>
<input <input
className="prd-i" className="wing-input"
placeholder="사고명 직접 입력" placeholder="사고명 직접 입력"
value={incidentName} value={incidentName}
onChange={(e) => onIncidentNameChange(e.target.value)} onChange={(e) => onIncidentNameChange(e.target.value)}
/> />
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly /> <input className="wing-input" placeholder="또는 사고 리스트에서 선택" readOnly />
</> </>
)} )}
@ -212,7 +212,7 @@ const PredictionInputSection = ({
{/* 분석 실행 버튼 */} {/* 분석 실행 버튼 */}
<button <button
className="prd-btn pri" className="wing-btn wing-btn-primary"
style={{ padding: '7px', fontSize: '11px' }} style={{ padding: '7px', fontSize: '11px' }}
onClick={handleAnalyze} onClick={handleAnalyze}
disabled={!uploadedFile || isAnalyzing} disabled={!uploadedFile || isAnalyzing}
@ -263,7 +263,7 @@ const PredictionInputSection = ({
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[9px] text-text-3 font-korean"> (KST)</label> <label className="text-[9px] text-text-3 font-korean"> (KST)</label>
<input <input
className="prd-i" className="wing-input"
type="datetime-local" type="datetime-local"
value={accidentTime} value={accidentTime}
onChange={(e) => onAccidentTimeChange(e.target.value)} onChange={(e) => onAccidentTimeChange(e.target.value)}
@ -275,7 +275,7 @@ const PredictionInputSection = ({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}> <div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
<input <input
className="prd-i" className="wing-input"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord?.lat ?? ''} value={incidentCoord?.lat ?? ''}
@ -286,7 +286,7 @@ const PredictionInputSection = ({
placeholder="위도°" placeholder="위도°"
/> />
<input <input
className="prd-i" className="wing-input"
type="number" type="number"
step="0.0001" step="0.0001"
value={incidentCoord?.lon ?? ''} value={incidentCoord?.lon ?? ''}
@ -315,7 +315,7 @@ const PredictionInputSection = ({
{/* Oil Type + Oil Kind */} {/* Oil Type + Oil Kind */}
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={spillType} value={spillType}
onChange={onSpillTypeChange} onChange={onSpillTypeChange}
options={[ options={[
@ -325,7 +325,7 @@ const PredictionInputSection = ({
]} ]}
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={oilType} value={oilType}
onChange={onOilTypeChange} onChange={onOilTypeChange}
options={[ options={[
@ -342,7 +342,7 @@ const PredictionInputSection = ({
{/* Volume + Unit + Duration */} {/* Volume + Unit + Duration */}
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}> <div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
<input <input
className="prd-i" className="wing-input"
placeholder="유출량" placeholder="유출량"
type="number" type="number"
min="1" min="1"
@ -351,7 +351,7 @@ const PredictionInputSection = ({
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)} onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={spillUnit} value={spillUnit}
onChange={onSpillUnitChange} onChange={onSpillUnitChange}
options={[ options={[
@ -361,7 +361,7 @@ const PredictionInputSection = ({
]} ]}
/> />
<ComboBox <ComboBox
className="prd-i" className="wing-input"
value={predictionTime} value={predictionTime}
onChange={(v) => onPredictionTimeChange(parseInt(v))} onChange={(v) => onPredictionTimeChange(parseInt(v))}
options={[ options={[
@ -430,7 +430,7 @@ const PredictionInputSection = ({
{/* Run Button */} {/* Run Button */}
<button <button
className="prd-btn pri mt-0.5" className="wing-btn wing-btn-primary mt-0.5"
style={{ padding: '7px', fontSize: '11px' }} style={{ padding: '7px', fontSize: '11px' }}
onClick={onRunSimulation} onClick={onRunSimulation}
disabled={isRunningSimulation} disabled={isRunningSimulation}

파일 보기

@ -173,7 +173,7 @@ export function RecalcModal({
{/* 유종 */} {/* 유종 */}
<FieldGroup label="유종"> <FieldGroup label="유종">
<select <select
className="prd-i" className="wing-select"
value={oilType} value={oilType}
onChange={(e) => setOilType(e.target.value)} onChange={(e) => setOilType(e.target.value)}
> >
@ -188,12 +188,12 @@ export function RecalcModal({
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<input <input
type="number" type="number"
className="prd-i flex-1" className="wing-input flex-1"
value={spillAmount} value={spillAmount}
onChange={(e) => setSpillAmount(Number(e.target.value))} onChange={(e) => setSpillAmount(Number(e.target.value))}
/> />
<select <select
className="prd-i" className="wing-select"
value={spillUnit} value={spillUnit}
onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')} onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')}
style={{ width: '70px' }} style={{ width: '70px' }}
@ -208,7 +208,7 @@ export function RecalcModal({
{/* 유출 형태 */} {/* 유출 형태 */}
<FieldGroup label="유출 형태"> <FieldGroup label="유출 형태">
<select <select
className="prd-i" className="wing-select"
value={spillType} value={spillType}
onChange={(e) => setSpillType(e.target.value)} onChange={(e) => setSpillType(e.target.value)}
> >
@ -221,7 +221,7 @@ export function RecalcModal({
{/* 예측 시간 */} {/* 예측 시간 */}
<FieldGroup label="예측 시간"> <FieldGroup label="예측 시간">
<select <select
className="prd-i" className="wing-select"
value={predictionTime} value={predictionTime}
onChange={(e) => setPredictionTime(Number(e.target.value))} onChange={(e) => setPredictionTime(Number(e.target.value))}
> >
@ -240,7 +240,7 @@ export function RecalcModal({
</div> </div>
<input <input
type="number" type="number"
className="prd-i font-mono" className="wing-input font-mono"
value={lat} value={lat}
step={0.0001} step={0.0001}
onChange={(e) => setLat(Number(e.target.value))} onChange={(e) => setLat(Number(e.target.value))}
@ -252,7 +252,7 @@ export function RecalcModal({
</div> </div>
<input <input
type="number" type="number"
className="prd-i font-mono" className="wing-input font-mono"
value={lon} value={lon}
step={0.0001} step={0.0001}
onChange={(e) => setLon(Number(e.target.value))} onChange={(e) => setLon(Number(e.target.value))}

파일 보기

@ -66,7 +66,7 @@ function ScatLeftPanel({
<select <select
value={jurisdictionFilter} value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)} onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full" className="wing-select w-full"
> >
<option> ()</option> <option> ()</option>
<option></option> <option></option>
@ -81,7 +81,7 @@ function ScatLeftPanel({
<select <select
value={areaFilter} value={areaFilter}
onChange={(e) => onAreaChange(e.target.value)} onChange={(e) => onAreaChange(e.target.value)}
className="prd-i w-full" className="wing-select w-full"
> >
<option></option> <option></option>
{zones.map((z) => ( {zones.map((z) => (
@ -99,7 +99,7 @@ function ScatLeftPanel({
<select <select
value={phaseFilter} value={phaseFilter}
onChange={(e) => onPhaseChange(e.target.value)} onChange={(e) => onPhaseChange(e.target.value)}
className="prd-i w-full" className="wing-select w-full"
> >
<option>Pre-SCAT ()</option> <option>Pre-SCAT ()</option>
<option>SCAT ( )</option> <option>SCAT ( )</option>
@ -113,12 +113,12 @@ function ScatLeftPanel({
placeholder="🔍 구간 검색..." placeholder="🔍 구간 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="prd-i flex-1" className="wing-input flex-1"
/> />
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)} onChange={(e) => onStatusChange(e.target.value)}
className="prd-i w-[70px]" className="wing-select w-[70px]"
> >
<option></option> <option></option>
<option></option> <option></option>

파일 보기

@ -0,0 +1,207 @@
import { useState } from 'react';
import BrandLogo from './sections/BrandLogo';
import BrandColor from './sections/BrandColor';
import Typography from './sections/Typography';
import Icons from './sections/Icons';
import Spacing from './sections/Spacing';
import Border from './sections/Border';
import BadgeSection from './sections/BadgeSection';
import ButtonSection from './sections/ButtonSection';
import InputSection from './sections/InputSection';
import CardSection from './sections/CardSection';
import DataTableSection from './sections/DataTableSection';
import PaginationSection from './sections/PaginationSection';
import ModalSection from './sections/ModalSection';
import SidePanelSection from './sections/SidePanelSection';
import ComboBoxSection from './sections/ComboBoxSection';
/* ── 라이트 테마 CSS 변수 오버라이드 ── */
const LIGHT_THEME_VARS: React.CSSProperties = {
'--bg0': '#ffffff',
'--bg1': '#f8f9fb',
'--bg2': '#f0f2f5',
'--bg3': '#e8ebf0',
'--bgH': '#dde1e8',
'--bd': '#d0d5dd',
'--bdL': '#e0e4ea',
'--t1': '#1a1d26',
'--t2': '#4a5568',
'--t3': '#8690a6',
'--cyan': '#0891b2',
'--blue': '#2563eb',
'--red': '#dc2626',
'--orange': '#ea580c',
'--yellow': '#ca8a04',
'--green': '#16a34a',
'--purple': '#9333ea',
} as React.CSSProperties;
/* ── 사이드바 메뉴 구조 ── */
type SectionId =
| 'logo'
| 'brand-color' | 'typography' | 'icons' | 'spacing' | 'border'
| 'badge' | 'button' | 'input' | 'card' | 'data-table' | 'pagination' | 'modal' | 'side-panel' | 'combo-box';
interface MenuItem {
id: SectionId;
label: string;
}
interface MenuGroup {
title: string;
items: MenuItem[];
}
const MENU: MenuGroup[] = [
{
title: 'Brand',
items: [
{ id: 'logo', label: '로고 (Logo)' },
],
},
{
title: 'Foundations',
items: [
{ id: 'brand-color', label: '브랜드 컬러' },
{ id: 'typography', label: '타이포그래피' },
{ id: 'icons', label: '아이콘' },
{ id: 'spacing', label: '간격' },
{ id: 'border', label: '테두리' },
],
},
{
title: 'Components',
items: [
{ id: 'badge', label: 'Badge' },
{ id: 'button', label: 'Button' },
{ id: 'input', label: 'Input' },
{ id: 'card', label: 'Card' },
{ id: 'data-table', label: 'DataTable' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'modal', label: 'Modal' },
{ id: 'side-panel', label: 'SidePanel' },
{ id: 'combo-box', label: 'ComboBox' },
],
},
];
const SECTION_MAP: Record<SectionId, () => React.ReactNode> = {
'logo': () => <BrandLogo />,
'brand-color': () => <BrandColor />,
'typography': () => <Typography />,
'icons': () => <Icons />,
'spacing': () => <Spacing />,
'border': () => <Border />,
'badge': () => <BadgeSection />,
'button': () => <ButtonSection />,
'input': () => <InputSection />,
'card': () => <CardSection />,
'data-table': () => <DataTableSection />,
'pagination': () => <PaginationSection />,
'modal': () => <ModalSection />,
'side-panel': () => <SidePanelSection />,
'combo-box': () => <ComboBoxSection />,
};
/* ── 메인 쇼케이스 ── */
const ShowcaseView = () => {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeSection, setActiveSection] = useState<SectionId>('logo');
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
Brand: true,
Foundations: true,
Components: true,
});
const isLight = theme === 'light';
const toggleGroup = (title: string) => {
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
};
const renderSection = SECTION_MAP[activeSection];
return (
<div
className="flex h-full overflow-hidden w-full"
style={isLight ? LIGHT_THEME_VARS : undefined}
>
{/* 사이드바 */}
<div
className="w-[240px] shrink-0 h-full overflow-y-auto border-r flex flex-col"
style={{ background: 'var(--bg1)', borderColor: 'var(--bd)' }}
>
{/* 사이드바 헤더 + 테마 토글 */}
<div className="p-4 border-b" style={{ borderColor: 'var(--bd)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-wing-heading font-bold" style={{ color: 'var(--t1)' }}>
Design System
</span>
<button
onClick={() => setTheme(isLight ? 'dark' : 'light')}
className="flex items-center gap-1.5 px-2 py-1 rounded-sm border transition-all text-[10px] font-semibold"
style={{
background: 'var(--bg3)',
borderColor: 'var(--bd)',
color: 'var(--t1)',
}}
>
<span>{isLight ? '☀️' : '🌙'}</span>
{isLight ? 'Light' : 'Dark'}
</button>
</div>
<p className="wing-meta" style={{ color: 'var(--t3)' }}>WING UI </p>
</div>
{/* 메뉴 그룹 */}
<nav className="flex-1 py-2">
{MENU.map((group) => (
<div key={group.title} className="mb-1">
{/* 그룹 헤더 */}
<button
onClick={() => toggleGroup(group.title)}
className="w-full flex items-center justify-between px-4 py-2 text-[11px] font-bold tracking-wide transition-colors hover:opacity-80"
style={{ color: 'var(--t3)' }}
>
<span>{group.title}</span>
<span className="text-[10px]">{openGroups[group.title] ? '▾' : '▸'}</span>
</button>
{/* 하위 메뉴 */}
{openGroups[group.title] && (
<div>
{group.items.map((item) => {
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className="w-full text-left px-4 py-1.5 pl-7 text-[11px] transition-all"
style={{
color: isActive ? 'var(--cyan)' : 'var(--t2)',
background: isActive ? 'rgba(6,182,212,0.08)' : 'transparent',
fontWeight: isActive ? 600 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
))}
</nav>
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 overflow-y-auto p-6" style={{ background: 'var(--bg0)', color: 'var(--t1)' }}>
<div className="max-w-[960px] mx-auto">
{renderSection()}
</div>
</div>
</div>
);
};
export default ShowcaseView;

파일 보기

@ -0,0 +1 @@
export { default as ShowcaseView } from './ShowcaseView';

파일 보기

@ -0,0 +1,23 @@
import Badge from '@common/components/ui/Badge';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BADGE_COLORS = ['neutral', 'red', 'blue', 'green', 'yellow', 'purple', 'cyan'] as const;
const BadgeSection = () => (
<Section title="Badge" desc="wing-badge 컬러 7종">
<div className="flex gap-2 flex-wrap">
{BADGE_COLORS.map((color) => (
<Badge key={color} color={color}>{color}</Badge>
))}
</div>
</Section>
);
export default BadgeSection;

파일 보기

@ -0,0 +1,48 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Border = () => (
<Section title="테두리 (Border)" desc="테두리 색상 + Border Radius 4단계">
<div className="space-y-4">
<div>
<p className="wing-label mb-2"> </p>
<div className="flex gap-2">
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bd)', background: 'var(--bg1)' }} />
<span className="wing-meta">bd</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ border: '2px solid var(--bdL)', background: 'var(--bg1)' }} />
<span className="wing-meta">bdL</span>
</div>
</div>
</div>
<div>
<p className="wing-label mb-2">Border Radius</p>
<div className="flex gap-2">
{[
{ r: 4, label: 'xs (4px)' },
{ r: 6, label: 'sm (6px)' },
{ r: 10, label: 'md (10px)' },
{ r: 12, label: 'lg (12px)' },
].map((item) => (
<div key={item.r} className="flex flex-col items-center gap-1">
<div
className="w-12 h-12 border-2 border-primary-cyan bg-bg-2"
style={{ borderRadius: item.r }}
/>
<span className="wing-meta text-text-3">{item.label}</span>
</div>
))}
</div>
</div>
</div>
</Section>
);
export default Border;

파일 보기

@ -0,0 +1,47 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BrandColor = () => (
<Section title="브랜드 컬러 (Brand Color)" desc="배경, 텍스트, Accent Color">
{/* 배경 */}
<p className="wing-label mb-2"> (Background)</p>
<div className="flex gap-2 mb-4">
{['--bg0', '--bg1', '--bg2', '--bg3', '--bgH'].map((v) => (
<div key={v} className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: `var(${v})`, border: '1px solid var(--bd)' }} />
<span className="wing-meta">{v.replace('--', '')}</span>
</div>
))}
</div>
{/* 텍스트 */}
<p className="wing-label mb-2"> (Text)</p>
<div className="flex gap-4 mb-4 p-3 rounded-sm" style={{ background: 'var(--bg1)' }}>
<span style={{ color: 'var(--t1)' }} className="text-wing-body">Text-1 </span>
<span style={{ color: 'var(--t2)' }} className="text-wing-body">Text-2 </span>
<span style={{ color: 'var(--t3)' }} className="text-wing-body">Text-3 </span>
</div>
{/* Accent */}
<p className="wing-label mb-2">Accent Color</p>
<div className="flex gap-2 mb-2">
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--cyan)' }} />
<span className="wing-meta">Cyan</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'var(--blue)' }} />
<span className="wing-meta">Blue</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-14 h-14 rounded-sm" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }} />
<span className="wing-meta">Gradient</span>
</div>
</div>
</Section>
);
export default BrandColor;

파일 보기

@ -0,0 +1,31 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const BrandLogo = () => (
<Section title="로고 (Logo)" desc="WING 로고 규격 및 여백">
<div className="p-6 rounded-sm flex flex-col gap-6" style={{ background: 'var(--bg1)' }}>
<div>
<p className="wing-label mb-3"> </p>
<div className="flex gap-8 items-center">
<div className="p-4 rounded-sm" style={{ background: 'var(--bg0)' }}>
<img src="/wing_logo_white.svg" alt="WING Logo" className="h-5" />
</div>
<div className="p-4 rounded-sm" style={{ background: '#ffffff' }}>
<img src="/wing_logo_text_white.svg" alt="WING Logo Text" className="h-5" style={{ filter: 'invert(1)' }} />
</div>
</div>
</div>
<div>
<p className="wing-label mb-2"> </p>
<p className="wing-meta" style={{ color: 'var(--t3)' }}> 여백: 로고 50% </p>
</div>
</div>
</Section>
);
export default BrandLogo;

파일 보기

@ -0,0 +1,21 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const ButtonSection = () => (
<Section title="Button" desc="wing-btn 변형">
<div className="flex gap-2 flex-wrap items-center">
<button className="wing-btn wing-btn-primary">Primary</button>
<button className="wing-btn wing-btn-secondary">Secondary</button>
<button className="wing-btn wing-btn-outline">Outline</button>
<button className="wing-btn wing-btn-danger">Danger</button>
<button className="wing-btn wing-btn-primary" disabled>Disabled</button>
</div>
</Section>
);
export default ButtonSection;

파일 보기

@ -0,0 +1,30 @@
import Card from '@common/components/ui/Card';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const CardSection = () => (
<Section title="Card" desc="wing-card 기본 + hover">
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
<Card>
<p className="wing-label mb-1"> </p>
<p className="wing-meta text-text-3">hover: true ()</p>
</Card>
<Card hover={false}>
<p className="wing-label mb-1">Hover </p>
<p className="wing-meta text-text-3">hover: false</p>
</Card>
<Card onClick={() => alert('클릭!')}>
<p className="wing-label mb-1"> </p>
<p className="wing-meta text-text-3">onClick </p>
</Card>
</div>
</Section>
);
export default CardSection;

파일 보기

@ -0,0 +1,32 @@
import { useState } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const OPTIONS = [
{ value: 'prediction', label: '확산 예측' },
{ value: 'hns', label: 'HNS 분석' },
{ value: 'aerial', label: '항공 방제' },
{ value: 'weather', label: '해양 기상' },
{ value: 'scat', label: 'SCAT 조사' },
];
const ComboBoxSection = () => {
const [value, setValue] = useState('prediction');
return (
<Section title="ComboBox" desc="검색 가능한 드롭다운 선택">
<div className="max-w-[240px]">
<ComboBox value={value} onChange={setValue} options={OPTIONS} placeholder="기능 선택..." />
</div>
<p className="wing-meta text-text-3 mt-2">: {value}</p>
</Section>
);
};
export default ComboBoxSection;

파일 보기

@ -0,0 +1,39 @@
import Badge from '@common/components/ui/Badge';
import DataTable from '@common/components/ui/DataTable';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const SAMPLE_DATA = [
{ id: 1, title: '유류 확산 예측 보고서', author: '이동녘', category: '공지사항', date: '2026-03-10', views: 142 },
{ id: 2, title: 'HNS 물질 분석 자료', author: '김해양', category: '자료실', date: '2026-03-09', views: 87 },
{ id: 3, title: '항공 방제 드론 운영 매뉴얼', author: '박방제', category: '매뉴얼', date: '2026-03-08', views: 256 },
{ id: 4, title: 'SCAT 해안조사 결과', author: '최조사', category: '보고서', date: '2026-03-07', views: 64 },
{ id: 5, title: '기상 데이터 연동 가이드', author: '정기상', category: 'Q&A', date: '2026-03-06', views: 33 },
];
const TABLE_COLUMNS = [
{ key: 'id' as const, label: '번호', width: '60px', align: 'center' as const },
{ key: 'title' as const, label: '제목' },
{ key: 'author' as const, label: '작성자', width: '100px' },
{ key: 'category' as const, label: '분류', width: '100px', render: (val: unknown) => <Badge>{val as string}</Badge> },
{ key: 'date' as const, label: '작성일', width: '120px' },
{ key: 'views' as const, label: '조회', width: '60px', align: 'center' as const },
];
const DataTableSection = () => (
<Section title="DataTable" desc="wing-table 기반 제네릭 테이블">
<DataTable
columns={TABLE_COLUMNS}
data={SAMPLE_DATA}
onRowClick={(row) => alert(`선택: ${row.title}`)}
/>
</Section>
);
export default DataTableSection;

파일 보기

@ -0,0 +1,31 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Icons = () => (
<Section title="아이콘 (Icons)" desc="lucide-react 기반, 16/18/20px 3단계">
<div className="p-4 rounded-sm space-y-4" style={{ background: 'var(--bg1)' }}>
<p className="wing-meta" style={{ color: 'var(--t3)' }}>
lucide-react .
</p>
<div className="flex gap-6 items-end">
{[16, 18, 20].map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<div className="w-10 h-10 rounded-sm flex items-center justify-center" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</div>
<span className="wing-meta" style={{ color: 'var(--t3)' }}>{size}px</span>
</div>
))}
</div>
</div>
</Section>
);
export default Icons;

파일 보기

@ -0,0 +1,53 @@
import { useState } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const COMBO_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'notice', label: '공지사항' },
{ value: 'data', label: '자료실' },
{ value: 'manual', label: '매뉴얼' },
];
const InputSection = () => {
const [comboValue, setComboValue] = useState('all');
return (
<Section title="Input / Select / ComboBox" desc="wing-input, wing-select, ComboBox">
<div className="grid grid-cols-3 gap-3 max-w-[720px]">
<div>
<label className="wing-label mb-1 block">Input</label>
<input className="wing-input" placeholder="텍스트 입력..." />
</div>
<div>
<label className="wing-label mb-1 block">Search</label>
<input className="wing-input-search" placeholder="검색..." />
</div>
<div>
<label className="wing-label mb-1 block">Select</label>
<select className="wing-select">
<option> 1</option>
<option> 2</option>
<option> 3</option>
</select>
</div>
<div>
<label className="wing-label mb-1 block">Textarea</label>
<textarea className="wing-textarea" rows={2} placeholder="여러 줄 입력..." />
</div>
<div>
<label className="wing-label mb-1 block">ComboBox</label>
<ComboBox value={comboValue} onChange={setComboValue} options={COMBO_OPTIONS} />
</div>
</div>
</Section>
);
};
export default InputSection;

파일 보기

@ -0,0 +1,46 @@
import { useState } from 'react';
import Modal from '@common/components/ui/Modal';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const ModalSection = () => {
const [modalOpen, setModalOpen] = useState(false);
const [modalSize, setModalSize] = useState<'sm' | 'md' | 'lg'>('md');
return (
<Section title="Modal" desc="wing-modal 3가지 사이즈">
<div className="flex gap-2">
{(['sm', 'md', 'lg'] as const).map((size) => (
<button
key={size}
className="wing-btn wing-btn-secondary"
onClick={() => { setModalSize(size); setModalOpen(true); }}
>
{size.toUpperCase()}
</button>
))}
</div>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={`모달 (${modalSize})`}
size={modalSize}
footer={
<div className="flex gap-2 justify-end">
<button className="wing-btn wing-btn-secondary" onClick={() => setModalOpen(false)}></button>
<button className="wing-btn wing-btn-primary" onClick={() => setModalOpen(false)}></button>
</div>
}
>
<p className="text-text-2 text-wing-body"> . : {modalSize}</p>
</Modal>
</Section>
);
};
export default ModalSection;

파일 보기

@ -0,0 +1,30 @@
import { useState } from 'react';
import Pagination from '@common/components/ui/Pagination';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const PaginationSection = () => {
const [page, setPage] = useState(1);
return (
<Section title="Pagination" desc="wing-btn 기반 페이지네이션">
<div className="flex gap-6 items-center">
<div>
<p className="wing-meta text-text-3 mb-2">7 {page}</p>
<Pagination currentPage={page} totalPages={7} onPageChange={setPage} />
</div>
<div>
<p className="wing-meta text-text-3 mb-2">3 (showFirstLast: false)</p>
<Pagination currentPage={1} totalPages={3} onPageChange={() => {}} showFirstLast={false} />
</div>
</div>
</Section>
);
};
export default PaginationSection;

파일 보기

@ -0,0 +1,67 @@
import { useState } from 'react';
import Badge from '@common/components/ui/Badge';
import SidePanel from '@common/components/ui/SidePanel';
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const SidePanelSection = () => {
const [visible, setVisible] = useState(false);
return (
<>
<Section title="SidePanel" desc="wing-panel-right/left 사이드패널">
<button
className="wing-btn wing-btn-secondary"
onClick={() => setVisible(!visible)}
>
{visible ? '사이드패널 닫기' : '사이드패널 열기'}
</button>
</Section>
{visible && (
<SidePanel
position="right"
width="default"
header={
<div className="flex items-center justify-between w-full">
<span className="wing-title"> </span>
<button className="wing-btn wing-btn-secondary" onClick={() => setVisible(false)}></button>
</div>
}
footer={
<button className="wing-btn wing-btn-primary w-full"></button>
}
>
<div className="p-3 space-y-3">
<div className="wing-info-row">
<span className="wing-info-label"></span>
<Badge color="green"></Badge>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<Badge></Badge>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value"></span>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value">2026-03-10</span>
</div>
<div className="wing-info-row">
<span className="wing-info-label"></span>
<span className="wing-info-value">142</span>
</div>
</div>
</SidePanel>
)}
</>
);
};
export default SidePanelSection;

파일 보기

@ -0,0 +1,25 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Spacing = () => (
<Section title="간격 (Spacing)" desc="4px 그리드 기반 6단계">
<div>
<p className="wing-label mb-2">Spacing (4px grid)</p>
<div className="flex items-end gap-2">
{[4, 6, 8, 12, 16, 20].map((s) => (
<div key={s} className="flex flex-col items-center gap-1">
<div className="bg-primary-cyan/30 border border-primary-cyan/50" style={{ width: s, height: 40 }} />
<span className="wing-meta text-text-3">{s}px</span>
</div>
))}
</div>
</div>
</Section>
);
export default Spacing;

파일 보기

@ -0,0 +1,43 @@
const Section = ({ title, desc, children }: { title: string; desc?: string; children: React.ReactNode }) => (
<div className="mb-8">
<h2 className="wing-section-header mb-1">{title}</h2>
{desc && <p className="wing-section-desc mb-4">{desc}</p>}
<div>{children}</div>
</div>
);
const Typography = () => (
<Section title="타이포그래피 (Typography)" desc="폰트 패밀리 + 5단계 스케일">
<div className="p-4 bg-bg-1 rounded-sm space-y-3">
<div className="flex items-baseline gap-4">
<span className="text-wing-title font-bold text-text-1">Title 15px</span>
<span className="wing-meta text-text-3">font-bold, line-height 1.3</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-heading font-bold text-text-1">Heading 13px</span>
<span className="wing-meta text-text-3">font-bold, line-height 1.4</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-body text-text-1">Body 11px </span>
<span className="wing-meta text-text-3">line-height 1.5</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-caption text-text-2">Caption 10px , </span>
<span className="wing-meta text-text-3">line-height 1.4</span>
</div>
<div className="flex items-baseline gap-4">
<span className="text-wing-meta text-text-3">Meta 9px , </span>
<span className="wing-meta text-text-3">line-height 1.4</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<p className="wing-meta text-text-3 mb-2"> </p>
<div className="flex gap-6">
<span className="text-wing-body font-sans text-text-2">Sans: Outfit + Noto Sans KR</span>
<span className="text-wing-body font-mono text-text-2">Mono: JetBrains Mono</span>
</div>
</div>
</div>
</Section>
);
export default Typography;

파일 보기

@ -44,6 +44,13 @@ export default {
mono: ['JetBrains Mono', 'monospace'], mono: ['JetBrains Mono', 'monospace'],
korean: ['Noto Sans KR', 'sans-serif'], korean: ['Noto Sans KR', 'sans-serif'],
}, },
fontSize: {
'wing-meta': ['9px', { lineHeight: '1.4' }],
'wing-caption': ['10px', { lineHeight: '1.4' }],
'wing-body': ['11px', { lineHeight: '1.5' }],
'wing-heading': ['13px', { lineHeight: '1.4' }],
'wing-title': ['15px', { lineHeight: '1.3' }],
},
borderRadius: { borderRadius: {
sm: '6px', sm: '6px',
md: '10px', md: '10px',