first commit
This commit is contained in:
112
.gitignore
vendored
Normal file
112
.gitignore
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
############################################
|
||||||
|
# Logs
|
||||||
|
############################################
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Build output
|
||||||
|
############################################
|
||||||
|
/dist
|
||||||
|
/dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Node modules
|
||||||
|
############################################
|
||||||
|
/node_modules/
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Environment files
|
||||||
|
############################################
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
|
.env.backup
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# IDE & Editor settings
|
||||||
|
############################################
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
.cursor/
|
||||||
|
.fleet/
|
||||||
|
.zed/
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# OS & 임시 파일
|
||||||
|
############################################
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
desktop.ini
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.old
|
||||||
|
*.orig
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Test / Coverage / Cache
|
||||||
|
############################################
|
||||||
|
*.cache
|
||||||
|
*.coverage
|
||||||
|
*.out
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.seed.php
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# 업로드 / 백업 / 데이터
|
||||||
|
############################################
|
||||||
|
/backup/
|
||||||
|
/backups/
|
||||||
|
**/data/*
|
||||||
|
!**/data/
|
||||||
|
!**/data/.gitkeep
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# 맵 파일 (소스맵)
|
||||||
|
############################################
|
||||||
|
*.map
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# 문서/이미지/미디어 파일 제외 (업로드 방지)
|
||||||
|
############################################
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.png
|
||||||
|
*.gif
|
||||||
|
*.bmp
|
||||||
|
*.svg
|
||||||
|
*.webp
|
||||||
|
*.ico
|
||||||
|
|
||||||
|
*.pdf
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
*.xls
|
||||||
|
*.xlsx
|
||||||
|
*.ppt
|
||||||
|
*.pptx
|
||||||
|
*.hwp
|
||||||
|
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.ogg
|
||||||
|
*.mp4
|
||||||
|
*.avi
|
||||||
|
*.mov
|
||||||
|
*.wmv
|
||||||
|
*.mkv
|
||||||
474
CURRENT_WORKS.md
Normal file
474
CURRENT_WORKS.md
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
# React 프론트엔드 작업 현황
|
||||||
|
|
||||||
|
## 2025-10-13 (일) - React 프론트엔드 프로젝트 초기 셋팅 완료
|
||||||
|
|
||||||
|
### ✅ 완료된 작업
|
||||||
|
|
||||||
|
#### 1. 프로젝트 초기화 및 의존성 설치
|
||||||
|
- Vite + React 19 + TypeScript 5 프로젝트 생성
|
||||||
|
- 모든 필수 의존성 설치 완료 (242개 패키지, 0 vulnerabilities)
|
||||||
|
|
||||||
|
**설치된 주요 패키지:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 프로젝트 구조 생성
|
||||||
|
완성된 디렉토리 구조:
|
||||||
|
```
|
||||||
|
react/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # 재사용 가능한 UI 컴포넌트
|
||||||
|
│ ├── pages/ # 라우트별 페이지 컴포넌트
|
||||||
|
│ ├── hooks/ # Custom React Hooks
|
||||||
|
│ ├── services/ # API 서비스 레이어
|
||||||
|
│ │ └── api.ts # Auth, Tenant API + CRUD 헬퍼
|
||||||
|
│ ├── stores/ # Zustand 스토어
|
||||||
|
│ │ └── auth.ts # 인증 스토어 (persist 포함)
|
||||||
|
│ ├── utils/ # 유틸리티 함수
|
||||||
|
│ ├── types/ # TypeScript 타입 정의
|
||||||
|
│ │ └── api.ts # API 응답 타입
|
||||||
|
│ ├── lib/ # 라이브러리 설정
|
||||||
|
│ │ ├── utils.ts # cn(), formatDate(), getEnv()
|
||||||
|
│ │ ├── axios.ts # Axios 인스턴스 + 인터셉터
|
||||||
|
│ │ └── query-client.ts # React Query 설정
|
||||||
|
│ ├── App.tsx # 메인 앱 컴포넌트
|
||||||
|
│ ├── main.tsx # 엔트리 포인트
|
||||||
|
│ └── index.css # Tailwind CSS + 테마
|
||||||
|
├── public/ # 정적 파일
|
||||||
|
├── .env.local # 환경변수 (VITE_API_BASE_URL 등)
|
||||||
|
├── vite.config.ts # Vite 설정 (path aliases 포함)
|
||||||
|
├── tailwind.config.js # Tailwind CSS v4 설정
|
||||||
|
├── tsconfig.json # TypeScript 설정 (프로젝트 참조)
|
||||||
|
├── tsconfig.app.json # 앱 TypeScript 설정 (path aliases)
|
||||||
|
├── package.json # 의존성 정의
|
||||||
|
└── README.md # 프로젝트 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Tailwind CSS 4.x 설정
|
||||||
|
- PostCSS 플러그인 설치: `@tailwindcss/postcss`
|
||||||
|
- Tailwind CSS v4 방식으로 `@theme` 설정
|
||||||
|
- Light/Dark 모드 지원 (CSS 변수 기반)
|
||||||
|
- shadcn/ui 호환 색상 시스템
|
||||||
|
|
||||||
|
**주요 설정 파일:**
|
||||||
|
- `index.css`: `@import "tailwindcss"` + `@theme` 블록
|
||||||
|
- `postcss.config.js`: `@tailwindcss/postcss` 플러그인
|
||||||
|
- `tailwind.config.js`: content paths 정의
|
||||||
|
|
||||||
|
#### 4. Path Aliases 설정
|
||||||
|
TypeScript 및 Vite에서 `@/` 경로 별칭 사용 가능:
|
||||||
|
|
||||||
|
**tsconfig.app.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/pages/*": ["./src/pages/*"],
|
||||||
|
"@/hooks/*": ["./src/hooks/*"],
|
||||||
|
"@/services/*": ["./src/services/*"],
|
||||||
|
"@/stores/*": ["./src/stores/*"],
|
||||||
|
"@/utils/*": ["./src/utils/*"],
|
||||||
|
"@/types/*": ["./src/types/*"],
|
||||||
|
"@/lib/*": ["./src/lib/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**vite.config.ts:**
|
||||||
|
```typescript
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@/components': path.resolve(__dirname, './src/components'),
|
||||||
|
// ... 기타 aliases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Axios + API 인터셉터 구현
|
||||||
|
**파일: `src/lib/axios.ts`**
|
||||||
|
- API Key 헤더 자동 추가: `X-API-Key`
|
||||||
|
- Bearer Token 자동 추가: `Authorization: Bearer {token}`
|
||||||
|
- 401 Unauthorized 시 자동 로그아웃 + 리다이렉트
|
||||||
|
- 403/404/500 에러 핸들링
|
||||||
|
|
||||||
|
**API 서비스 구조 (`src/services/api.ts`):**
|
||||||
|
```typescript
|
||||||
|
// 인증 API
|
||||||
|
export const authApi = {
|
||||||
|
login: (email, password) => ApiResponse<LoginResponse>
|
||||||
|
logout: () => ApiResponse<null>
|
||||||
|
me: () => ApiResponse<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테넌트 API
|
||||||
|
export const tenantApi = {
|
||||||
|
switch: (tenantId) => ApiResponse<any>
|
||||||
|
list: () => ApiResponse<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제네릭 CRUD 헬퍼
|
||||||
|
export const createCrudApi = <T>(basePath: string) => {
|
||||||
|
list, get, create, update, delete
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. React Query 설정
|
||||||
|
**파일: `src/lib/query-client.ts`**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5분
|
||||||
|
gcTime: 10 * 60 * 1000 // 10분
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. Zustand 인증 스토어 구현
|
||||||
|
**파일: `src/stores/auth.ts`**
|
||||||
|
- LocalStorage persist 적용
|
||||||
|
- 사용자 정보, 토큰, 현재 테넌트 관리
|
||||||
|
- `setAuth`, `setCurrentTenant`, `logout`, `clearAuth` 액션
|
||||||
|
|
||||||
|
**사용 예시:**
|
||||||
|
```typescript
|
||||||
|
const { user, isAuthenticated, setAuth, logout } = useAuthStore()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. TypeScript 타입 정의
|
||||||
|
**파일: `src/types/api.ts`**
|
||||||
|
- `ApiResponse<T>`: 표준 API 응답 구조
|
||||||
|
- `PaginatedResponse<T>`: 페이지네이션 응답
|
||||||
|
- `User`, `Tenant`: 기본 엔티티 타입
|
||||||
|
- `LoginResponse`, `AuthUser`: 인증 관련 타입
|
||||||
|
|
||||||
|
#### 9. 유틸리티 함수
|
||||||
|
**파일: `src/lib/utils.ts`**
|
||||||
|
- `cn()`: Tailwind 클래스 병합 (clsx + tailwind-merge)
|
||||||
|
- `formatDate()`: 날짜 포맷팅 (TODO: date-fns 적용 예정)
|
||||||
|
- `delay()`: 비동기 딜레이
|
||||||
|
- `getEnv()`: 타입 안전 환경변수 접근
|
||||||
|
|
||||||
|
#### 10. Docker 설정 완료
|
||||||
|
**docker-compose.yml에 React 서비스 추가:**
|
||||||
|
```yaml
|
||||||
|
react:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ../docker/react/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ../react:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- VITE_API_BASE_URL=http://api.sam.kr
|
||||||
|
- VITE_API_KEY=${VITE_API_KEY:-}
|
||||||
|
- VITE_APP_NAME=SAM
|
||||||
|
- VITE_APP_ENV=development
|
||||||
|
networks:
|
||||||
|
- samnet
|
||||||
|
working_dir: /app
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx.conf에 dev.sam.kr 설정 추가:**
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name dev.sam.kr;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://react:5173;
|
||||||
|
# WebSocket support for Vite HMR
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dockerfile (`docker/react/Dockerfile`):**
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ../../react/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY ../../react .
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 11. 환경변수 설정
|
||||||
|
**파일: `.env.local`**
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://api.sam.kr
|
||||||
|
VITE_API_KEY=your-api-key-here
|
||||||
|
VITE_APP_NAME=SAM
|
||||||
|
VITE_APP_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 12. 빌드 검증 완료
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# ✅ Output:
|
||||||
|
# dist/index.html 0.45 kB │ gzip: 0.29 kB
|
||||||
|
# dist/assets/index-Uh-4EJ_4.css 6.56 kB │ gzip: 2.02 kB
|
||||||
|
# dist/assets/index-DmPu9Lzh.js 250.04 kB │ gzip: 79.38 kB
|
||||||
|
# ✓ built in 580ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📋 주요 의사 결정
|
||||||
|
|
||||||
|
1. **Tailwind CSS v4 채택**
|
||||||
|
- 최신 버전 사용으로 향후 호환성 보장
|
||||||
|
- `@theme` 방식 사용 (shadcn/ui 호환)
|
||||||
|
- PostCSS 플러그인: `@tailwindcss/postcss`
|
||||||
|
|
||||||
|
2. **API 인증 전략**
|
||||||
|
- 2단계 인증: API Key + Sanctum Bearer Token
|
||||||
|
- 인터셉터를 통한 자동 헤더 추가
|
||||||
|
- 401 에러 시 자동 로그아웃 처리
|
||||||
|
|
||||||
|
3. **상태 관리 분리**
|
||||||
|
- 전역 상태: Zustand (인증, UI 상태)
|
||||||
|
- 서버 상태: React Query (API 데이터)
|
||||||
|
- LocalStorage persist로 새로고침 대응
|
||||||
|
|
||||||
|
4. **Path Aliases 전략**
|
||||||
|
- `@/` 기본 경로로 통일
|
||||||
|
- 각 주요 디렉토리별 개별 alias 제공
|
||||||
|
- TypeScript + Vite 양쪽 설정 동기화
|
||||||
|
|
||||||
|
5. **Docker 개발 환경**
|
||||||
|
- Hot Reload 지원 (Vite HMR + WebSocket)
|
||||||
|
- Volume mount로 실시간 코드 반영
|
||||||
|
- node_modules 별도 volume으로 성능 최적화
|
||||||
|
|
||||||
|
### 🎯 다음 단계 (향후 작업)
|
||||||
|
|
||||||
|
1. **UI 컴포넌트 개발**
|
||||||
|
- shadcn/ui 컴포넌트 추가 설치 필요시
|
||||||
|
- 공통 컴포넌트 제작 (Button, Input, Modal 등)
|
||||||
|
|
||||||
|
2. **페이지 구현**
|
||||||
|
- 로그인 페이지
|
||||||
|
- 대시보드
|
||||||
|
- 주요 기능 페이지들
|
||||||
|
|
||||||
|
3. **라우팅 설정**
|
||||||
|
- React Router 라우트 정의
|
||||||
|
- Protected Routes (인증 필요 페이지)
|
||||||
|
- 권한 기반 라우팅
|
||||||
|
|
||||||
|
4. **API 연동 확장**
|
||||||
|
- 각 도메인별 API 서비스 추가
|
||||||
|
- React Query hooks 작성
|
||||||
|
- 에러 핸들링 강화
|
||||||
|
|
||||||
|
5. **테스트 환경 구축**
|
||||||
|
- Docker Compose로 전체 환경 테스트
|
||||||
|
- dev.sam.kr 도메인 접속 확인
|
||||||
|
|
||||||
|
### 🔧 개발 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 개발 서버 실행
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 프로덕션 빌드
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 빌드 결과 미리보기
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# Docker로 실행 (전체 환경)
|
||||||
|
cd ../docker
|
||||||
|
docker-compose up -d react
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📌 참고사항
|
||||||
|
|
||||||
|
1. **TypeScript 엄격 모드**
|
||||||
|
- `verbatimModuleSyntax` 활성화
|
||||||
|
- 타입 import 시 `type` 키워드 필수: `import type { ... }`
|
||||||
|
|
||||||
|
2. **Tailwind CSS v4 변경사항**
|
||||||
|
- `@apply` 대신 `@theme` 사용
|
||||||
|
- CSS 변수 직접 정의
|
||||||
|
- 플러그인: `@tailwindcss/postcss` 필수
|
||||||
|
|
||||||
|
3. **API 응답 구조**
|
||||||
|
- 모든 API는 `{ success, message, data }` 구조
|
||||||
|
- 메시지는 i18n 키 사용 (예: `message.created`)
|
||||||
|
|
||||||
|
4. **환경변수 접근**
|
||||||
|
- Vite에서는 `import.meta.env.VITE_*` 형식만 사용 가능
|
||||||
|
- `getEnv()` 헬퍼 함수로 타입 안전 접근
|
||||||
|
|
||||||
|
### ✅ 검증 완료 항목
|
||||||
|
|
||||||
|
- [x] npm install 성공 (0 vulnerabilities)
|
||||||
|
- [x] npm run build 성공
|
||||||
|
- [x] TypeScript 컴파일 에러 없음
|
||||||
|
- [x] Path aliases 정상 작동
|
||||||
|
- [x] Tailwind CSS 빌드 성공
|
||||||
|
- [x] Docker 설정 파일 생성
|
||||||
|
- [x] Nginx 프록시 설정 완료
|
||||||
|
- [x] 환경변수 파일 생성
|
||||||
|
- [x] **Docker 컨테이너 빌드 및 실행 완료**
|
||||||
|
- [x] **dev.sam.kr 도메인 설정 완료**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025-10-13 (일) - Docker 환경 실행 완료 + Vite 프록시 설정 수정
|
||||||
|
|
||||||
|
### Docker 컨테이너 실행
|
||||||
|
|
||||||
|
#### 문제 해결 과정 #1: Docker Build Context
|
||||||
|
**문제**: 초기 docker-compose.yml 설정에서 빌드 context가 잘못 설정됨
|
||||||
|
```yaml
|
||||||
|
# 문제 있는 설정
|
||||||
|
react:
|
||||||
|
build:
|
||||||
|
context: . # /docker 디렉토리를 context로 사용
|
||||||
|
dockerfile: ../docker/react/Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**해결**: Docker build context를 상위 디렉토리로 변경
|
||||||
|
```yaml
|
||||||
|
# 수정된 설정
|
||||||
|
react:
|
||||||
|
build:
|
||||||
|
context: .. # /SAM 디렉토리를 context로 사용
|
||||||
|
dockerfile: docker/react/Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 실행 결과
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build react
|
||||||
|
# ✅ 빌드 성공 (2.5초)
|
||||||
|
# ✅ 컨테이너 시작 완료
|
||||||
|
|
||||||
|
docker ps --filter "name=react"
|
||||||
|
# CONTAINER ID IMAGE STATUS PORTS NAMES
|
||||||
|
# 515b94586161 sam-react Up 7 seconds 5173/tcp sam-react-1
|
||||||
|
|
||||||
|
docker-compose restart nginx
|
||||||
|
# ✅ Nginx 재시작 완료 (dev.sam.kr 설정 반영)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 문제 해결 과정 #2: Vite allowedHosts 설정
|
||||||
|
**오류 메시지**:
|
||||||
|
```
|
||||||
|
Blocked request. This host ("dev.sam.kr") is not allowed.
|
||||||
|
To allow this host, add "dev.sam.kr" to `server.allowedHosts` in vite.config.js.
|
||||||
|
```
|
||||||
|
|
||||||
|
**근본 원인 분석** (devops-architect 페르소나 사용):
|
||||||
|
1. **Vite 보안 메커니즘**: Host Header를 검증하여 허용되지 않은 도메인 요청 차단
|
||||||
|
2. **Docker 환경**: 컨테이너 내부에서 실행되는 Vite가 Nginx의 `Host: dev.sam.kr` 헤더를 받음
|
||||||
|
3. **기본 설정**: `allowedHosts`가 설정되지 않아 프록시를 통한 접근 차단
|
||||||
|
|
||||||
|
**해결 방법**:
|
||||||
|
`vite.config.ts`에 다음 설정 추가:
|
||||||
|
```typescript
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
// Nginx 리버스 프록시 환경을 위한 설정
|
||||||
|
hmr: {
|
||||||
|
clientPort: 80, // HTTP 환경 (nginx port 80)
|
||||||
|
protocol: 'ws', // WebSocket (HTTP용)
|
||||||
|
host: 'dev.sam.kr', // HMR 연결 호스트
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true, // Docker 파일 감시 필수
|
||||||
|
interval: 100,
|
||||||
|
},
|
||||||
|
cors: true,
|
||||||
|
allowedHosts: [
|
||||||
|
'dev.sam.kr', // 프로덕션 도메인
|
||||||
|
'localhost', // 로컬 개발
|
||||||
|
'127.0.0.1', // 로컬 IP
|
||||||
|
'.sam.kr', // 서브도메인 와일드카드
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 변경사항**:
|
||||||
|
- ✅ `allowedHosts` 추가: dev.sam.kr 및 와일드카드 도메인 허용
|
||||||
|
- ✅ `hmr.clientPort`: 80 (HTTP 환경에 맞춤)
|
||||||
|
- ✅ `hmr.protocol`: 'ws' (HTTPS가 아닌 HTTP 사용)
|
||||||
|
- ✅ `watch.usePolling`: Docker 환경에서 파일 변경 감지
|
||||||
|
|
||||||
|
**재시작 결과**:
|
||||||
|
```bash
|
||||||
|
docker-compose restart react
|
||||||
|
# ✅ Vite 서버 정상 재시작 (88ms)
|
||||||
|
# ✅ Network: http://172.18.0.6:5173/ 리스닝 중
|
||||||
|
```
|
||||||
|
|
||||||
|
### 접속 정보
|
||||||
|
- **URL**: http://dev.sam.kr
|
||||||
|
- **포트**: 80 (Nginx 프록시) → 5173 (Vite 개발 서버)
|
||||||
|
- **HMR**: WebSocket을 통한 Hot Module Replacement 지원 (ws://dev.sam.kr)
|
||||||
|
|
||||||
|
### 추가 검증 항목
|
||||||
|
- [x] Docker 이미지 빌드 성공 (263 packages, 0 vulnerabilities)
|
||||||
|
- [x] React 컨테이너 정상 실행 중
|
||||||
|
- [x] Nginx 프록시 설정 반영
|
||||||
|
- [x] Vite HMR WebSocket 연결 지원
|
||||||
|
- [x] **Vite allowedHosts 설정 완료**
|
||||||
|
- [x] **dev.sam.kr 접속 허용 설정 완료**
|
||||||
|
|
||||||
|
### 🔍 사용된 도구 및 방법론
|
||||||
|
- **SuperClaude 페르소나**: devops-architect (Docker + Nginx + Vite 통합 분석)
|
||||||
|
- **MCP**: 없음 (devops-architect Task 에이전트 사용)
|
||||||
|
- **Native Tools**: Edit (vite.config.ts 수정), Bash (Docker 재시작)
|
||||||
|
|
||||||
|
### 📚 학습 포인트
|
||||||
|
1. **Docker 프록시 환경에서는 반드시 `allowedHosts` 설정 필요**
|
||||||
|
2. **HMR 설정은 프로토콜(HTTP/HTTPS)에 따라 다르게 구성**
|
||||||
|
3. **복잡한 통합 작업은 SuperClaude 페르소나 활용이 필수**
|
||||||
|
4. **단순한 설정으로 보이는 작업도 전문가 분석이 오류 예방에 중요**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작업 완료 시간**: 약 30분 (설정) + 5분 (Docker 실행) + 10분 (프록시 설정 수정)
|
||||||
|
**생성된 파일 수**: 25개
|
||||||
|
**수정된 파일 수**: 1개 (vite.config.ts)
|
||||||
|
**작성된 코드 라인 수**: 약 800줄
|
||||||
|
**사용된 도구**: Vite, npm, Tailwind CSS v4, Docker, Docker Compose, devops-architect 페르소나
|
||||||
|
|
||||||
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# SAM React Frontend
|
||||||
|
|
||||||
|
React 기반 SAM 프론트엔드 애플리케이션입니다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- **Build Tool**: Vite 7.x
|
||||||
|
- **Framework**: React 19.x
|
||||||
|
- **Language**: TypeScript 5.x
|
||||||
|
- **Styling**: Tailwind CSS 4.x
|
||||||
|
- **State Management**:
|
||||||
|
- Zustand (전역 상태)
|
||||||
|
- React Query / TanStack Query v5 (서버 상태)
|
||||||
|
- **Routing**: React Router v7
|
||||||
|
- **Form**: React Hook Form + Zod
|
||||||
|
- **HTTP Client**: Axios
|
||||||
|
- **UI Components**: shadcn/ui (Radix UI 기반)
|
||||||
|
- **Icons**: Lucide React
|
||||||
|
- **Date**: date-fns
|
||||||
|
- **Table**: TanStack Table
|
||||||
|
|
||||||
|
## 개발 환경 설정
|
||||||
|
|
||||||
|
### 환경변수 설정
|
||||||
|
|
||||||
|
`.env.local` 파일을 생성하고 다음 내용을 설정하세요:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://api.sam.kr
|
||||||
|
VITE_API_KEY=your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 의존성 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 개발 서버 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
개발 서버는 `http://localhost:5173`에서 실행됩니다.
|
||||||
|
|
||||||
|
## API 연동
|
||||||
|
|
||||||
|
SAM API는 2단계 인증을 사용합니다:
|
||||||
|
1. **API Key**: `X-API-Key` 헤더 (모든 요청)
|
||||||
|
2. **Bearer Token**: `Authorization: Bearer {token}` 헤더 (로그인 후)
|
||||||
|
|
||||||
|
자세한 내용은 프로젝트 문서를 참고하세요.
|
||||||
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['recommended-latest'],
|
||||||
|
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>react</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4751
package-lock.json
generated
Normal file
4751
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "react",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-query-devtools": "^5.90.2",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.65.0",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.1.12",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.45.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
42
src/App.css
Normal file
42
src/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;
|
||||||
|
}
|
||||||
37
src/App.tsx
Normal file
37
src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { queryClient } from '@/lib/query-client'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||||
|
SAM React Frontend
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
React 프로젝트가 성공적으로 설정되었습니다.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 p-6 bg-card rounded-lg border">
|
||||||
|
<h2 className="text-2xl font-semibold mb-2">설정 완료</h2>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-card-foreground">
|
||||||
|
<li>Vite + React + TypeScript</li>
|
||||||
|
<li>Tailwind CSS with shadcn/ui theming</li>
|
||||||
|
<li>React Query for server state</li>
|
||||||
|
<li>Zustand for global state</li>
|
||||||
|
<li>Axios with interceptors</li>
|
||||||
|
<li>React Router for routing</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
52
src/index.css
Normal file
52
src/index.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Colors */
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #0a0a0a;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0a0a0a;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0a0a0a;
|
||||||
|
--color-primary: #18181b;
|
||||||
|
--color-primary-foreground: #fafafa;
|
||||||
|
--color-secondary: #f4f4f5;
|
||||||
|
--color-secondary-foreground: #18181b;
|
||||||
|
--color-muted: #f4f4f5;
|
||||||
|
--color-muted-foreground: #71717a;
|
||||||
|
--color-accent: #f4f4f5;
|
||||||
|
--color-accent-foreground: #18181b;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-border: #e4e4e7;
|
||||||
|
--color-input: #e4e4e7;
|
||||||
|
--color-ring: #18181b;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode colors (optional) */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
@theme {
|
||||||
|
--color-background: #0a0a0a;
|
||||||
|
--color-foreground: #fafafa;
|
||||||
|
--color-card: #0a0a0a;
|
||||||
|
--color-card-foreground: #fafafa;
|
||||||
|
--color-popover: #0a0a0a;
|
||||||
|
--color-popover-foreground: #fafafa;
|
||||||
|
--color-primary: #fafafa;
|
||||||
|
--color-primary-foreground: #18181b;
|
||||||
|
--color-secondary: #27272a;
|
||||||
|
--color-secondary-foreground: #fafafa;
|
||||||
|
--color-muted: #27272a;
|
||||||
|
--color-muted-foreground: #a1a1aa;
|
||||||
|
--color-accent: #27272a;
|
||||||
|
--color-accent-foreground: #fafafa;
|
||||||
|
--color-destructive: #7f1d1d;
|
||||||
|
--color-destructive-foreground: #fafafa;
|
||||||
|
--color-border: #27272a;
|
||||||
|
--color-input: #27272a;
|
||||||
|
--color-ring: #d4d4d8;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/lib/axios.ts
Normal file
73
src/lib/axios.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { API_BASE_URL, API_KEY } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios instance with default configuration
|
||||||
|
*/
|
||||||
|
export const axiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor - Add API Key and Bearer token
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
// Add API Key header
|
||||||
|
if (API_KEY) {
|
||||||
|
config.headers['X-API-Key'] = API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Bearer token if available
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor - Handle common errors
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
// Handle 401 Unauthorized - redirect to login
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 403 Forbidden
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
console.error('Access forbidden:', error.response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 404 Not Found
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
console.error('Resource not found:', error.config?.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 500 Internal Server Error
|
||||||
|
if (error.response?.status === 500) {
|
||||||
|
console.error('Server error:', error.response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default axiosInstance
|
||||||
18
src/lib/query-client.ts
Normal file
18
src/lib/query-client.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Query client configuration
|
||||||
|
*/
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
43
src/lib/utils.ts
Normal file
43
src/lib/utils.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind CSS classes with conflict resolution
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date using date-fns
|
||||||
|
*/
|
||||||
|
export function formatDate(date: Date | string, _format: string = 'yyyy-MM-dd'): string {
|
||||||
|
// TODO: Implement using date-fns when needed
|
||||||
|
return new Date(date).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution (useful for testing loading states)
|
||||||
|
*/
|
||||||
|
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get environment variable with type safety
|
||||||
|
*/
|
||||||
|
export function getEnv(key: string, defaultValue?: string): string {
|
||||||
|
const value = import.meta.env[key]
|
||||||
|
if (value === undefined && defaultValue === undefined) {
|
||||||
|
throw new Error(`Environment variable ${key} is not defined`)
|
||||||
|
}
|
||||||
|
return value ?? defaultValue ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API base URL
|
||||||
|
*/
|
||||||
|
export const API_BASE_URL = getEnv('VITE_API_BASE_URL', 'http://api.sam.kr')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key
|
||||||
|
*/
|
||||||
|
export const API_KEY = getEnv('VITE_API_KEY', '')
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
85
src/services/api.ts
Normal file
85
src/services/api.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import axiosInstance from '@/lib/axios'
|
||||||
|
import type { ApiResponse, LoginResponse } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth API Service
|
||||||
|
*/
|
||||||
|
export const authApi = {
|
||||||
|
/**
|
||||||
|
* Login with email and password
|
||||||
|
*/
|
||||||
|
login: async (email: string, password: string): Promise<ApiResponse<LoginResponse>> => {
|
||||||
|
const response = await axiosInstance.post<ApiResponse<LoginResponse>>('/v1/login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout current user
|
||||||
|
*/
|
||||||
|
logout: async (): Promise<ApiResponse<null>> => {
|
||||||
|
const response = await axiosInstance.post<ApiResponse<null>>('/v1/logout')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user profile
|
||||||
|
*/
|
||||||
|
me: async (): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant API Service
|
||||||
|
*/
|
||||||
|
export const tenantApi = {
|
||||||
|
/**
|
||||||
|
* Switch to different tenant
|
||||||
|
*/
|
||||||
|
switch: async (tenantId: number): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await axiosInstance.post<ApiResponse<any>>(`/v1/tenants/${tenantId}/switch`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of tenants for current user
|
||||||
|
*/
|
||||||
|
list: async (): Promise<ApiResponse<any>> => {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me/tenants')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic API helper for CRUD operations
|
||||||
|
*/
|
||||||
|
export const createCrudApi = <T>(basePath: string) => ({
|
||||||
|
list: async (params?: Record<string, any>): Promise<ApiResponse<T[]>> => {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<T[]>>(basePath, { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: number | string): Promise<ApiResponse<T>> => {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<T>>(`${basePath}/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: Partial<T>): Promise<ApiResponse<T>> => {
|
||||||
|
const response = await axiosInstance.post<ApiResponse<T>>(basePath, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number | string, data: Partial<T>): Promise<ApiResponse<T>> => {
|
||||||
|
const response = await axiosInstance.put<ApiResponse<T>>(`${basePath}/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number | string): Promise<ApiResponse<null>> => {
|
||||||
|
const response = await axiosInstance.delete<ApiResponse<null>>(`${basePath}/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
83
src/stores/auth.ts
Normal file
83
src/stores/auth.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { AuthUser, Tenant } from '@/types/api'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: AuthUser | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
currentTenant: Tenant | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthActions {
|
||||||
|
setAuth: (user: AuthUser, token: string) => void
|
||||||
|
setCurrentTenant: (tenant: Tenant) => void
|
||||||
|
logout: () => void
|
||||||
|
clearAuth: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication store using Zustand with persistence
|
||||||
|
*/
|
||||||
|
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
// Initial state
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentTenant: null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setAuth: (user, token) => {
|
||||||
|
localStorage.setItem('auth_token', token)
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
currentTenant: user.current_tenant,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentTenant: (tenant) => {
|
||||||
|
set((state) => ({
|
||||||
|
currentTenant: tenant,
|
||||||
|
user: state.user
|
||||||
|
? { ...state.user, current_tenant: tenant }
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentTenant: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth: () => {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_user')
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentTenant: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
user: state.user,
|
||||||
|
token: state.token,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
currentTenant: state.currentTenant,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
85
src/types/api.ts
Normal file
85
src/types/api.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Standard API response structure (from SAM API)
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API error response
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
success: false
|
||||||
|
message: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata
|
||||||
|
*/
|
||||||
|
export interface PaginationMeta {
|
||||||
|
current_page: number
|
||||||
|
last_page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated API response
|
||||||
|
*/
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[]
|
||||||
|
meta: PaginationMeta
|
||||||
|
links: {
|
||||||
|
first: string
|
||||||
|
last: string
|
||||||
|
prev: string | null
|
||||||
|
next: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User type
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant type
|
||||||
|
*/
|
||||||
|
export interface Tenant {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth login response
|
||||||
|
*/
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
tenants: Tenant[]
|
||||||
|
current_tenant: Tenant | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth user with tenant info
|
||||||
|
*/
|
||||||
|
export interface AuthUser extends User {
|
||||||
|
current_tenant: Tenant | null
|
||||||
|
tenants: Tenant[]
|
||||||
|
}
|
||||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
// Note: Tailwind v4 has built-in support for forms and typography
|
||||||
|
// These plugins are kept for compatibility
|
||||||
|
],
|
||||||
|
}
|
||||||
42
tsconfig.app.json
Normal file
42
tsconfig.app.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/components/*": ["./src/components/*"],
|
||||||
|
"@/pages/*": ["./src/pages/*"],
|
||||||
|
"@/hooks/*": ["./src/hooks/*"],
|
||||||
|
"@/services/*": ["./src/services/*"],
|
||||||
|
"@/stores/*": ["./src/stores/*"],
|
||||||
|
"@/utils/*": ["./src/utils/*"],
|
||||||
|
"@/types/*": ["./src/types/*"],
|
||||||
|
"@/lib/*": ["./src/lib/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* 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"]
|
||||||
|
}
|
||||||
46
vite.config.ts
Normal file
46
vite.config.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@/components': path.resolve(__dirname, './src/components'),
|
||||||
|
'@/pages': path.resolve(__dirname, './src/pages'),
|
||||||
|
'@/hooks': path.resolve(__dirname, './src/hooks'),
|
||||||
|
'@/services': path.resolve(__dirname, './src/services'),
|
||||||
|
'@/stores': path.resolve(__dirname, './src/stores'),
|
||||||
|
'@/utils': path.resolve(__dirname, './src/utils'),
|
||||||
|
'@/types': path.resolve(__dirname, './src/types'),
|
||||||
|
'@/lib': path.resolve(__dirname, './src/lib'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0', // Docker 컨테이너 내 모든 네트워크 인터페이스에서 접근 허용
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
// Nginx 리버스 프록시를 통한 도메인 접근 허용
|
||||||
|
hmr: {
|
||||||
|
clientPort: 80, // HTTP 사용 (nginx가 port 80에서 리스닝)
|
||||||
|
protocol: 'ws', // HTTP 환경에서는 WebSocket (ws) 사용
|
||||||
|
host: 'dev.sam.kr', // HMR 연결 시 사용할 호스트
|
||||||
|
},
|
||||||
|
// 파일 감시 설정 (Docker 환경에서 필수)
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 100, // 폴링 간격 (ms)
|
||||||
|
},
|
||||||
|
// CORS 설정 (필요한 경우)
|
||||||
|
cors: true,
|
||||||
|
// 리버스 프록시를 통한 도메인 접근 허용
|
||||||
|
allowedHosts: [
|
||||||
|
'dev.sam.kr',
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'.sam.kr', // 서브도메인 와일드카드
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user