Compare commits
2 커밋
main
...
feature/wi
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 51da334e7a | |||
| 2d9bf0a8e0 |
462
docs/DESIGN-SYSTEM.backup.md
Normal file
462
docs/DESIGN-SYSTEM.backup.md
Normal file
@ -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
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 { ScatView } from '@tabs/scat'
|
||||
import { RescueView } from '@tabs/rescue'
|
||||
import { ShowcaseView } from '@tabs/showcase'
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
|
||||
|
||||
@ -97,6 +98,8 @@ function App() {
|
||||
return <AdminView />
|
||||
case 'rescue':
|
||||
return <RescueView />
|
||||
case 'showcase':
|
||||
return <ShowcaseView />
|
||||
default:
|
||||
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>
|
||||
<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') && (
|
||||
<button
|
||||
onClick={() => onTabChange('admin')}
|
||||
|
||||
17
frontend/src/common/components/ui/Badge.tsx
Normal file
17
frontend/src/common/components/ui/Badge.tsx
Normal file
@ -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;
|
||||
21
frontend/src/common/components/ui/Card.tsx
Normal file
21
frontend/src/common/components/ui/Card.tsx
Normal file
@ -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;
|
||||
94
frontend/src/common/components/ui/DataTable.tsx
Normal file
94
frontend/src/common/components/ui/DataTable.tsx
Normal file
@ -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;
|
||||
73
frontend/src/common/components/ui/Modal.tsx
Normal file
73
frontend/src/common/components/ui/Modal.tsx
Normal file
@ -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;
|
||||
123
frontend/src/common/components/ui/Pagination.tsx
Normal file
123
frontend/src/common/components/ui/Pagination.tsx
Normal file
@ -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;
|
||||
45
frontend/src/common/components/ui/SidePanel.tsx
Normal file
45
frontend/src/common/components/ui/SidePanel.tsx
Normal file
@ -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;
|
||||
--rS: 6px;
|
||||
--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 ═══ */
|
||||
/* @deprecated: wing-input 사용 권장 */
|
||||
.prd-i {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
@ -45,6 +46,7 @@
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
/* @deprecated: wing-select 사용 권장 */
|
||||
select.prd-i {
|
||||
cursor: pointer;
|
||||
padding-right: 30px;
|
||||
@ -219,6 +221,7 @@
|
||||
}
|
||||
|
||||
/* ═══ Buttons ═══ */
|
||||
/* @deprecated: wing-btn + wing-btn-primary/secondary 사용 권장 */
|
||||
.prd-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
|
||||
@ -28,6 +28,11 @@
|
||||
.wing-card {
|
||||
@apply rounded-md p-4 border border-border;
|
||||
background: var(--bg3);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.wing-card-hover:hover {
|
||||
border-color: rgba(6, 182, 212, 0.4);
|
||||
}
|
||||
|
||||
.wing-card-sm {
|
||||
@ -83,7 +88,42 @@
|
||||
|
||||
/* ── 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 ── */
|
||||
@ -151,11 +191,11 @@
|
||||
|
||||
/* ── Table ── */
|
||||
.wing-table {
|
||||
@apply w-full text-[10px] font-korean;
|
||||
@apply w-full text-sm font-korean;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.wing-th {
|
||||
.wing-table-head {
|
||||
@apply text-left font-semibold;
|
||||
padding: 8px 10px;
|
||||
color: var(--t3);
|
||||
@ -163,17 +203,26 @@
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.wing-td {
|
||||
.wing-table-cell {
|
||||
padding: 8px 10px;
|
||||
color: var(--t2);
|
||||
border-bottom: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.wing-tr-hover:hover {
|
||||
.wing-table-row {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wing-table-row:hover {
|
||||
background: var(--bgH);
|
||||
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 ── */
|
||||
.wing-tab-bar {
|
||||
@apply flex gap-0.5 rounded-lg p-1 border border-border;
|
||||
@ -201,7 +250,7 @@
|
||||
/* ── Modal ── */
|
||||
.wing-overlay {
|
||||
@apply fixed inset-0 flex items-center justify-center;
|
||||
z-index: 10000;
|
||||
z-index: var(--z-modal);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@ -219,6 +268,127 @@
|
||||
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 ── */
|
||||
.wing-divider {
|
||||
@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
|
||||
value={sortBy}
|
||||
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="name">이름순</option>
|
||||
@ -370,7 +370,7 @@ export function MediaManagement() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<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 Mavic 3E)</option>
|
||||
<option>유인항공기 (CN-235)</option>
|
||||
@ -382,7 +382,7 @@ export function MediaManagement() {
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<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>
|
||||
@ -391,7 +391,7 @@ export function MediaManagement() {
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-semibold mb-1.5 text-text-2 font-korean">메모</label>
|
||||
<textarea
|
||||
className="prd-i w-full h-[60px] resize-y"
|
||||
className="wing-input w-full h-[60px] resize-y"
|
||||
placeholder="촬영 조건, 비고 등..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -116,9 +116,9 @@ function AssetManagement() {
|
||||
placeholder="기관명 검색..."
|
||||
value={searchTerm}
|
||||
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="남해">남해청</option>
|
||||
<option value="서해">서해청</option>
|
||||
@ -126,7 +126,7 @@ function AssetManagement() {
|
||||
<option value="동해">동해청</option>
|
||||
<option value="제주">제주청</option>
|
||||
</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="해경관할">해경관할</option>
|
||||
<option value="해경경찰서">해경경찰서</option>
|
||||
@ -140,7 +140,7 @@ function AssetManagement() {
|
||||
<option value="해군">해군</option>
|
||||
<option value="기타">기타</option>
|
||||
</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="vessel">방제선</option>
|
||||
<option value="skimmer">유회수기</option>
|
||||
|
||||
@ -37,7 +37,7 @@ function AssetUpload() {
|
||||
{/* Asset Classification */}
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
@ -50,7 +50,7 @@ function AssetUpload() {
|
||||
{/* Jurisdiction */}
|
||||
<div className="mb-4">
|
||||
<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>
|
||||
|
||||
@ -173,7 +173,7 @@ function ShipInsurance() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
@ -181,7 +181,7 @@ function ShipInsurance() {
|
||||
</div>
|
||||
<div>
|
||||
<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>부산지방해양수산청</option>
|
||||
<option>인천지방해양수산청</option>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { fetchBoardPost, type BoardPostDetail } from '../services/boardApi'
|
||||
import Badge from '@common/components/ui/Badge'
|
||||
|
||||
// 카테고리 코드 → 표시명
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@ -10,14 +11,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
postSn: number
|
||||
onBack: () => void
|
||||
@ -64,10 +57,10 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
return (
|
||||
<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
|
||||
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>
|
||||
@ -76,13 +69,13 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-bg-2 text-text-1 border border-border hover:bg-bg-3 transition-colors"
|
||||
className="wing-btn wing-btn-secondary"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 text-sm font-semibold rounded bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
className="wing-btn bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
@ -96,13 +89,9 @@ export function BoardDetailView({ postSn, onBack, onEdit, onDelete }: BoardDetai
|
||||
{/* 게시글 헤더 */}
|
||||
<div className="pb-6 border-b border-border">
|
||||
<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'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
|
||||
{post.pinnedYn === 'Y' && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-yellow-500/20 text-yellow-400">
|
||||
📌 고정
|
||||
</span>
|
||||
<Badge color="yellow">📌 고정</Badge>
|
||||
)}
|
||||
</div>
|
||||
<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 { useAuthStore } from '@common/store/authStore';
|
||||
import { fetchBoardPosts, type BoardPostItem } from '../services/boardApi';
|
||||
import Badge from '@common/components/ui/Badge';
|
||||
|
||||
// 카테고리 코드 ↔ 표시명 매핑
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
@ -17,13 +18,6 @@ const CATEGORY_FILTER: { label: string; code: string | null }[] = [
|
||||
{ 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;
|
||||
|
||||
interface BoardListTableProps {
|
||||
@ -97,7 +91,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-0">
|
||||
{/* 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 gap-2">
|
||||
{CATEGORY_FILTER.map((cat) => (
|
||||
@ -123,13 +117,13 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="px-4 py-2 text-sm bg-bg-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 && (
|
||||
<button
|
||||
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>
|
||||
@ -146,15 +140,15 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<table className="wing-table">
|
||||
<thead>
|
||||
<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="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 w-32">작성자</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2 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-20">번호</th>
|
||||
<th className="wing-table-head w-32">분류</th>
|
||||
<th className="wing-table-head">제목</th>
|
||||
<th className="wing-table-head w-32">작성자</th>
|
||||
<th className="wing-table-head w-32">작성일</th>
|
||||
<th className="wing-table-head w-24">조회수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -162,27 +156,19 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<tr
|
||||
key={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' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold bg-red-500/20 text-red-400">
|
||||
공지
|
||||
</span>
|
||||
<Badge color="cyan">공지</Badge>
|
||||
) : (
|
||||
post.sn
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold ${
|
||||
CATEGORY_STYLE[post.categoryCd] || 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_MAP[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
<td className="wing-table-cell">
|
||||
<Badge>{CATEGORY_MAP[post.categoryCd] || post.categoryCd}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<td className="wing-table-cell">
|
||||
<span
|
||||
className={`text-sm ${
|
||||
post.pinnedYn === 'Y' ? 'font-semibold text-text-1' : 'text-text-1'
|
||||
@ -191,9 +177,9 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm 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">{post.authorName}</td>
|
||||
<td className="wing-table-cell text-text-3">{formatDate(post.regDtm)}</td>
|
||||
<td className="wing-table-cell text-text-3">{post.viewCnt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@ -214,7 +200,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-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>
|
||||
@ -222,10 +208,10 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded ${
|
||||
className={`wing-btn ${
|
||||
page === p
|
||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1 transition-colors'
|
||||
? 'wing-btn-primary font-semibold'
|
||||
: 'wing-btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
@ -234,7 +220,7 @@ export function BoardListTable({ onPostClick, onWriteClick }: BoardListTableProp
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1.5 text-sm rounded bg-bg-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>
|
||||
|
||||
@ -3,6 +3,8 @@ import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { BoardWriteForm } from './BoardWriteForm'
|
||||
import { BoardDetailView } from './BoardDetailView'
|
||||
import Badge from '@common/components/ui/Badge'
|
||||
import Card from '@common/components/ui/Card'
|
||||
import {
|
||||
fetchBoardPosts,
|
||||
deleteBoardPost,
|
||||
@ -33,14 +35,6 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
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
|
||||
|
||||
export function BoardView() {
|
||||
@ -190,15 +184,6 @@ export function BoardView() {
|
||||
|
||||
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') {
|
||||
return (
|
||||
@ -206,7 +191,7 @@ export function BoardView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<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-2">
|
||||
<span className="text-lg">📘</span>
|
||||
@ -216,12 +201,11 @@ export function BoardView() {
|
||||
<div className="flex gap-1 ml-4">
|
||||
{manualCategories.map(cat => (
|
||||
<button key={cat} onClick={() => setManualCategory(cat)}
|
||||
className="px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all"
|
||||
style={{
|
||||
background: manualCategory === cat ? 'rgba(6,182,212,.15)' : 'var(--bg3)',
|
||||
border: manualCategory === cat ? '1px solid rgba(6,182,212,.3)' : '1px solid var(--bd)',
|
||||
color: manualCategory === cat ? 'var(--cyan)' : 'var(--t3)',
|
||||
}}>
|
||||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-md transition-all border ${
|
||||
manualCategory === cat
|
||||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||||
: 'bg-bg-3 border-border text-text-3'
|
||||
}`}>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
@ -229,10 +213,9 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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)}
|
||||
className="px-5 py-2 text-[12px] font-semibold rounded-md transition-all flex items-center gap-1.5"
|
||||
style={{ background: 'rgba(6,182,212,.15)', border: '1px solid rgba(6,182,212,.3)', color: '#22d3ee', cursor: 'pointer' }}>
|
||||
className="wing-btn wing-btn-primary flex items-center gap-1.5">
|
||||
📤 새로 업로드
|
||||
</button>
|
||||
</div>
|
||||
@ -246,33 +229,18 @@ export function BoardView() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||||
{filteredManuals.map(file => {
|
||||
const cc = catColor(file.catgNm)
|
||||
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)' }}
|
||||
>
|
||||
{filteredManuals.map(file => (
|
||||
<Card key={file.manualSn}>
|
||||
<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 }}>
|
||||
{file.catgNm}
|
||||
</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>
|
||||
<Badge>{file.catgNm}</Badge>
|
||||
<Badge>{file.version}</Badge>
|
||||
</div>
|
||||
<div className="text-[12px] font-bold mb-3 leading-[1.5]">
|
||||
{file.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
|
||||
<span style={{ fontSize: 12 }}>📄</span>
|
||||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
<Badge>📄 {file.fileTp || 'PDF'}</Badge>
|
||||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<button onClick={(e) => {
|
||||
@ -287,8 +255,7 @@ export function BoardView() {
|
||||
})
|
||||
setShowUploadModal(true)
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{ background: 'rgba(59,130,246,.1)', border: '1px solid rgba(59,130,246,.2)', color: '#3b82f6', cursor: 'pointer' }}
|
||||
className="wing-btn wing-btn-secondary text-[10px]"
|
||||
title="수정">
|
||||
✏️ 수정
|
||||
</button>
|
||||
@ -303,19 +270,18 @@ export function BoardView() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
style={{ background: 'rgba(239,68,68,.1)', border: '1px solid rgba(239,68,68,.2)', color: '#ef4444', cursor: 'pointer' }}
|
||||
className="wing-btn text-[10px] bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20"
|
||||
title="삭제">
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</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">
|
||||
<span>{file.authorNm}</span>
|
||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
<span className="text-[10px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
|
||||
⬇ {file.dwnldCnt}
|
||||
</span>
|
||||
<button onClick={async (e) => {
|
||||
@ -350,17 +316,13 @@ export function BoardView() {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="px-3 py-1 rounded text-[10px] font-semibold transition-all" style={{
|
||||
background: 'rgba(6,182,212,.1)', border: '1px solid rgba(6,182,212,.25)',
|
||||
color: '#22d3ee', cursor: 'pointer',
|
||||
}}>
|
||||
className="wing-btn wing-btn-primary text-[10px]">
|
||||
📥 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -392,16 +354,14 @@ export function BoardView() {
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">카테고리</label>
|
||||
<div className="flex gap-1.5">
|
||||
{['방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'].map(cat => {
|
||||
const cc = catColor(cat)
|
||||
const isActive = uploadForm.category === cat
|
||||
return (
|
||||
<button key={cat} onClick={() => setUploadForm(prev => ({ ...prev, category: cat }))}
|
||||
className="flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer"
|
||||
style={{
|
||||
background: isActive ? cc.bg : 'var(--bg3)',
|
||||
border: isActive ? `1px solid ${cc.text}40` : '1px solid var(--bd)',
|
||||
color: isActive ? cc.text : 'var(--t3)',
|
||||
}}>
|
||||
className={`flex-1 py-2 px-1 rounded-md text-[11px] font-semibold cursor-pointer border ${
|
||||
isActive
|
||||
? 'bg-primary-cyan/15 border-primary-cyan/30 text-primary-cyan'
|
||||
: 'bg-bg-3 border-border text-text-3'
|
||||
}`}>
|
||||
{cat}
|
||||
</button>
|
||||
)
|
||||
@ -412,13 +372,13 @@ export function BoardView() {
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">매뉴얼 제목</label>
|
||||
<input type="text" placeholder="매뉴얼 제목을 입력하세요" value={uploadForm.title}
|
||||
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>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">버전</label>
|
||||
<input type="text" placeholder="예: v1.0" value={uploadForm.version}
|
||||
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>
|
||||
<label className="block text-[11px] font-semibold text-text-2 mb-1.5">파일 첨부</label>
|
||||
@ -458,7 +418,7 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||||
<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 onClick={async () => {
|
||||
@ -491,7 +451,7 @@ export function BoardView() {
|
||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 rounded-md text-xs font-semibold cursor-pointer" style={{ background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee' }}>
|
||||
className="wing-btn wing-btn-primary px-6 py-2">
|
||||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
||||
</button>
|
||||
</div>
|
||||
@ -547,7 +507,7 @@ export function BoardView() {
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<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">
|
||||
총 <span className="text-text-1 font-semibold">{totalCount}</span>건
|
||||
</div>
|
||||
@ -558,12 +518,12 @@ export function BoardView() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
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') && (
|
||||
<button
|
||||
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>
|
||||
@ -579,42 +539,40 @@ export function BoardView() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse">
|
||||
<table className="wing-table">
|
||||
<thead>
|
||||
<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="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">분류</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-text-2">제목</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 w-24">작성자</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-text-2 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>
|
||||
<th className="wing-table-head text-center w-24">분류</th>
|
||||
<th className="wing-table-head">제목</th>
|
||||
<th className="wing-table-head text-center w-24">작성자</th>
|
||||
<th className="wing-table-head text-center w-28">작성일</th>
|
||||
<th className="wing-table-head text-center w-16">조회</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr
|
||||
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="px-4 py-4 text-center">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold ${CATEGORY_COLORS[post.categoryCd] || 'bg-blue-500/20 text-blue-400'}`}>
|
||||
{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}
|
||||
</span>
|
||||
<td className="wing-table-cell text-text-1 text-center">{post.sn}</td>
|
||||
<td className="wing-table-cell text-center">
|
||||
<Badge>{CATEGORY_LABELS[post.categoryCd] || post.categoryCd}</Badge>
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-4 cursor-pointer"
|
||||
className="wing-table-cell cursor-pointer"
|
||||
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`}>
|
||||
{post.pinnedYn === 'Y' && '📌 '}{post.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-text-2 text-center">{post.authorName}</td>
|
||||
<td className="px-4 py-4 text-sm text-text-3 text-center">
|
||||
<td className="wing-table-cell text-center">{post.authorName}</td>
|
||||
<td className="wing-table-cell text-text-3 text-center">
|
||||
{new Date(post.regDtm).toLocaleDateString('ko-KR')}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@ -636,10 +594,10 @@ export function BoardView() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`px-3 py-1.5 text-sm rounded transition-colors ${
|
||||
className={`wing-btn ${
|
||||
p === page
|
||||
? 'bg-primary-cyan/20 text-primary-cyan font-semibold'
|
||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3 hover:text-text-1'
|
||||
? 'wing-btn-primary font-semibold'
|
||||
: 'wing-btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
|
||||
@ -121,7 +121,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
return (
|
||||
<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">
|
||||
{isEditMode ? '게시글 수정' : '게시글 작성'}
|
||||
</h2>
|
||||
@ -140,7 +140,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
value={categoryCd}
|
||||
onChange={(e) => setCategoryCd(e.target.value)}
|
||||
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 => (
|
||||
<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)}
|
||||
maxLength={200}
|
||||
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>
|
||||
|
||||
@ -174,7 +174,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
maxLength={10000}
|
||||
placeholder="내용을 입력하세요"
|
||||
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>
|
||||
|
||||
@ -192,7 +192,7 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
/>
|
||||
<label
|
||||
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>
|
||||
@ -207,14 +207,14 @@ export function BoardWriteForm({ postSn, defaultCategoryCd, onSaveComplete, onCa
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
type="submit"
|
||||
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 ? '수정하기' : '등록하기'}
|
||||
</button>
|
||||
|
||||
@ -229,7 +229,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 사고명 직접 입력 */}
|
||||
<input
|
||||
className="prd-i w-full"
|
||||
className="wing-input w-full"
|
||||
value={accidentName}
|
||||
onChange={(e) => setAccidentName(e.target.value)}
|
||||
placeholder="사고명 직접 입력"
|
||||
@ -237,7 +237,7 @@ export function HNSLeftPanel({
|
||||
|
||||
{/* 또는 사고 리스트에서 선택 */}
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={selectedIncidentSn}
|
||||
onChange={handleSelectIncident}
|
||||
placeholder="또는 사고 리스트에서 선택"
|
||||
@ -252,13 +252,13 @@ export function HNSLeftPanel({
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">사고 발생 일시</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
type="date"
|
||||
value={accidentDate}
|
||||
onChange={(e) => setAccidentDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
type="time"
|
||||
value={accidentTime}
|
||||
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' }}>
|
||||
<input
|
||||
className="prd-i flex-1 font-mono"
|
||||
className="wing-input flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
||||
@ -280,7 +280,7 @@ export function HNSLeftPanel({
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="prd-i flex-1 font-mono"
|
||||
className="wing-input flex-1 font-mono"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
||||
@ -304,7 +304,7 @@ export function HNSLeftPanel({
|
||||
{/* 유출형태 + 물질명 */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={releaseType}
|
||||
onChange={(v) => setReleaseType(v as ReleaseType)}
|
||||
options={[
|
||||
@ -314,7 +314,7 @@ export function HNSLeftPanel({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={substance}
|
||||
onChange={handleSubstanceChange}
|
||||
options={[
|
||||
@ -335,14 +335,14 @@ export function HNSLeftPanel({
|
||||
{/* 유출량 + 단위 + 예측시간 */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||
<input
|
||||
className="prd-i font-mono"
|
||||
className="wing-input font-mono"
|
||||
type="number"
|
||||
value={releaseType === '순간 유출' ? totalRelease : emissionRate}
|
||||
onChange={(e) => releaseType === '순간 유출' ? setTotalRelease(e.target.value) : setEmissionRate(e.target.value)}
|
||||
placeholder={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={releaseType === '순간 유출' ? 'g' : 'g/s'}
|
||||
onChange={() => {}}
|
||||
options={
|
||||
@ -352,7 +352,7 @@ export function HNSLeftPanel({
|
||||
}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
@ -371,7 +371,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">예측 알고리즘</label>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={algorithm}
|
||||
onChange={setAlgorithm}
|
||||
options={[
|
||||
@ -385,7 +385,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">확산 등급 기준</label>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={criteriaModel}
|
||||
onChange={setCriteriaModel}
|
||||
options={[
|
||||
@ -419,7 +419,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={emissionRate}
|
||||
onChange={(e) => setEmissionRate(e.target.value)}
|
||||
@ -430,7 +430,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">지속시간 (s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={releaseDuration}
|
||||
onChange={(e) => setReleaseDuration(e.target.value)}
|
||||
@ -442,7 +442,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -459,7 +459,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">총 누출량 (g)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={totalRelease}
|
||||
onChange={(e) => setTotalRelease(e.target.value)}
|
||||
@ -470,7 +470,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -488,7 +488,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">배출률 (g/s)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={emissionRate}
|
||||
onChange={(e) => setEmissionRate(e.target.value)}
|
||||
@ -499,7 +499,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">풀 반경 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={poolRadius}
|
||||
onChange={(e) => setPoolRadius(e.target.value)}
|
||||
@ -511,7 +511,7 @@ export function HNSLeftPanel({
|
||||
<div>
|
||||
<label className="text-[10px] text-text-3 block mb-0.5">누출 높이 (m)</label>
|
||||
<input
|
||||
className="prd-i w-full font-mono"
|
||||
className="wing-input w-full font-mono"
|
||||
type="number"
|
||||
value={releaseHeight}
|
||||
onChange={(e) => setReleaseHeight(e.target.value)}
|
||||
@ -588,7 +588,7 @@ export function HNSLeftPanel({
|
||||
{/* 실행 버튼 */}
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<button
|
||||
className="prd-btn pri"
|
||||
className="wing-btn wing-btn-primary"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={onRunPrediction}
|
||||
disabled={isRunningPrediction}
|
||||
@ -596,7 +596,7 @@ export function HNSLeftPanel({
|
||||
{isRunningPrediction ? '⏳ 실행 중...' : '🧪 대기확산 예측 실행'}
|
||||
</button>
|
||||
<button
|
||||
className="prd-btn sec"
|
||||
className="wing-btn wing-btn-secondary"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={handleReset}
|
||||
>
|
||||
|
||||
@ -109,7 +109,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
{/* HNS 물질 */}
|
||||
<FG label="HNS 물질">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={substance}
|
||||
onChange={setSubstance}
|
||||
options={[
|
||||
@ -127,7 +127,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
<div className="grid grid-cols-2 gap-[10px]">
|
||||
<FG label="유출 유형">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={releaseType}
|
||||
onChange={(v) => setReleaseType(v as RecalcParams['releaseType'])}
|
||||
options={[
|
||||
@ -139,7 +139,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</FG>
|
||||
<FG label={`${amountLabel} (${amountUnit})`}>
|
||||
<input
|
||||
className="prd-i font-mono"
|
||||
className="wing-input font-mono"
|
||||
type="number"
|
||||
value={amount}
|
||||
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]">
|
||||
<FG label="예측 알고리즘">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={algorithm}
|
||||
onChange={setAlgorithm}
|
||||
options={[
|
||||
@ -165,7 +165,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit, currentParams }: HNS
|
||||
</FG>
|
||||
<FG label="예측 시간">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={predictionTime}
|
||||
onChange={setPredictionTime}
|
||||
options={[
|
||||
|
||||
@ -148,7 +148,7 @@ export function HNSScenarioView() {
|
||||
<select
|
||||
value={selectedIncident}
|
||||
onChange={(e) => setSelectedIncident(Number(e.target.value))}
|
||||
className="prd-i w-[280px] text-[11px]"
|
||||
className="wing-select w-[280px] text-[11px]"
|
||||
>
|
||||
{incidents.length === 0
|
||||
? <option value={0}>분석 데이터 없음</option>
|
||||
@ -708,16 +708,16 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
{/* 기본 정보 */}
|
||||
<ModalSection title="기본 정보">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<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>
|
||||
</div>
|
||||
</ModalSection>
|
||||
@ -725,7 +725,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
{/* 물질·유출 조건 */}
|
||||
<ModalSection title="물질 · 유출 조건">
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
@ -750,7 +750,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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="continuous">연속 유출</option>
|
||||
<option value="semi">반연속</option>
|
||||
@ -758,8 +758,8 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
</ModalField>
|
||||
<ModalField label="유출량">
|
||||
<div className="flex gap-1">
|
||||
<input className="prd-i 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)}>
|
||||
<input className="wing-input flex-1" type="number" value={amount} onChange={e => setAmount(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>)}
|
||||
</select>
|
||||
</div>
|
||||
@ -771,31 +771,31 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: {
|
||||
<ModalSection title="기상 조건">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
</div>
|
||||
<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>)}
|
||||
</select>
|
||||
</ModalField>
|
||||
|
||||
@ -151,12 +151,12 @@ const PredictionInputSection = ({
|
||||
{inputMode === 'direct' && (
|
||||
<>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
placeholder="사고명 직접 입력"
|
||||
value={incidentName}
|
||||
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
|
||||
className="prd-btn pri"
|
||||
className="wing-btn wing-btn-primary"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={handleAnalyze}
|
||||
disabled={!uploadedFile || isAnalyzing}
|
||||
@ -263,7 +263,7 @@ const PredictionInputSection = ({
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
type="datetime-local"
|
||||
value={accidentTime}
|
||||
onChange={(e) => onAccidentTimeChange(e.target.value)}
|
||||
@ -275,7 +275,7 @@ const PredictionInputSection = ({
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lat ?? ''}
|
||||
@ -286,7 +286,7 @@ const PredictionInputSection = ({
|
||||
placeholder="위도°"
|
||||
/>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={incidentCoord?.lon ?? ''}
|
||||
@ -315,7 +315,7 @@ const PredictionInputSection = ({
|
||||
{/* Oil Type + Oil Kind */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={spillType}
|
||||
onChange={onSpillTypeChange}
|
||||
options={[
|
||||
@ -325,7 +325,7 @@ const PredictionInputSection = ({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={oilType}
|
||||
onChange={onOilTypeChange}
|
||||
options={[
|
||||
@ -342,7 +342,7 @@ const PredictionInputSection = ({
|
||||
{/* Volume + Unit + Duration */}
|
||||
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
||||
<input
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
placeholder="유출량"
|
||||
type="number"
|
||||
min="1"
|
||||
@ -351,7 +351,7 @@ const PredictionInputSection = ({
|
||||
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={spillUnit}
|
||||
onChange={onSpillUnitChange}
|
||||
options={[
|
||||
@ -361,7 +361,7 @@ const PredictionInputSection = ({
|
||||
]}
|
||||
/>
|
||||
<ComboBox
|
||||
className="prd-i"
|
||||
className="wing-input"
|
||||
value={predictionTime}
|
||||
onChange={(v) => onPredictionTimeChange(parseInt(v))}
|
||||
options={[
|
||||
@ -430,7 +430,7 @@ const PredictionInputSection = ({
|
||||
|
||||
{/* Run Button */}
|
||||
<button
|
||||
className="prd-btn pri mt-0.5"
|
||||
className="wing-btn wing-btn-primary mt-0.5"
|
||||
style={{ padding: '7px', fontSize: '11px' }}
|
||||
onClick={onRunSimulation}
|
||||
disabled={isRunningSimulation}
|
||||
|
||||
@ -173,7 +173,7 @@ export function RecalcModal({
|
||||
{/* 유종 */}
|
||||
<FieldGroup label="유종">
|
||||
<select
|
||||
className="prd-i"
|
||||
className="wing-select"
|
||||
value={oilType}
|
||||
onChange={(e) => setOilType(e.target.value)}
|
||||
>
|
||||
@ -188,12 +188,12 @@ export function RecalcModal({
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i flex-1"
|
||||
className="wing-input flex-1"
|
||||
value={spillAmount}
|
||||
onChange={(e) => setSpillAmount(Number(e.target.value))}
|
||||
/>
|
||||
<select
|
||||
className="prd-i"
|
||||
className="wing-select"
|
||||
value={spillUnit}
|
||||
onChange={(e) => setSpillUnit(e.target.value as 'kl' | 'ton' | 'bbl')}
|
||||
style={{ width: '70px' }}
|
||||
@ -208,7 +208,7 @@ export function RecalcModal({
|
||||
{/* 유출 형태 */}
|
||||
<FieldGroup label="유출 형태">
|
||||
<select
|
||||
className="prd-i"
|
||||
className="wing-select"
|
||||
value={spillType}
|
||||
onChange={(e) => setSpillType(e.target.value)}
|
||||
>
|
||||
@ -221,7 +221,7 @@ export function RecalcModal({
|
||||
{/* 예측 시간 */}
|
||||
<FieldGroup label="예측 시간">
|
||||
<select
|
||||
className="prd-i"
|
||||
className="wing-select"
|
||||
value={predictionTime}
|
||||
onChange={(e) => setPredictionTime(Number(e.target.value))}
|
||||
>
|
||||
@ -240,7 +240,7 @@ export function RecalcModal({
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
className="wing-input font-mono"
|
||||
value={lat}
|
||||
step={0.0001}
|
||||
onChange={(e) => setLat(Number(e.target.value))}
|
||||
@ -252,7 +252,7 @@ export function RecalcModal({
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
className="prd-i font-mono"
|
||||
className="wing-input font-mono"
|
||||
value={lon}
|
||||
step={0.0001}
|
||||
onChange={(e) => setLon(Number(e.target.value))}
|
||||
|
||||
@ -66,7 +66,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={jurisdictionFilter}
|
||||
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
className="wing-select w-full"
|
||||
>
|
||||
<option>전체 (제주도)</option>
|
||||
<option>서귀포해양경비안전서</option>
|
||||
@ -81,7 +81,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => onAreaChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
className="wing-select w-full"
|
||||
>
|
||||
<option>전체</option>
|
||||
{zones.map((z) => (
|
||||
@ -99,7 +99,7 @@ function ScatLeftPanel({
|
||||
<select
|
||||
value={phaseFilter}
|
||||
onChange={(e) => onPhaseChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
className="wing-select w-full"
|
||||
>
|
||||
<option>Pre-SCAT (사전조사)</option>
|
||||
<option>SCAT (사고 시 조사)</option>
|
||||
@ -113,12 +113,12 @@ function ScatLeftPanel({
|
||||
placeholder="🔍 구간 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="prd-i flex-1"
|
||||
className="wing-input flex-1"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="prd-i w-[70px]"
|
||||
className="wing-select w-[70px]"
|
||||
>
|
||||
<option>전체</option>
|
||||
<option>완료</option>
|
||||
|
||||
207
frontend/src/tabs/showcase/ShowcaseView.tsx
Normal file
207
frontend/src/tabs/showcase/ShowcaseView.tsx
Normal file
@ -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;
|
||||
1
frontend/src/tabs/showcase/index.ts
Normal file
1
frontend/src/tabs/showcase/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as ShowcaseView } from './ShowcaseView';
|
||||
23
frontend/src/tabs/showcase/sections/BadgeSection.tsx
Normal file
23
frontend/src/tabs/showcase/sections/BadgeSection.tsx
Normal file
@ -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;
|
||||
48
frontend/src/tabs/showcase/sections/Border.tsx
Normal file
48
frontend/src/tabs/showcase/sections/Border.tsx
Normal file
@ -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;
|
||||
47
frontend/src/tabs/showcase/sections/BrandColor.tsx
Normal file
47
frontend/src/tabs/showcase/sections/BrandColor.tsx
Normal file
@ -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;
|
||||
31
frontend/src/tabs/showcase/sections/BrandLogo.tsx
Normal file
31
frontend/src/tabs/showcase/sections/BrandLogo.tsx
Normal file
@ -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;
|
||||
21
frontend/src/tabs/showcase/sections/ButtonSection.tsx
Normal file
21
frontend/src/tabs/showcase/sections/ButtonSection.tsx
Normal file
@ -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;
|
||||
30
frontend/src/tabs/showcase/sections/CardSection.tsx
Normal file
30
frontend/src/tabs/showcase/sections/CardSection.tsx
Normal file
@ -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;
|
||||
32
frontend/src/tabs/showcase/sections/ComboBoxSection.tsx
Normal file
32
frontend/src/tabs/showcase/sections/ComboBoxSection.tsx
Normal file
@ -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;
|
||||
39
frontend/src/tabs/showcase/sections/DataTableSection.tsx
Normal file
39
frontend/src/tabs/showcase/sections/DataTableSection.tsx
Normal file
@ -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;
|
||||
31
frontend/src/tabs/showcase/sections/Icons.tsx
Normal file
31
frontend/src/tabs/showcase/sections/Icons.tsx
Normal file
@ -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;
|
||||
53
frontend/src/tabs/showcase/sections/InputSection.tsx
Normal file
53
frontend/src/tabs/showcase/sections/InputSection.tsx
Normal file
@ -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;
|
||||
46
frontend/src/tabs/showcase/sections/ModalSection.tsx
Normal file
46
frontend/src/tabs/showcase/sections/ModalSection.tsx
Normal file
@ -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;
|
||||
30
frontend/src/tabs/showcase/sections/PaginationSection.tsx
Normal file
30
frontend/src/tabs/showcase/sections/PaginationSection.tsx
Normal file
@ -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;
|
||||
67
frontend/src/tabs/showcase/sections/SidePanelSection.tsx
Normal file
67
frontend/src/tabs/showcase/sections/SidePanelSection.tsx
Normal file
@ -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;
|
||||
25
frontend/src/tabs/showcase/sections/Spacing.tsx
Normal file
25
frontend/src/tabs/showcase/sections/Spacing.tsx
Normal file
@ -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;
|
||||
43
frontend/src/tabs/showcase/sections/Typography.tsx
Normal file
43
frontend/src/tabs/showcase/sections/Typography.tsx
Normal file
@ -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'],
|
||||
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: {
|
||||
sm: '6px',
|
||||
md: '10px',
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user