first commit
This commit is contained in:
68
.gitignore
vendored
Normal file
68
.gitignore
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Production
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Build files
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 아키텍처 문서 디렉터리 (참고용, 커밋 제외)
|
||||||
|
base_arcitectures_md/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
.vite/
|
||||||
37
ENV_SETUP.md
Normal file
37
ENV_SETUP.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 환경 변수 파일 설정
|
||||||
|
|
||||||
|
다음 파일들을 프로젝트 루트에 생성해주세요:
|
||||||
|
|
||||||
|
## .env.development
|
||||||
|
```
|
||||||
|
# 개발 환경 변수
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
|
VITE_API_PREFIX=/api
|
||||||
|
VITE_HTTP_TIMEOUT_MS=20000
|
||||||
|
VITE_HTTP_LOGGING=true
|
||||||
|
|
||||||
|
# TanStack Query 사용 여부 (옵션)
|
||||||
|
# true: TanStack Query 활성화 (서버 상태 관리)
|
||||||
|
# false 또는 미설정: TanStack Query 비활성화 (직접 fetch 사용)
|
||||||
|
VITE_USE_REACT_QUERY=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## .env.production
|
||||||
|
```
|
||||||
|
# 운영 환경 변수
|
||||||
|
VITE_API_BASE_URL=https://api.example.com
|
||||||
|
VITE_API_PREFIX=/api
|
||||||
|
VITE_HTTP_TIMEOUT_MS=20000
|
||||||
|
VITE_HTTP_LOGGING=false
|
||||||
|
|
||||||
|
# TanStack Query 사용 여부 (옵션)
|
||||||
|
VITE_USE_REACT_QUERY=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수 설명
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL`: API 서버 기본 URL
|
||||||
|
- `VITE_API_PREFIX`: API 경로 prefix (기본값: /api)
|
||||||
|
- `VITE_HTTP_TIMEOUT_MS`: HTTP 요청 타임아웃 (밀리초, 기본값: 20000)
|
||||||
|
- `VITE_HTTP_LOGGING`: HTTP 로깅 활성화 여부 (개발: true, 운영: false)
|
||||||
|
- `VITE_USE_REACT_QUERY`: TanStack Query 사용 여부 (옵션, 기본값: false)
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3635
package-lock.json
generated
Normal file
3635
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"_comment": "의존성 설치: npm install (또는 npm ci)",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"@tanstack/react-query": "^5.62.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/app/App.css
Normal file
42
src/app/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
35
src/app/App.tsx
Normal file
35
src/app/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import reactLogo from '../assets/react.svg'
|
||||||
|
import viteLogo from '/vite.svg'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<a href="https://vite.dev" target="_blank">
|
||||||
|
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://react.dev" target="_blank">
|
||||||
|
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1>Vite + React</h1>
|
||||||
|
<div className="card">
|
||||||
|
<button onClick={() => setCount((count) => count + 1)}>
|
||||||
|
count is {count}
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
Edit <code>src/app/App.tsx</code> and save to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="read-the-docs">
|
||||||
|
Click on the Vite and React logos to learn more
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
28
src/common/api/endpoints.ts
Normal file
28
src/common/api/endpoints.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* API 경로 상수 정의
|
||||||
|
*
|
||||||
|
* 목표:
|
||||||
|
* - 문자열 API 경로를 코드 전역에 흩뿌리지 않음
|
||||||
|
* - route 변경/버전 변경 시 영향 범위 최소화
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - API 경로는 endpoints.ts로만 관리
|
||||||
|
* - feature 코드에서 문자열 URL 직접 사용 금지
|
||||||
|
*/
|
||||||
|
export const API = {
|
||||||
|
AUTH: {
|
||||||
|
CSRF: "/auth/csrf",
|
||||||
|
LOGIN: "/auth/login",
|
||||||
|
LOGOUT: "/auth/logout",
|
||||||
|
ME: "/auth/me",
|
||||||
|
},
|
||||||
|
USER: {
|
||||||
|
LIST: "/users",
|
||||||
|
DETAIL: (id: string | number) => `/users/${encodeURIComponent(id)}`,
|
||||||
|
ME: "/users/me",
|
||||||
|
},
|
||||||
|
DOCUMENT: {
|
||||||
|
LIST: "/documents",
|
||||||
|
DETAIL: (id: string | number) => `/documents/${encodeURIComponent(id)}`,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
22
src/common/api/http.ts
Normal file
22
src/common/api/http.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios 인스턴스 설정
|
||||||
|
*
|
||||||
|
* - 세션 기반 인증 (withCredentials: true)
|
||||||
|
* - 환경 변수 기반 baseURL 설정
|
||||||
|
* - 타임아웃 설정
|
||||||
|
*/
|
||||||
|
const baseURL = `${import.meta.env.VITE_API_BASE_URL ?? ""}${import.meta.env.VITE_API_PREFIX ?? "/api"}`
|
||||||
|
.replace(/\/+$/g, "");
|
||||||
|
const timeout = Number(import.meta.env.VITE_HTTP_TIMEOUT_MS ?? 20000);
|
||||||
|
|
||||||
|
export const http = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
});
|
||||||
107
src/common/api/interceptors.ts
Normal file
107
src/common/api/interceptors.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { http } from "./http";
|
||||||
|
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios Interceptor 설정
|
||||||
|
*
|
||||||
|
* 역할:
|
||||||
|
* - 모든 요청은 전역 로딩과 연결
|
||||||
|
* - 모든 응답은 로딩 종료 보장
|
||||||
|
* - 401 응답은 전역 인증 만료로 처리
|
||||||
|
* - 401 재시도 루프 방지
|
||||||
|
* - 민감정보는 절대 로그에 남기지 않음
|
||||||
|
*
|
||||||
|
* 보안 규칙:
|
||||||
|
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||||
|
* - 401인데 아무 처리 없이 콘솔만 출력 금지
|
||||||
|
*
|
||||||
|
* 주의:
|
||||||
|
* - loading.start/end는 동적으로 주입받아야 함 (순환 참조 방지)
|
||||||
|
* - window 객체를 통해 접근
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 401 재시도 루프 방지 플래그
|
||||||
|
let isHandling401 = false;
|
||||||
|
|
||||||
|
// 로딩 제어 함수 (동적 주입)
|
||||||
|
let loadingStart: (() => void) | null = null;
|
||||||
|
let loadingEnd: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로딩 제어 함수 등록 (LoadingProvider에서 호출)
|
||||||
|
*/
|
||||||
|
export function setLoadingControls(start: () => void, end: () => void) {
|
||||||
|
loadingStart = start;
|
||||||
|
loadingEnd = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 인터셉터
|
||||||
|
*/
|
||||||
|
http.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
// 로딩 시작 (X-Skip-Loading 헤더가 없을 때만)
|
||||||
|
if (!config.headers?.["X-Skip-Loading"] && loadingStart) {
|
||||||
|
loadingStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// 요청 에러 시에도 로딩 종료
|
||||||
|
if (loadingEnd) {
|
||||||
|
loadingEnd();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 인터셉터
|
||||||
|
*/
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// 로딩 종료 (X-Skip-Loading 헤더가 없을 때만)
|
||||||
|
if (!response.config.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||||
|
loadingEnd();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
// 로딩 종료 (에러 시에도)
|
||||||
|
if (!error.config?.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||||
|
loadingEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = error.response?.status;
|
||||||
|
|
||||||
|
// 401 처리 (인증 만료)
|
||||||
|
if (status === 401 && !isHandling401) {
|
||||||
|
isHandling401 = true;
|
||||||
|
|
||||||
|
// 전역 인증 만료 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("app:unauthorized"));
|
||||||
|
|
||||||
|
// 플래그 리셋 (다음 요청을 위해)
|
||||||
|
setTimeout(() => {
|
||||||
|
isHandling401 = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 처리 (권한 없음)
|
||||||
|
if (status === 403) {
|
||||||
|
// 필요 시 권한 없음 이벤트 발생
|
||||||
|
// window.dispatchEvent(new CustomEvent("app:forbidden"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로깅 (민감정보 제외)
|
||||||
|
// 운영 환경에서는 console.log 금지
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_HTTP_LOGGING === "true") {
|
||||||
|
const url = error.config?.url;
|
||||||
|
const method = error.config?.method?.toUpperCase();
|
||||||
|
console.warn(`[API Error] ${method} ${url} - ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
24
src/common/auth/AuthContext.tsx
Normal file
24
src/common/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { AuthContextValue } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 Context 정의
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - 인증 상태 및 사용자 정보는 AuthContext에서만 관리
|
||||||
|
* - 페이지별 /me 호출로 중복 상태 생성 금지
|
||||||
|
* - 앱 초기 로딩에서 1회 /me 호출 + refresh 로직만 허용
|
||||||
|
*/
|
||||||
|
export const AuthContext = createContext<AuthContextValue>({
|
||||||
|
loading: true,
|
||||||
|
user: null,
|
||||||
|
login: async () => {
|
||||||
|
throw new Error("AuthProvider not initialized");
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
throw new Error("AuthProvider not initialized");
|
||||||
|
},
|
||||||
|
refreshMe: async () => {
|
||||||
|
throw new Error("AuthProvider not initialized");
|
||||||
|
},
|
||||||
|
});
|
||||||
97
src/common/auth/AuthProvider.tsx
Normal file
97
src/common/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { AuthContext } from "./AuthContext";
|
||||||
|
import { authService } from "./authService";
|
||||||
|
import type { User, AuthContextValue } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 Provider
|
||||||
|
*
|
||||||
|
* 책임:
|
||||||
|
* - 앱 최초 로딩 시 사용자 상태 확정
|
||||||
|
* - /api/auth/me 호출
|
||||||
|
* - 인증 성공 시 user 상태 설정
|
||||||
|
* - 인증 실패(401) 시 user = null 처리
|
||||||
|
* - 전역 unauthorized 이벤트 대응
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - AuthProvider가 초기 /me 호출 → user set
|
||||||
|
* - 페이지는 useAuth로 읽기만
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 정보 갱신
|
||||||
|
*/
|
||||||
|
const refreshMe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const me = await authService.me();
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*/
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
await authService.login(username, password);
|
||||||
|
// 로그인 성공 후 사용자 정보 갱신
|
||||||
|
await refreshMe();
|
||||||
|
}, [refreshMe]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*/
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await authService.logout();
|
||||||
|
} catch {
|
||||||
|
// 로그아웃 실패해도 상태는 초기화
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기 사용자 정보 조회
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
refreshMe();
|
||||||
|
}, [refreshMe]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 401 이벤트 리스너
|
||||||
|
* interceptor에서 발생시킨 이벤트 처리
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnauthorized = () => {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("app:unauthorized", handleUnauthorized);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("app:unauthorized", handleUnauthorized);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
loading,
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshMe,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/common/auth/ProtectedRoute.tsx
Normal file
29
src/common/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보호된 라우트
|
||||||
|
*
|
||||||
|
* 역할:
|
||||||
|
* - 인증이 필요한 페이지 접근 제어
|
||||||
|
* - 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - ProtectedRoute는 라우팅 레벨에서 적용
|
||||||
|
* - 버튼 숨김/메뉴 숨김은 보안이 아님 (라우트 차단 필수)
|
||||||
|
*/
|
||||||
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { loading, user } = useAuth();
|
||||||
|
|
||||||
|
// 로딩 중에는 아무것도 렌더링하지 않음
|
||||||
|
if (loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증되지 않은 경우 로그인 페이지로 리다이렉트
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
51
src/common/auth/authService.ts
Normal file
51
src/common/auth/authService.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { http } from "../api/http";
|
||||||
|
import { API } from "../api/endpoints";
|
||||||
|
import type { User } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 서비스
|
||||||
|
*
|
||||||
|
* 역할:
|
||||||
|
* - login / logout / me API 호출
|
||||||
|
* - 세션 기반 인증 (withCredentials 자동 처리)
|
||||||
|
*
|
||||||
|
* 보안 규칙:
|
||||||
|
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||||
|
* - 토큰을 프론트엔드에서 직접 관리하지 않음
|
||||||
|
*/
|
||||||
|
export const authService = {
|
||||||
|
/**
|
||||||
|
* 로그인
|
||||||
|
*/
|
||||||
|
async login(username: string, password: string): Promise<void> {
|
||||||
|
await http.post(API.AUTH.LOGIN, { username, password });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃
|
||||||
|
*/
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
await http.post(API.AUTH.LOGOUT);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자 정보 조회
|
||||||
|
*/
|
||||||
|
async me(): Promise<User | null> {
|
||||||
|
try {
|
||||||
|
const response = await http.get(API.AUTH.ME);
|
||||||
|
// ApiResponse 구조에 맞게 data.data 접근
|
||||||
|
return response.data?.data ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
// 401 등 에러 시 null 반환
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF 토큰 초기화
|
||||||
|
*/
|
||||||
|
async csrf(): Promise<void> {
|
||||||
|
await http.get(API.AUTH.CSRF);
|
||||||
|
},
|
||||||
|
};
|
||||||
32
src/common/auth/types.ts
Normal file
32
src/common/auth/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 인증 관련 타입 정의
|
||||||
|
*
|
||||||
|
* 백엔드 MeResponse와 일치하도록 정의
|
||||||
|
* - userId: 사용자 ID (number)
|
||||||
|
* - email: 이메일 (string, 필수)
|
||||||
|
* - displayName: 표시 이름 (string, 필수)
|
||||||
|
* - role: 역할 (string, 필수)
|
||||||
|
*
|
||||||
|
* 보안 규칙:
|
||||||
|
* - password, token 등 민감정보 포함 금지
|
||||||
|
* - 최소 정보만 포함
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
loading: boolean;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthActions {
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshMe: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextValue extends AuthState, AuthActions {}
|
||||||
14
src/common/auth/useAuth.ts
Normal file
14
src/common/auth/useAuth.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext } from "./AuthContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthContext 접근 훅
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* ```tsx
|
||||||
|
* const { loading, user, login, logout } = useAuth();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
68
src/common/components/action/Button.tsx
Normal file
68
src/common/components/action/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
* - 범용성 위주, 과도한 추상화 지양
|
||||||
|
* - 동작은 props 옵션으로만 제어
|
||||||
|
*
|
||||||
|
* 지원 옵션:
|
||||||
|
* - disabled: 비활성화
|
||||||
|
* - loading: 로딩 상태 (중복 클릭 방지)
|
||||||
|
* - size: 버튼 크기
|
||||||
|
* - variant: 버튼 스타일 변형
|
||||||
|
*/
|
||||||
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "outline" | "ghost";
|
||||||
|
|
||||||
|
export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
size?: ButtonSize;
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
size = "md",
|
||||||
|
variant = "primary",
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled || loading) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClass = `btn-${size}`;
|
||||||
|
const variantClass = `btn-${variant}`;
|
||||||
|
const loadingClass = loading ? "btn-loading" : "";
|
||||||
|
const disabledClass = disabled || loading ? "btn-disabled" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
className={`btn ${sizeClass} ${variantClass} ${loadingClass} ${disabledClass} ${className}`.trim()}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={handleClick}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && <span className="btn-spinner" aria-hidden="true" />}
|
||||||
|
<span className={loading ? "btn-content-loading" : ""}>{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/common/components/action/index.ts
Normal file
4
src/common/components/action/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Action 컴포넌트 통합 export
|
||||||
|
*/
|
||||||
|
export * from "./Button";
|
||||||
189
src/common/components/display/table/Table.tsx
Normal file
189
src/common/components/display/table/Table.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { SortValue, TableColumn, RowSelectionState } from "./types";
|
||||||
|
import { TableEmpty } from "./TableEmpty";
|
||||||
|
import { TableSkeleton } from "./TableSkeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 Table 컴포넌트
|
||||||
|
*
|
||||||
|
* 목표:
|
||||||
|
* - 페이지마다 테이블이 달라도 "동작"은 동일하게 유지
|
||||||
|
* - 로딩, Empty, RowClick, Selection, Server Sort UI를 표준화
|
||||||
|
* - 데이터 fetch는 Table이 하지 않음
|
||||||
|
* - 페이지 상태(page/size/sort/filter)는 URL state가 담당
|
||||||
|
*
|
||||||
|
* Table이 책임지는 것:
|
||||||
|
* - 로딩 표시(영역 Skeleton)
|
||||||
|
* - Empty 표시
|
||||||
|
* - Row 클릭 핸들링(옵션)
|
||||||
|
* - Selection(checkbox) 핸들링(옵션)
|
||||||
|
* - 서버 정렬 UI 표시 및 정렬 변경 이벤트 발생
|
||||||
|
*
|
||||||
|
* Table이 책임지지 않는 것:
|
||||||
|
* - 데이터 fetch
|
||||||
|
* - page/size/filter 상태 관리
|
||||||
|
* - 비즈니스 로직 처리(권한에 따른 컬럼 숨김, API 조건 조합 등)
|
||||||
|
*/
|
||||||
|
export interface TableProps<T> {
|
||||||
|
columns: Array<TableColumn<T>>;
|
||||||
|
rows: T[];
|
||||||
|
getRowId: (row: T) => string;
|
||||||
|
|
||||||
|
loading?: boolean;
|
||||||
|
emptyText?: string;
|
||||||
|
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
|
||||||
|
selection?: RowSelectionState;
|
||||||
|
|
||||||
|
sort?: SortValue;
|
||||||
|
onSortChange?: (next: SortValue) => void;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
getRowId,
|
||||||
|
loading,
|
||||||
|
emptyText,
|
||||||
|
onRowClick,
|
||||||
|
selection,
|
||||||
|
sort,
|
||||||
|
onSortChange,
|
||||||
|
className,
|
||||||
|
}: TableProps<T>) {
|
||||||
|
const allIds = rows.map(getRowId);
|
||||||
|
const allSelected =
|
||||||
|
selection
|
||||||
|
? allIds.length > 0 && allIds.every((id) => selection.selectedIds.has(id))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
function toggleSort(col: TableColumn<T>) {
|
||||||
|
if (!col.sortable || !onSortChange) return;
|
||||||
|
const key = col.sortKey ?? col.key;
|
||||||
|
|
||||||
|
if (!sort || sort.key !== key) {
|
||||||
|
return onSortChange({ key, dir: "asc" });
|
||||||
|
}
|
||||||
|
if (sort.dir === "asc") {
|
||||||
|
return onSortChange({ key, dir: "desc" });
|
||||||
|
}
|
||||||
|
return onSortChange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
border: "1px solid rgba(255,255,255,0.12)",
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
|
<thead style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||||
|
<tr>
|
||||||
|
{selection ? (
|
||||||
|
<th style={{ padding: 10, width: 44 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allSelected}
|
||||||
|
onChange={() => selection.toggleAll(allIds)}
|
||||||
|
aria-label="select all"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{columns.map((c) => {
|
||||||
|
const isSorted = sort && (c.sortKey ?? c.key) === sort.key;
|
||||||
|
const sortMark = isSorted
|
||||||
|
? sort!.dir === "asc"
|
||||||
|
? " ▲"
|
||||||
|
: " ▼"
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={c.key}
|
||||||
|
onClick={() => toggleSort(c)}
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
textAlign: c.align ?? "left",
|
||||||
|
width: c.width,
|
||||||
|
cursor: c.sortable ? "pointer" : "default",
|
||||||
|
userSelect: "none",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
{c.sortable ? sortMark : null}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={(selection ? 1 : 0) + columns.length}
|
||||||
|
style={{ padding: 12 }}
|
||||||
|
>
|
||||||
|
<TableSkeleton rows={8} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={(selection ? 1 : 0) + columns.length}
|
||||||
|
style={{ padding: 12 }}
|
||||||
|
>
|
||||||
|
<TableEmpty text={emptyText ?? "데이터가 없습니다."} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => {
|
||||||
|
const id = getRowId(row);
|
||||||
|
const clickable = Boolean(onRowClick);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
style={{
|
||||||
|
cursor: clickable ? "pointer" : "default",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selection ? (
|
||||||
|
<td
|
||||||
|
style={{ padding: 10, width: 44 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selection.selectedIds.has(id)}
|
||||||
|
onChange={() => selection.toggle(id)}
|
||||||
|
aria-label={`select ${id}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{columns.map((c) => (
|
||||||
|
<td
|
||||||
|
key={c.key}
|
||||||
|
style={{ padding: 10, textAlign: c.align ?? "left" }}
|
||||||
|
>
|
||||||
|
{c.cell(row)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/common/components/display/table/TableEmpty.tsx
Normal file
16
src/common/components/display/table/TableEmpty.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Table Empty 컴포넌트
|
||||||
|
*
|
||||||
|
* 데이터가 없을 때 표시되는 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface TableEmptyProps {
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableEmpty({ text }: TableEmptyProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 12, textAlign: "center", color: "#888" }}>
|
||||||
|
{text ?? "데이터가 없습니다."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/common/components/display/table/TablePagination.tsx
Normal file
60
src/common/components/display/table/TablePagination.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Table Pagination 컴포넌트
|
||||||
|
*
|
||||||
|
* 주의:
|
||||||
|
* - Pagination은 Table 내부 상태로 두지 않음
|
||||||
|
* - Pagination은 URL state 업데이트만 호출
|
||||||
|
*/
|
||||||
|
export interface TablePaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onChangePage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePagination({ page, totalPages, onChangePage }: TablePaginationProps) {
|
||||||
|
const tp = Math.max(1, totalPages ?? 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChangePage(1)}
|
||||||
|
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
{"<<"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onChangePage(page - 1)}
|
||||||
|
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<span style={{ padding: "0 8px" }}>
|
||||||
|
Page {page} / {tp}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={page >= tp}
|
||||||
|
onClick={() => onChangePage(page + 1)}
|
||||||
|
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page >= tp}
|
||||||
|
onClick={() => onChangePage(tp)}
|
||||||
|
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
{">>"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Table Skeleton 컴포넌트
|
||||||
|
*
|
||||||
|
* 로딩 중 표시되는 스켈레톤 UI
|
||||||
|
*/
|
||||||
|
export interface TableSkeletonProps {
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 8 }: TableSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: 14,
|
||||||
|
background: "rgba(255,255,255,0.06)",
|
||||||
|
borderRadius: 6,
|
||||||
|
animation: "pulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/common/components/display/table/index.ts
Normal file
6
src/common/components/display/table/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { Table } from "./Table";
|
||||||
|
export type { TableProps } from "./Table";
|
||||||
|
export { TableEmpty } from "./TableEmpty";
|
||||||
|
export { TableSkeleton } from "./TableSkeleton";
|
||||||
|
export { TablePagination } from "./TablePagination";
|
||||||
|
export type { TableColumn, SortValue, RowSelectionState, ColumnAlign } from "./types";
|
||||||
33
src/common/components/display/table/types.ts
Normal file
33
src/common/components/display/table/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 설계 원칙:
|
||||||
|
* - Column 정의는 "표준 타입"으로만 작성
|
||||||
|
* - Column 정의는 페이지마다 임의로 만들지 않음
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ColumnAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
|
export type TableColumn<T> = {
|
||||||
|
key: string;
|
||||||
|
header: React.ReactNode;
|
||||||
|
width?: number | string;
|
||||||
|
align?: ColumnAlign;
|
||||||
|
cell: (row: T) => React.ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortValue = {
|
||||||
|
key: string;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type RowSelectionState = {
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
toggle: (id: string) => void;
|
||||||
|
toggleAll: (ids: string[]) => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
45
src/common/components/form/Checkbox.tsx
Normal file
45
src/common/components/form/Checkbox.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkbox 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
* - 기본 UI 통일 목적
|
||||||
|
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||||
|
*/
|
||||||
|
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
checked = false,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: CheckboxProps) {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={`checkbox-wrapper ${className}`.trim()}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{label && <span className="checkbox-label">{label}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/common/components/form/Input.tsx
Normal file
183
src/common/components/form/Input.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||||
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
* - 범용성 위주, 과도한 추상화 지양
|
||||||
|
* - 동작은 props 옵션으로만 제어
|
||||||
|
*
|
||||||
|
* 지원 타입:
|
||||||
|
* - text: 기본 텍스트 입력
|
||||||
|
* - password: 비밀번호 입력
|
||||||
|
* - number: 숫자 입력 (숫자 이외 문자 자동 제거)
|
||||||
|
*
|
||||||
|
* text 타입 옵션:
|
||||||
|
* - formatEmail: 이메일 형식 자동 포맷
|
||||||
|
* - formatPhone: 전화번호 형식 자동 포맷
|
||||||
|
* - validationRegex: 정규식 검증 (옵션)
|
||||||
|
*
|
||||||
|
* 검색 옵션:
|
||||||
|
* - debounce, commitOnEnter, clearOnEscape 등
|
||||||
|
*/
|
||||||
|
export interface InputSearchOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
debounceMs?: number;
|
||||||
|
autoCommit?: boolean;
|
||||||
|
commitOnEnter?: boolean;
|
||||||
|
clearOnEscape?: boolean;
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputFormatOptions {
|
||||||
|
formatEmail?: boolean;
|
||||||
|
formatPhone?: boolean;
|
||||||
|
validationRegex?: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||||
|
type?: "text" | "password" | "number";
|
||||||
|
value: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
search?: InputSearchOptions;
|
||||||
|
format?: InputFormatOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({
|
||||||
|
type = "text",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
search,
|
||||||
|
format,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: InputProps) {
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// value prop 변경 시 localValue 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// 포맷 적용 함수
|
||||||
|
const applyFormat = (v: string): string => {
|
||||||
|
if (type !== "text" || !format) return v;
|
||||||
|
|
||||||
|
// 이메일 포맷 (자동 포맷은 적용하지 않음, 검증만)
|
||||||
|
if (format.formatEmail) {
|
||||||
|
// 이메일 형식 검증은 onChange에서 처리하지 않고, 외부에서 처리
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전화번호 포맷 (010-1234-5678)
|
||||||
|
if (format.formatPhone) {
|
||||||
|
const digits = v.replace(/\D/g, "");
|
||||||
|
if (digits.length <= 3) return digits;
|
||||||
|
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||||
|
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정규식 검증
|
||||||
|
const validateRegex = (v: string): boolean => {
|
||||||
|
if (!format?.validationRegex) return true;
|
||||||
|
return format.validationRegex.test(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 커밋
|
||||||
|
const commit = (v: string) => {
|
||||||
|
search?.onCommit?.(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let v = e.target.value;
|
||||||
|
|
||||||
|
// number 타입: 숫자 이외 문자 제거
|
||||||
|
if (type === "number") {
|
||||||
|
v = v.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포맷 적용 (text 타입만)
|
||||||
|
if (type === "text" && format) {
|
||||||
|
v = applyFormat(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정규식 검증 (실패 시 이전 값 유지)
|
||||||
|
if (format?.validationRegex && !validateRegex(v)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalValue(v);
|
||||||
|
onChange?.(v);
|
||||||
|
|
||||||
|
if (!search?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCommit이면 즉시 커밋
|
||||||
|
if (search.autoCommit) {
|
||||||
|
commit(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounce 처리
|
||||||
|
if (search.debounceMs) {
|
||||||
|
if (timer.current !== null) {
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
}
|
||||||
|
timer.current = window.setTimeout(() => {
|
||||||
|
commit(v);
|
||||||
|
}, search.debounceMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!search?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter 키 커밋
|
||||||
|
if (e.key === "Enter" && search.commitOnEnter) {
|
||||||
|
e.preventDefault();
|
||||||
|
commit(localValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape 키 초기화
|
||||||
|
if (e.key === "Escape" && search.clearOnEscape) {
|
||||||
|
setLocalValue("");
|
||||||
|
onChange?.("");
|
||||||
|
commit("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timer.current !== null) {
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
className={`input ${className}`.trim()}
|
||||||
|
value={localValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/common/components/form/Radio.tsx
Normal file
45
src/common/components/form/Radio.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
* - 기본 UI 통일 목적
|
||||||
|
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||||
|
*/
|
||||||
|
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||||
|
checked?: boolean;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Radio({
|
||||||
|
checked = false,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: RadioProps) {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange?.(e.target.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={`radio-wrapper ${className}`.trim()}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type="radio"
|
||||||
|
className="radio"
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{label && <span className="radio-label">{label}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/common/components/form/Select.tsx
Normal file
64
src/common/components/form/Select.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
* - 기본 select 기능을 공통 컴포넌트화
|
||||||
|
* - 스타일 통일 목적 위주
|
||||||
|
*/
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
|
||||||
|
options: SelectOption[];
|
||||||
|
value?: string | number;
|
||||||
|
onChange?: (value: string | number) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: SelectProps) {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedValue = e.target.value;
|
||||||
|
// 숫자 옵션이면 숫자로 변환 시도
|
||||||
|
const numValue = Number(selectedValue);
|
||||||
|
const finalValue = isNaN(numValue) ? selectedValue : numValue;
|
||||||
|
onChange?.(finalValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
className={`select ${className}`.trim()}
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/common/components/form/Textarea.tsx
Normal file
46
src/common/components/form/Textarea.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Textarea 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
*
|
||||||
|
* 기본 속성:
|
||||||
|
* - resize: none
|
||||||
|
* - width: 100%
|
||||||
|
*/
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
value: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: TextareaProps) {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
onChange?.(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
className={`textarea ${className}`.trim()}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{
|
||||||
|
resize: "none",
|
||||||
|
width: "100%",
|
||||||
|
...props.style,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/common/components/form/index.ts
Normal file
8
src/common/components/form/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Form 컴포넌트 통합 export
|
||||||
|
*/
|
||||||
|
export * from "./Input";
|
||||||
|
export * from "./Select";
|
||||||
|
export * from "./Textarea";
|
||||||
|
export * from "./Checkbox";
|
||||||
|
export * from "./Radio";
|
||||||
23
src/common/components/index.ts
Normal file
23
src/common/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 공통 컴포넌트 통합 export
|
||||||
|
*
|
||||||
|
* 모든 공통 컴포넌트는 여기서 export하여 사용 편의성 제공
|
||||||
|
*
|
||||||
|
* 디렉터리 구조:
|
||||||
|
* - form/: 폼 입력 컴포넌트 (Input, Select, Textarea, Checkbox, Radio)
|
||||||
|
* - action/: 액션 컴포넌트 (Button)
|
||||||
|
* - display/: 표시 컴포넌트 (Table)
|
||||||
|
* - system/: 시스템 컴포넌트 (LoadingOverlay, LoadingProvider, Toast, CommonModal)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Form 컴포넌트
|
||||||
|
export * from "./form";
|
||||||
|
|
||||||
|
// Action 컴포넌트
|
||||||
|
export * from "./action";
|
||||||
|
|
||||||
|
// Display 컴포넌트
|
||||||
|
export * from "./display/table";
|
||||||
|
|
||||||
|
// System 컴포넌트
|
||||||
|
export * from "./system";
|
||||||
237
src/common/components/system/CommonModal.tsx
Normal file
237
src/common/components/system/CommonModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommonModal 공통 컴포넌트
|
||||||
|
*
|
||||||
|
* 공통 원칙:
|
||||||
|
* - id, name, className 전달 옵션 지원
|
||||||
|
* - 스타일은 CSS로 처리
|
||||||
|
*
|
||||||
|
* 구조:
|
||||||
|
* - 헤더: 타이틀, X 닫기 버튼
|
||||||
|
* - 컨텐츠: children
|
||||||
|
* - 푸터: 확인/닫기 버튼 (옵션)
|
||||||
|
*
|
||||||
|
* 사이즈:
|
||||||
|
* - 기본: width 50vw, height 50vh
|
||||||
|
* - 커스텀 사이즈 옵션 제공
|
||||||
|
*/
|
||||||
|
export interface CommonModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
// 푸터 옵션
|
||||||
|
showFooter?: boolean;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
|
||||||
|
// 사이즈 옵션
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
|
||||||
|
// 공통 속성
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommonModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
showFooter = true,
|
||||||
|
onConfirm,
|
||||||
|
confirmText = "확인",
|
||||||
|
cancelText = "닫기",
|
||||||
|
width = "50vw",
|
||||||
|
height = "50vh",
|
||||||
|
id,
|
||||||
|
className = "",
|
||||||
|
}: CommonModalProps) {
|
||||||
|
// ESC 키로 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleEscape);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
// 모달 열릴 때 body 스크롤 방지
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalStyle: React.CSSProperties = {
|
||||||
|
width: typeof width === "number" ? `${width}px` : width,
|
||||||
|
height: typeof height === "number" ? `${height}px` : height,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`modal-backdrop ${className}`.trim()}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0, 0, 0, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 10000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={`modal ${className}`.trim()}
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
maxWidth: "90vw",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
...modalStyle,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="modal-header"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: "1px solid #e0e0e0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3
|
||||||
|
className="modal-title"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="닫기"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "24px",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨텐츠 */}
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "20px",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
{showFooter && (
|
||||||
|
<div
|
||||||
|
className="modal-footer"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: onConfirm ? "flex-end" : "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderTop: "1px solid #e0e0e0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onConfirm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-btn modal-btn-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-btn modal-btn-confirm"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
border: "none",
|
||||||
|
background: "#007bff",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/common/components/system/LoadingOverlay.tsx
Normal file
46
src/common/components/system/LoadingOverlay.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 전역 로딩 Overlay 컴포넌트
|
||||||
|
*
|
||||||
|
* 설계 목적:
|
||||||
|
* - "표현 전용 컴포넌트"
|
||||||
|
* - 로딩 상태를 내부에서 판단하지 않음
|
||||||
|
* - visible prop만 보고 렌더링 여부 결정
|
||||||
|
* - 디자인 시스템에 맞게 교체 가능
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - 로딩 상태를 내부에서 판단하지 않음
|
||||||
|
* - visible prop만 보고 렌더링 여부 결정
|
||||||
|
*/
|
||||||
|
export interface LoadingOverlayProps {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.35)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 9999,
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
padding: "20px 40px",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/common/components/system/LoadingProvider.tsx
Normal file
72
src/common/components/system/LoadingProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
|
||||||
|
import { setLoadingControls } from "../api/interceptors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전역 로딩 Provider
|
||||||
|
*
|
||||||
|
* 설계 목적:
|
||||||
|
* - 네트워크 통신이 발생하면 사용자는 항상 "지금 처리 중"임을 인지
|
||||||
|
* - 페이지별 로딩 처리로 인한 UX 불일치와 코드 중복 제거
|
||||||
|
* - Vibe Coding / AI 코드 생성 시 "각 페이지에서 로딩 처리" 패턴 원천 차단
|
||||||
|
*
|
||||||
|
* 동작 원칙:
|
||||||
|
* - 요청 시작 시 count +1
|
||||||
|
* - 요청 종료 시 count -1
|
||||||
|
* - count > 0 이면 로딩 표시
|
||||||
|
* - 중첩 요청에서도 안정적으로 동작
|
||||||
|
*
|
||||||
|
* 책임:
|
||||||
|
* - 로딩 상태 단일 관리
|
||||||
|
* - Axios Interceptor와 직접 연결
|
||||||
|
* - UI 표현은 담당하지 않음
|
||||||
|
*/
|
||||||
|
interface LoadingContextValue {
|
||||||
|
start: () => void;
|
||||||
|
end: () => void;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingContext = createContext<LoadingContextValue>({
|
||||||
|
start: () => {},
|
||||||
|
end: () => {},
|
||||||
|
visible: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function LoadingProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const end = useCallback(() => {
|
||||||
|
setCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// interceptor에 로딩 제어 함수 등록
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingControls(start, end);
|
||||||
|
return () => {
|
||||||
|
setLoadingControls(() => {}, () => {});
|
||||||
|
};
|
||||||
|
}, [start, end]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
visible: count > 0,
|
||||||
|
}),
|
||||||
|
[start, end, count]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</LoadingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoading() {
|
||||||
|
return useContext(LoadingContext);
|
||||||
|
}
|
||||||
103
src/common/components/system/Toast.tsx
Normal file
103
src/common/components/system/Toast.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 메시지 시스템
|
||||||
|
*
|
||||||
|
* 목적:
|
||||||
|
* - 사용자에게 "즉시 피드백" 제공
|
||||||
|
* - 성공/실패/경고 메시지를 화면 어디서든 통일되게 출력
|
||||||
|
* - 페이지마다 alert/console.log로 처리하는 것을 금지
|
||||||
|
*
|
||||||
|
* 규칙:
|
||||||
|
* - 성공: 저장/삭제/등록 완료 정도만 짧게 표시
|
||||||
|
* - 실패: 네트워크/권한/유효성 등 최소 정보로 표시
|
||||||
|
* - 경고: 사용자 선택이 필요할 때만
|
||||||
|
*/
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type?: "success" | "error" | "warning" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
push: (message: string, type?: ToastItem["type"]) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToastContext = createContext<ToastContextValue>({
|
||||||
|
push: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const remove = useCallback((id: string) => {
|
||||||
|
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(message: string, type: ToastItem["type"] = "info") => {
|
||||||
|
const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
|
setItems((prev) => [...prev, { id, message, type }]);
|
||||||
|
// 3.5초 후 자동 제거
|
||||||
|
window.setTimeout(() => remove(id), 3500);
|
||||||
|
},
|
||||||
|
[remove]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ push, remove }), [push, remove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: 14,
|
||||||
|
bottom: 14,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
zIndex: 9998,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid rgba(255,255,255,0.16)",
|
||||||
|
background:
|
||||||
|
t.type === "error"
|
||||||
|
? "rgba(220, 38, 38, 0.9)"
|
||||||
|
: t.type === "success"
|
||||||
|
? "rgba(34, 197, 94, 0.9)"
|
||||||
|
: t.type === "warning"
|
||||||
|
? "rgba(234, 179, 8, 0.9)"
|
||||||
|
: "rgba(0,0,0,0.65)",
|
||||||
|
color: "white",
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
maxWidth: 360,
|
||||||
|
cursor: "pointer",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
}}
|
||||||
|
onClick={() => remove(t.id)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
src/common/components/system/index.ts
Normal file
8
src/common/components/system/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* System 컴포넌트 통합 export
|
||||||
|
*/
|
||||||
|
export * from "./CommonModal";
|
||||||
|
export * from "./LoadingOverlay";
|
||||||
|
export * from "./LoadingProvider";
|
||||||
|
export * from "./Toast";
|
||||||
|
export * from "./useToast";
|
||||||
17
src/common/components/system/useToast.ts
Normal file
17
src/common/components/system/useToast.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { ToastContext } from "./Toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 훅
|
||||||
|
*
|
||||||
|
* ToastProvider의 Context를 사용하여 Toast 기능을 제공합니다.
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* ```tsx
|
||||||
|
* const toast = useToast();
|
||||||
|
* toast.push("메시지", "success");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext);
|
||||||
|
}
|
||||||
60
src/common/hooks/useAppError.ts
Normal file
60
src/common/hooks/useAppError.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { mapAxiosError } from "../utils/errorMapper";
|
||||||
|
import { useToast } from "../components";
|
||||||
|
import type { AppError } from "../types/error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공통 에러 처리 훅
|
||||||
|
*
|
||||||
|
* 역할:
|
||||||
|
* - AxiosError를 AppError로 변환
|
||||||
|
* - Toast 메시지 자동 표시 (옵션)
|
||||||
|
* - 에러 정보 반환
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* ```tsx
|
||||||
|
* const { handle } = useAppError();
|
||||||
|
*
|
||||||
|
* try {
|
||||||
|
* await apiCall();
|
||||||
|
* } catch (error) {
|
||||||
|
* const appError = handle(error);
|
||||||
|
* // 추가 처리
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface UseAppErrorOptions {
|
||||||
|
toast?: boolean;
|
||||||
|
fallbackMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppError() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const handle = useCallback(
|
||||||
|
(error: unknown, opts?: UseAppErrorOptions): AppError => {
|
||||||
|
const appErr = mapAxiosError(error);
|
||||||
|
|
||||||
|
// Toast 표시 (기본값: true)
|
||||||
|
if (opts?.toast !== false) {
|
||||||
|
const message = opts?.fallbackMessage ?? appErr.message;
|
||||||
|
const type =
|
||||||
|
appErr.kind === "unauthorized" ||
|
||||||
|
appErr.kind === "forbidden" ||
|
||||||
|
appErr.kind === "server" ||
|
||||||
|
appErr.kind === "network" ||
|
||||||
|
appErr.kind === "timeout"
|
||||||
|
? "error"
|
||||||
|
: appErr.kind === "validation"
|
||||||
|
? "warning"
|
||||||
|
: "error";
|
||||||
|
toast.push(message, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return appErr;
|
||||||
|
},
|
||||||
|
[toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handle };
|
||||||
|
}
|
||||||
120
src/common/hooks/useFilterQuery.ts
Normal file
120
src/common/hooks/useFilterQuery.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL 기반 필터 쿼리 훅
|
||||||
|
*
|
||||||
|
* 역할:
|
||||||
|
* - URL → 상태 파싱
|
||||||
|
* - 상태 → URL 반영
|
||||||
|
* - 타입 정규화
|
||||||
|
* - page 리셋 규칙 강제 (q, f_*, size 변경 시 page=1)
|
||||||
|
* - 조합형 검색 통합
|
||||||
|
*
|
||||||
|
* 핵심 원칙:
|
||||||
|
* - 리스트 상태의 단일 진실은 URL SearchParams
|
||||||
|
* - 컴포넌트 내부 state는 보조 표현일 뿐
|
||||||
|
* - 뒤로가기/앞으로가기/새로고침 시 100% 동일 상태 복원
|
||||||
|
*
|
||||||
|
* page 리셋 규칙 (중요):
|
||||||
|
* - q 변경 시 page=1
|
||||||
|
* - f_* 변경 시 page=1
|
||||||
|
* - size 변경 시 page=1
|
||||||
|
*/
|
||||||
|
export interface FilterQueryState {
|
||||||
|
q: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
sort?: string;
|
||||||
|
filters: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterQueryUpdate {
|
||||||
|
q?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sort?: string;
|
||||||
|
filters?: Record<string, string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFilterQueryOptions {
|
||||||
|
defaults?: {
|
||||||
|
size?: number;
|
||||||
|
sort?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFilterQuery(options: UseFilterQueryOptions = {}) {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const defaults = options.defaults ?? {};
|
||||||
|
|
||||||
|
// URL → 상태 파싱
|
||||||
|
const state: FilterQueryState = {
|
||||||
|
q: params.get("q") ?? "",
|
||||||
|
page: Number(params.get("page") ?? 1) || 1,
|
||||||
|
size: Number(params.get("size") ?? defaults.size ?? 20) || 20,
|
||||||
|
sort: params.get("sort") ?? defaults.sort,
|
||||||
|
filters: Object.fromEntries(
|
||||||
|
[...params.entries()].filter(([k]) => k.startsWith("f_"))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 → URL 반영
|
||||||
|
const update = (next: FilterQueryUpdate) => {
|
||||||
|
const p = new URLSearchParams(params);
|
||||||
|
|
||||||
|
// q 변경 시 page=1 리셋
|
||||||
|
if (next.q !== undefined) {
|
||||||
|
if (next.q === "") {
|
||||||
|
p.delete("q");
|
||||||
|
} else {
|
||||||
|
p.set("q", next.q);
|
||||||
|
}
|
||||||
|
p.set("page", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// page 변경
|
||||||
|
if (next.page !== undefined) {
|
||||||
|
p.set("page", String(next.page));
|
||||||
|
}
|
||||||
|
|
||||||
|
// size 변경 시 page=1 리셋
|
||||||
|
if (next.size !== undefined) {
|
||||||
|
p.set("size", String(next.size));
|
||||||
|
p.set("page", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort 변경
|
||||||
|
if (next.sort !== undefined) {
|
||||||
|
if (next.sort === "" || next.sort == null) {
|
||||||
|
p.delete("sort");
|
||||||
|
} else {
|
||||||
|
p.set("sort", next.sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filters 변경 시 page=1 리셋
|
||||||
|
if (next.filters !== undefined) {
|
||||||
|
// 기존 f_* 파라미터 제거
|
||||||
|
const keysToDelete = [...p.keys()].filter((k) => k.startsWith("f_"));
|
||||||
|
keysToDelete.forEach((k) => p.delete(k));
|
||||||
|
|
||||||
|
// 새로운 filters 설정
|
||||||
|
Object.entries(next.filters).forEach(([k, v]) => {
|
||||||
|
if (v == null || v === "") {
|
||||||
|
p.delete(k);
|
||||||
|
} else {
|
||||||
|
p.set(k, String(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// filters 변경 시 page=1 리셋
|
||||||
|
if (Object.keys(next.filters).length > 0) {
|
||||||
|
p.set("page", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setParams(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { state, update };
|
||||||
|
}
|
||||||
23
src/common/query/QueryProvider.tsx
Normal file
23
src/common/query/QueryProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "./queryClient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TanStack Query Provider 래퍼
|
||||||
|
*
|
||||||
|
* 옵션으로 사용 가능하도록 래핑
|
||||||
|
* 환경 변수 VITE_USE_REACT_QUERY로 제어
|
||||||
|
*/
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||||
|
|
||||||
|
if (!useReactQuery) {
|
||||||
|
// TanStack Query 비활성화 시 그대로 children 반환
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/common/query/hooks/useDocuments.ts
Normal file
48
src/common/query/hooks/useDocuments.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../keys";
|
||||||
|
import { http } from "../../api/http";
|
||||||
|
import { API } from "../../api/endpoints";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 목록 Query Hook
|
||||||
|
*
|
||||||
|
* 주의:
|
||||||
|
* - TanStack Query가 비활성화된 경우 사용 불가
|
||||||
|
* - 환경 변수 VITE_USE_REACT_QUERY=true 필요
|
||||||
|
*/
|
||||||
|
export interface DocumentRow {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentListResponse {
|
||||||
|
items: DocumentRow[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDocuments(params: {
|
||||||
|
q: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
sort?: string;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
}) {
|
||||||
|
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||||
|
|
||||||
|
if (!useReactQuery) {
|
||||||
|
throw new Error(
|
||||||
|
"TanStack Query is not enabled. Set VITE_USE_REACT_QUERY=true in .env"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.documents(params),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await http.get(API.DOCUMENT.LIST, { params });
|
||||||
|
// ApiResponse 구조에 맞게 data.data 접근
|
||||||
|
return (res.data?.data ?? { items: [], total: 0 }) as DocumentListResponse;
|
||||||
|
},
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
33
src/common/query/keys.ts
Normal file
33
src/common/query/keys.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* QueryKey 팩토리
|
||||||
|
*
|
||||||
|
* 원칙:
|
||||||
|
* - queryKey의 shape는 항상 동일해야 함
|
||||||
|
* - params는 URL state를 그대로 사용하되 "정규화된 값"만 포함
|
||||||
|
* - filters는 useFilterQuery에서 만든 "빈값 제거/타입 통일"된 값을 사용
|
||||||
|
*
|
||||||
|
* 정리:
|
||||||
|
* - 리스트: ["documents", {q,page,size,sort,filters}]
|
||||||
|
* - 상세: ["document", id]
|
||||||
|
*/
|
||||||
|
export const qk = {
|
||||||
|
documents: (params: {
|
||||||
|
q: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
sort?: string;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
}) => ["documents", params] as const,
|
||||||
|
|
||||||
|
document: (id: string | number) => ["document", id] as const,
|
||||||
|
|
||||||
|
users: (params: {
|
||||||
|
q: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
sort?: string;
|
||||||
|
filters: Record<string, any>;
|
||||||
|
}) => ["users", params] as const,
|
||||||
|
|
||||||
|
user: (id: string | number) => ["user", id] as const,
|
||||||
|
};
|
||||||
28
src/common/query/queryClient.ts
Normal file
28
src/common/query/queryClient.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TanStack Query Client 설정
|
||||||
|
*
|
||||||
|
* 정책:
|
||||||
|
* - staleTime 30초: 뒤로가기 UX에 유리 (짧은 시간은 캐시를 신선하게 취급)
|
||||||
|
* - retry 1: 네트워크 튐 보완
|
||||||
|
* - refetchOnWindowFocus false: 업무 시스템에서 포커스만으로 리패치 반복 방지
|
||||||
|
*
|
||||||
|
* 전제:
|
||||||
|
* - 검색/필터/페이지/정렬 상태는 URL이 단일 진실
|
||||||
|
* - TanStack Query는 URL state를 입력으로 받아 fetch
|
||||||
|
* - 전역 로딩(Overlay)은 axios interceptor가 처리
|
||||||
|
* - Query의 loading은 TableSkeleton 같은 "영역 UX"에만 사용
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
13
src/common/types/api.ts
Normal file
13
src/common/types/api.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* API 응답 타입 정의
|
||||||
|
*
|
||||||
|
* 백엔드 ApiResponse<T> 구조와 일치
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
29
src/common/types/error.ts
Normal file
29
src/common/types/error.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 공통 에러 타입
|
||||||
|
*
|
||||||
|
* 설계 목적:
|
||||||
|
* - AxiosError를 공통 구조(AppError)로 매핑
|
||||||
|
* - 사용자에게는 안전한 메시지만 표시
|
||||||
|
* - 필요 시 토스트로 즉시 피드백
|
||||||
|
* - 로깅은 민감정보를 완전히 제거한 형태로만
|
||||||
|
*
|
||||||
|
* 규정:
|
||||||
|
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||||
|
* - 서버 message는 내부 정보(SQL, stack trace, 경로 등)를 포함할 수 있음
|
||||||
|
* - 프론트는 상태코드/코드 기반으로 사용자 메시지를 통제
|
||||||
|
*/
|
||||||
|
export type AppError = {
|
||||||
|
kind:
|
||||||
|
| "network"
|
||||||
|
| "timeout"
|
||||||
|
| "unauthorized"
|
||||||
|
| "forbidden"
|
||||||
|
| "notfound"
|
||||||
|
| "validation"
|
||||||
|
| "server"
|
||||||
|
| "unknown";
|
||||||
|
status?: number;
|
||||||
|
code?: string;
|
||||||
|
message: string;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
115
src/common/utils/errorMapper.ts
Normal file
115
src/common/utils/errorMapper.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import type { AppError } from "../types/error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AxiosError → AppError 매핑
|
||||||
|
*
|
||||||
|
* 규정:
|
||||||
|
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||||
|
* - 상태코드/코드 기반으로 사용자 메시지 통제
|
||||||
|
* - 에러 종류를 최소 공통 분류로 통일
|
||||||
|
*/
|
||||||
|
function isTimeout(err: AxiosError): boolean {
|
||||||
|
return (
|
||||||
|
err.code === "ECONNABORTED" ||
|
||||||
|
(typeof err.message === "string" &&
|
||||||
|
err.message.toLowerCase().includes("timeout"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAxiosError(error: unknown): AppError {
|
||||||
|
const ax = error as AxiosError<any>;
|
||||||
|
|
||||||
|
// 응답이 없는 경우 (네트워크 오류, 타임아웃)
|
||||||
|
if (!ax.response) {
|
||||||
|
if (isTimeout(ax)) {
|
||||||
|
return {
|
||||||
|
kind: "timeout",
|
||||||
|
message: "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "network",
|
||||||
|
message: "네트워크 오류가 발생했습니다. 연결 상태를 확인해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = ax.response.status;
|
||||||
|
const data = ax.response.data;
|
||||||
|
|
||||||
|
// 서버 메시지 추출 (안전하게)
|
||||||
|
const serverMessage: string | undefined =
|
||||||
|
(typeof data?.error?.message === "string" && data.error.message) ||
|
||||||
|
(typeof data?.message === "string" && data.message) ||
|
||||||
|
(typeof data?.error === "string" && data.error) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
// 상태코드별 에러 분류
|
||||||
|
if (status === 401) {
|
||||||
|
return {
|
||||||
|
kind: "unauthorized",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "로그인이 필요합니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
return {
|
||||||
|
kind: "forbidden",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "권한이 없습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 404) {
|
||||||
|
return {
|
||||||
|
kind: "notfound",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "요청한 리소스를 찾을 수 없습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 409) {
|
||||||
|
return {
|
||||||
|
kind: "validation",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "충돌이 발생했습니다. 다시 시도해주세요.",
|
||||||
|
details: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 429) {
|
||||||
|
return {
|
||||||
|
kind: "server",
|
||||||
|
status,
|
||||||
|
message: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx 클라이언트 오류
|
||||||
|
if (status >= 400 && status < 500) {
|
||||||
|
return {
|
||||||
|
kind: "validation",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "요청 값이 올바르지 않습니다.",
|
||||||
|
details: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5xx 서버 오류
|
||||||
|
if (status >= 500) {
|
||||||
|
return {
|
||||||
|
kind: "server",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 알 수 없는 오류
|
||||||
|
return {
|
||||||
|
kind: "unknown",
|
||||||
|
status,
|
||||||
|
message: serverMessage ?? "알 수 없는 오류가 발생했습니다.",
|
||||||
|
details: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
src/index.css
Normal file
68
src/index.css
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main.tsx
Normal file
51
src/main.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./app/App.tsx";
|
||||||
|
import { LoadingProvider, useLoading, LoadingOverlay, ToastProvider } from "./common/components";
|
||||||
|
import { AuthProvider } from "./common/auth/AuthProvider";
|
||||||
|
import { QueryProvider } from "./common/query/QueryProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root 컴포넌트
|
||||||
|
* - LoadingOverlay 표시
|
||||||
|
*/
|
||||||
|
export function Root() {
|
||||||
|
const { visible } = useLoading();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingOverlay visible={visible} />
|
||||||
|
<App />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider 조합 순서 (구조적으로 고정):
|
||||||
|
* 1. BrowserRouter (라우팅)
|
||||||
|
* 2. LoadingProvider (최상단에서 모든 요청 감시)
|
||||||
|
* 3. QueryProvider (TanStack Query - 옵션, 환경 변수로 제어)
|
||||||
|
* 4. AuthProvider (서버 상태 기반)
|
||||||
|
* 5. ToastProvider (토스트 메시지)
|
||||||
|
* 6. Root (App + LoadingOverlay)
|
||||||
|
*
|
||||||
|
* TanStack Query 활성화:
|
||||||
|
* - .env.development 또는 .env.production에 VITE_USE_REACT_QUERY=true 추가
|
||||||
|
*/
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<LoadingProvider>
|
||||||
|
<QueryProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<Root />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</LoadingProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user